Camera与Player
1. 核心链路总览:Camera ↔ Player 对照图
理解链路的最快方式是将采集端 和播放端进行对比。它们就像一个沙漏的两端,中间通过文件或网络流连接。
| 环节 | Camera 采集链路(生产者) | Player 播放链路(消费者) |
|---|---|---|
| 物理层 | Sensor 采集: 光信号转电信号 | Screen/Speaker: 电信号转光/声 |
| 原始数据 | YUV / RGB: 原始像素帧 | Surface / AudioTrack: 渲染/输出 |
| 核心处理 | Encoder (编码): 压缩数据 (H.264/H.265) | Decoder (解码): 解压数据 |
| 容器层 | Muxer (封装): 打包成 MP4/TS | Demuxer (解封装): 拆解音视频流 |
| 持久化 | File / Network: 存储或直播推流 | DataSource: 读取本地文件或 URL |
2. 深度拆解:数据流的走向
A. Camera 采集流程:从 Sensor 到文件
-
采集 (Capture) : Camera Sensor 捕获原始光电信号,通过 ISP(图像信号处理)输出 YUV 或 RGB 格式。
-
缓冲区传递 (Buffer Queue) : Android 中 Camera 数据通常通过
SurfaceTexture或ImageReader传递。为了性能,通常采用 Zero-copy (零拷贝) 机制,直接传递 Buffer 的句柄。 -
编码 (Encoding) : 使用
MediaCodec将庞大的 YUV 数据压缩为 H.264/H.265 编码帧(I/P/B 帧)。 -
封装 (Muxing) : 使用
MediaMuxer将编码后的视频 H.264 流、音频 AAC 流以及元数据(Metadata)打包进容器(如 .mp4)。
B. Player 播放流程:从 DataSource 到屏幕
-
解封装 (Demuxing) :
MediaExtractor读取 MP4,根据索引分离出音频轨道和视频轨道。 -
解码 (Decoding) :
MediaCodec接收压缩帧,解码回原始的 YUV/RGB 像素数据。 -
渲染 (Rendering):
-
视频 : 将解码后的 Buffer 发送到
Surface,由 SurfaceFlinger 合成并提交给显示驱动。 -
音频 : 通过
AudioTrack将 PCM 数据送入音频混音器(AudioFlinger)。
-
3. 关键环节:时间戳 (PTS/DTS) 与同步
在音视频中,时间就是一切。
-
PTS (Presentation Time Stamp): 告诉渲染器这一帧应该在什么时候显示。
-
DTS (Decoding Time Stamp): 告诉解码器这一帧应该什么时候解码(在有 B 帧的情况下,DTS 和 PTS 顺序不同)。
-
音画同步 (AV-Sync):
-
通常以音频时钟 (Audio Master) 为基准,因为人类对声音的停顿更敏感。
-
视频帧会根据当前音频播放的时间点,决定是"快进(跳帧)"、"准时渲染"还是"等待(重复上一帧)"。
-
4. 性能瓶颈与常见问题
在实际开发(JD 对齐)中,你需要关注以下高频痛点:
-
内存拷贝过多: 频繁在 Java 层和 Native 层、或者 CPU 和 GPU 之间拷贝大块像素数据,会导致掉帧。
- 优化 : 使用
HardwareBuffer或Surface直接传递。
- 优化 : 使用
-
编解码延迟: 硬编硬解性能好但存在兼容性问题;软编软解(FFmpeg)兼容性好但耗电、发热。
-
Buffer 堆积: 采集端生产太快而编码太慢,导致内存溢出(OOM)。这个需要另外研究
-
时间戳抖动: 采集时 PTS 赋值不准,会导致播放时视觉卡顿(Jitter)。
整个多媒体链路是一个从模拟到数字再到模拟的过程。采集端通过 Camera Sensor 获取 YUV 原始数据,经过 MediaCodec 硬编码压缩,再由 MediaMuxer 封装成容器格式。播放端则是逆向过程:Extractor 解封装,MediaCodec 解码,最后通过 PTS 时间戳对齐,将画面渲染到 Surface,声音输出到 AudioTrack。核心难点在于零拷贝下的 Buffer 流转效率以及音画同步的精度控制。
GOP
当你看着一个 MP4 文件时,它在你眼里不应该只是一个"视频文件",而应该像一辆载满了不同货物的火车。
你现在最困惑的其实是:视频内部是如何组织这些"碎块"数据的,以及为什么要有 GOP 这种结构?
为了帮你建立直观理解,我们把 MP4 文件拆开来看:
1. 轨道 (Track):火车的车厢
一个多媒体文件(容器)里通常包含多个轨道,它们是并行运行的:
-
视频轨 (Video Track):存 H.264/H.265 编码后的画面。
-
音频轨 (Audio Track):存 AAC/Opus 编码后的声音。
-
字幕轨 (Subtitle Track):存时间戳和文本。
核心逻辑:播放器的工作就是像"拉链"一样,把这些独立的轨道按时间线(PTS)对齐,然后同时喂给显卡和声卡。
2. GOP (Group of Pictures):视频压缩的"基本单位"
这是你最需要掌握的细节。视频之所以能压缩得很小,是因为画面之间是有冗余的(比如背景一直没变)。
GOP(画面组) 就是一组连续的、有参考关系的帧。它包含三种核心成员:
-
I 帧 (Intra-coded picture) :关键帧。它是完整的图片,不需要参考任何其他帧就能解码。它是 GOP 的开头。
-
P 帧 (Predictive picture) :前向预测帧。它只记录与前一帧的"差异"。如果要看这一帧,必须先解出前一帧。
-
B 帧 (Bi-predictive picture) :双向预测帧。它既参考前面的帧,也参考后面的帧,压缩率最高,但最消耗性能。
JD 考点 / 常见问题:
GOP 越长越好吗? GOP 越长,压缩率越高(文件越小),但在直播中延迟会变大,且进度条拖动(Seek)时会变慢,因为播放器必须找到最近的 I 帧才能开始画面绘制。
直播秒开优化:通常就是通过缓存最近的一个 GOP 给观众来实现的。
3. 帧率、码率与分辨率:视频的"三围"
这三个参数决定了轨道的质量:
| 参数 | 类比 | 对体验的影响 |
|---|---|---|
| 分辨率 (Resolution) | 画布大小 | 1080P 比 720P 像素更多,更清晰。 |
| 帧率 (FPS) | 连环画翻动的速度 | 24帧是电影感,60帧极度丝滑(游戏常用)。 |
| 码率 (Bitrate) | 每秒消耗的数据量 | 最关键! 即使是 4K 分辨率,如果码率太低,画面也会全是"马赛克"。 |
4. 封装格式 vs 编码格式:瓶子与酒
这是初学者最容易搞混的地方:
-
编码格式 (H.264 / H.265 / AV1):这是"酒"。决定了视频压缩得好不好,能不能在手机上流畅运行(硬解支持)。
-
封装格式 (MP4 / MKV / FLV):这是"瓶子"。决定了它能不能支持流媒体播放、能不能装多个音轨、在 Android 上的兼容性。
口述练习:如果你在面试中
问:请解释一下什么是 GOP,它对播放性能有什么影响?
答: "GOP 是画面组,是以 I 帧开始的一组具有参考关系的视频帧。I 帧是全帧数据,P/B 帧是差异数据。GOP 的大小直接影响压缩效率和交互体验:GOP 越长,文件越小,但拖动进度条时的寻址时间(Seek Latency)就越长,因为必须从最近的关键帧开始重建画面。"
1. 轨道分离与选择 (Demuxing)
Java
// 1. 初始化解封装器
MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource(filePath); // 设置文件路径
// 2. 遍历所有轨道(轨道里装的是"酒"的信息)
int trackCount = extractor.getTrackCount();
int videoTrackIndex = -1;
for (int i = 0; i < trackCount; i++) {
MediaFormat format = extractor.getTrackFormat(i);
String mime = format.getString(MediaFormat.KEY_MIME);
// 寻找视频轨
if (mime.startsWith("video/")) {
videoTrackIndex = i;
// 这里可以获取 GOP 相关的参数
int width = format.getInteger(MediaFormat.KEY_WIDTH);
int height = format.getInteger(MediaFormat.KEY_HEIGHT);
break;
}
}
// 3. 锁定视频轨,告诉 Extractor 接下来只读这一路数据
extractor.selectTrack(videoTrackIndex);
2. 按帧读取数据 (The Data Flow)
接下来的过程就像在传送带上取货。每一帧(Sample)被取出来时,都带着它的 PTS 和 标志位。
Java
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024); // 准备一个 1MB 的盆接数据
while (true) {
// 读取当前帧的数据到 buffer
int sampleSize = extractor.readSampleData(buffer, 0);
if (sampleSize < 0) {
break; // 文件读完了
}
// 获取关键信息
long presentationTimeUs = extractor.getSampleTime(); // 这一帧的 PTS (显示时间)
int flags = extractor.getSampleFlags(); // 这一帧的标记
// 判断是否为关键帧 (I 帧)
boolean isKeyFrame = (flags & MediaExtractor.SAMPLE_FLAG_SYNC) != 0;
if (isKeyFrame) {
// 这就是 GOP 的开头!如果没有这一帧,后面的 P/B 帧都解不出来
Log.d("Video", "Found IDR Frame at: " + presentationTimeUs);
}
// 接下来通常是把 buffer 里的数据交给 MediaCodec 去解码
// decoder.queueInputBuffer(..., presentationTimeUs, ...);
// 移动到下一帧
extractor.advance();
}
extractor.release();
关键点剖析
-
SAMPLE_FLAG_SYNC: 这是代码层面的 GOP 入口标志 。在做视频剪辑(Seek)或者快进时,代码必须先找到最近的这个SYNC帧。 -
MediaFormat: 这里面存储了"元数据"。比如 H.264 的 SPS/PPS(解码参数),没有这些参数,解码器就不知道如何解析后续的像素。 -
时间戳
presentationTimeUs: 注意单位是微秒。播放器同步音视频,全靠对比视频轨和音频轨拿到的这个时间。
课后小思考
如果你要在视频里实现"快速拖动进度条",你是应该让 extractor 随便跳到一个时间点,还是跳到最近的一个 SAMPLE_FLAG_SYNC 帧?
我会选择直接跳到最近的关键帧
SPS
1. 它们到底是什么?
-
SPS (Sequence Parameter Set,序列参数集):
-
作用:描述整个视频序列的"全局特征"。
-
包含内容:分辨率(宽/高)、帧率上限、编码等级(Profile/Level)、是否包含 B 帧等。
-
重要性 :最高。如果 SPS 丢了,解码器不知道该创建多大的画布,直接报错。
-
-
PPS (Picture Parameter Set,图像参数集):
-
作用:描述每一帧图像是如何被切分和压缩的。
-
包含内容:熵编码模式(CABAC/CAVLC)、加权预测等。
-
重要性 :高。通常一个视频序列里 PPS 会随 SPS 一起出现。
-
2. 为什么你必须了解它们?
在 Android 开发中,有两个场景你一定会碰到它们:
场景 A:MediaCodec 初始化
当你手动解析视频流并交给 MediaCodec 解码时,你不能直接塞给它 I 帧。你必须先通过 MediaFormat 告诉它 SPS 和 PPS,否则 MediaCodec 会报 IllegalStateException。
- 在
MediaFormat中,它们被命名为csd-0(SPS) 和csd-1(PPS)。
场景 B:直播/流媒体推流
在 RTMP 等推流协议中,客户端连接到服务器后的第一件事,就是发送一段特殊的包(通常叫 AVC Sequence Header),里面就死死地封装着 SPS 和 PPS。如果这个包没发,观众点进直播间只会看到黑屏,直到下一个 GOP 的 SPS/PPS 到来。
3. 数据长什么样?(极简解析)
H.264 的原始码流(Annex-B 格式)是由一系列 Start Code (00 00 00 01) 分隔的。你可以通过看 Start Code 后面跟着的第一个字节来分辨:
-
0x67:SPS (0110 0111 -> 最后 5 位是 7) -
0x68:PPS (0110 1000 -> 最后 5 位 is 8) -
0x65:IDR 帧 (I 帧) (0110 0101 -> 最后 5 位是 5)
顺口溜总结 : 7 是 SPS,8 是 PPS,5 是关键帧。 只要在二进制里看到
00 00 00 01 67,你就知道:哦!视频的头信息来了!
4. 它们与 GOP 的关系
通常一个 GOP 的第一个帧是 I 帧(关键帧),但在 I 帧之前,编码器必须强制插入 SPS 和 PPS。 结构序列通常是: [SPS] -> [PPS] -> [I Frame] -> [P Frame] -> [P Frame] ...
这样做的目的是:如果用户从视频中间开始看(比如刷短视频刷到一半),只要他能刷到下一个 GOP 的开头,他就能通过那里的 SPS/PPS 重新初始化解码器,从而恢复画面。
口述建议
如果你在聊多媒体链路时提到: "在处理 MediaCodec 解码时,我会先利用 MediaExtractor 提取出 csd-0 和 csd-1 缓冲区的数据,这其实就是 H.264 的 SPS 和 PPS。如果不先配置这些序列参数集,解码器将无法得知视频的分辨率和编码 Profile,导致后续数据无法正常解析。" ------ 这段话会显得你非常专业。
在 H.264 (AVC) 协议中,为了适配从"老旧的功能机"到"高性能的电脑"不同的硬件能力,官方定义了不同的 Profile。
1. 为什么需要 Profile?
视频编码算法非常多。有些算法压缩率极高,但计算起来非常费电、费 CPU;有些算法压缩率一般,但解码非常快。
Profile 的存在就是为了告诉解码器:"这个视频只用了这几个工具进行压缩,你看看你能不能搞定?"
2. 常见的三个"档位"
在 Android 开发和日常业务中,你只需要记住这三个最核心的 Profile:
| Profile 名称 | 压缩效率 | 硬件要求 | 应用场景 |
|---|---|---|---|
| Baseline Profile (BP) | 最低 | 极低 | 老旧手机、视频通话(低延迟)、视频监控。不支持 B 帧。 |
| Main Profile (MP) | 中等 | 中等 | 数字电视、早期主流智能机视频。 |
| High Profile (HP) | 最高 | 较高 | 当前主流。蓝光高清、B站/抖音视频、4K 电影。支持所有压缩工具。 |
3. Profile 与 Level 的"黄金搭档"
在 Android 的 MediaFormat 中,你经常会看到两个参数:Profile 和 Level。
-
Profile: 决定**"能用哪些压缩工具"**(决定了清晰度和文件大小)。
-
Level: 决定**"处理速度有多快"**。它规定了最大分辨率、最大帧率和最大码率。
- 例如:Level 3.0 可能只支持 720P,而 Level 4.1 才能支持 1080P。
4. 在 Android 开发中的实际意义
作为开发者,你为什么要在意这个?
-
硬件兼容性(避坑):
有些低端 Android 设备的硬解码器只支持到 Main Profile。如果你强行给它一个 High Profile 的视频,可能会出现只有声音没画面或者直接解码器崩溃的情况。
-
设置编码参数:
当你使用 MediaCodec 进行录制时,你需要配置这个参数:
Java// 伪代码:设置 H.264 的 High Profile mediaFormat.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileHigh); mediaFormat.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel41); -
B 帧的问题:
Baseline Profile 是绝对没有 B 帧的。如果你在做低延迟直播,通常会选择 Baseline,因为 B 帧需要参考后面的帧,会带来额外的解压延迟。
5. 总结口述
"Profile 是 H.264 定义的算法特征集。常见的有 Baseline、Main 和 High Profile。Baseline 不支持 B 帧,适合实时性要求高的场景;而 High Profile 压缩效率最高,是目前高清视频的主流。在 Android 开发中,我们需要根据硬件的解码能力(从 SPS 里解析出来的 Profile/Level)来决定是否能流畅播放该视频。"
恭喜!
你现在已经把视频流最核心的"黑话"搞清楚了:
-
GOP: 视频帧的组织结构(I/P/B)。
-
SPS/PPS: 视频的全局配置信息。
-
Profile/Level: 视频的算法档位和性能级别。
这个视频是 High Profile 且带有 B 帧。老设备的解码器不支持处理 B 帧的重排逻辑(DTS 顺序和 PTS 顺序不一致),导致解析出错。 这个没懂
这其实是音视频开发里最烧脑 的一个逻辑点:解码顺序 \\neq 显示顺序。
我们要先理解为什么 B 帧会造成这种"混乱"。
1. 为什么会有顺序不一致?
-
I 帧:自己就能解出来(独立)。
-
P 帧 :需要参考前面的帧才能解出来。
-
B 帧 :需要参考前面和后面的帧才能解出来。
关键点就在这里:如果你要解码一帧 B 帧,你必须先拿到它后面的那一帧(通常是 P 帧)。
2. 举个例子:拍皮球
假设一个视频只有 3 帧,画面是一个皮球落下的过程:
-
帧 1 (I):球在空中。
-
帧 2 (B):球在中间。
-
帧 3 (P):球落地。
-
显示顺序 (PTS):1 -> 2 -> 3 (这是给观众看的)。
-
解码顺序 (DTS):1 -> 3 -> 2。
为什么解码顺序是 1-3-2? 因为第 2 帧是 B 帧,解码器必须先读到第 3 帧(P 帧),才能根据 1 和 3 的数据"推算"出第 2 帧。所以,在视频文件里,第 3 帧的数据其实排在第 2 帧前面!
3. 老设备的"重排逻辑"崩溃
为了处理这种 1-3-2 的乱序,解码器内部需要一个缓存队列(我们叫 DPB, Decoded Picture Buffer)。
-
现代设备:解码器很聪明。它先解出 1,存起来;再解出 3,存起来;然后解出 2。最后按照 1-2-3 的 PTS 顺序吐给屏幕。
-
老旧设备:它的缓存空间极小,或者固件代码写得比较死。它可能默认"解出一帧就得显一帧"。
-
当它遇到 B 帧时,它发现还没解出后面的参考帧,于是它可能直接跳过 这一帧,或者画面卡住 等待,甚至直接报错。
-
如果它强行按解出的顺序显示,观众看到的画面就是:球在空中 -> 球落地 -> 球突然回到中间(鬼畜跳动)。
-
4. 为什么 Baseline Profile 没这个问题?
因为 Baseline Profile 强制规定不准使用 B 帧。
在 Baseline 里,全是 I 帧和 P 帧。P 帧只参考前面的,所以:
-
解码顺序:1 -> 2 -> 3
-
显示顺序:1 -> 2 -> 3
两者完全一致。这对老旧硬件极其友好,因为它不需要复杂的内存重排,解一帧显一帧就行。
总结:DTS 与 PTS 的关系
-
DTS (Decoding Time Stamp):你应该什么时候把这堆二进制数据丢进解码器。
-
PTS (Presentation Time Stamp):你应该什么时候把解出来的图片贴在屏幕上。
如果视频里有 B 帧,DTS 和 PTS 就会发生错位。 > JD 考点: 面试官问"为什么直播推流通常不用 B 帧?"
标准回答: "因为 B 帧需要参考后续帧,会产生额外的解码等待,增加端到端的延迟;同时也为了兼容那些处理不好 PTS/DTS 重排的老旧设备。"
课后小练习
如果你现在用 MediaExtractor 读取一个带 B 帧的 MP4,你会发现 getSampleTime() 拿到的时间戳序列并不是从小到大排列的(比如可能是 0, 66, 33...)。
那视频都是IBP这么排布吗
虽然 IBP 是最经典的排布方式,但视频文件里的帧顺序(存储顺序)和帧类型完全取决于编码器的策略 和应用场景。
我们可以把视频帧的排布想象成"排队",常见的排布方式有以下三种:
1. 经典的 IBP 模式(追求极致压缩)
这是你在本地看电影(MP4/MKV)或在 B 站看视频时最常见的排布。
-
特点:为了把文件压得尽可能小,大量使用 B 帧。
-
存储顺序(DTS):I, P, B, B, P... (为了让 B 帧能参考到后面的 P 帧,P 帧会提前出现在文件里)。
-
显示顺序(PTS):I, B, B, P, B, B, P...
2. IP 模式(追求低延迟/兼容性)
在**直播、视频会议(Zoom/WeChat)**中,你几乎看不到 B 帧。
-
为什么? 因为 B 帧需要"看未来"的帧。如果你在直播时用 B 帧,编码器必须等下一帧 P 帧产生后才能编码这一帧 B 帧,这会额外增加几十甚至上百毫秒的延迟。
-
排布:I, P, P, P, P...
-
特点 :DTS 永远等于 PTS。数据一进来,解出来直接就能显示,不需要任何等待。
3. 全 I 帧模式(All-I / Intra-only)
在**专业视频剪辑(如 ProRes 格式)**或高采样率的录像中,可以每一帧都是 I 帧。
-
排布:I, I, I, I, I...
-
优点:
-
编辑极其流畅:剪辑软件拖动进度条时,不需要计算任何参考帧,点到哪一帧就是哪一帧。
-
画质极高:没有预测误差。
-
-
缺点:文件体积巨大(可能是普通 MP4 的几十倍)。
4. 更加复杂的:分层参考 (Hierarchical B-frames)
这是现代编码器(如 H.265)的高级玩法。B 帧不仅仅参考 I 和 P,甚至 B 帧还可以参考另外一个 B 帧,形成一个复杂的树状结构。这种排布能进一步提升压缩率,但对设备性能的要求也更高。
5. 关键知识点:GOP 的"封闭"与"开放"
你在看帧排布时,还会遇到两个专业词汇:
-
Closed GOP (封闭式):这个 GOP 里的所有帧只参考自己内部的帧。
- 好处:你从这个 I 帧开始剪切,后面画面绝对不会花。
-
Open GOP (开放式):后面的 B 帧可能会参考上一个 GOP 的末尾帧。
- 坏处:如果你把视频从中间切开,新视频的第一秒可能会花屏,因为参考帧丢了。
总结:如何判断一个视频是怎么排的?
作为 Android 开发者,你不需要猜。当你拿到一个 MediaFormat 时,你可以观察:
-
如果有 B 帧:PTS 的序列必然是乱的(不单调递增)。
-
如果没有 B 帧 :每一个
readSampleData拿到的getSampleTime()都是从小到大整齐排列的。
下周实战小贴士:
当你用 MediaCodec 编码视频时,如果你设置 KEY_LATENCY 为 0 或者选择 Baseline Profile,系统就会自动帮你把排布切换成 IP 模式,从而消灭 B 帧带来的延迟。
mediacodec状态机
MediaCodec 是 Android 音视频开发中最难调教的 API,原因就在于它是一个异步的、严格依赖状态 的处理器。你可以把它想象成一个极其死板的加工工厂:你必须先按顺序开机、配置、运行,而且它只通过"传送带"和你交流。
1. 核心流程:生产者-消费者模型
在深入状态机之前,你要先理解它的底层逻辑------Buffer(缓冲区)流转。 它有两条传送带:
-
Input Buffer 传送带:你把压缩数据(H.264)放进去,递给解码器。
-
Output Buffer 传送带:解码器把解好的原始画面(YUV/Surface)吐出来,你拿走。
2. 状态机全景图
MediaCodec 的生命周期主要分为三个大状态:Stopped (停止) 、Executing (执行中) 和 Released (释放)。
第一阶段:从"出生"到"就绪" (Stopped 状态)
-
Uninitialized (未初始化) :你刚
createByCodecName之后。 -
Configured (已配置) :你调用了
configure(),告诉它MediaFormat(就是我们之前学的 SPS/PPS/Profile)。 -
Flushed (已冲洗) :调用
start()后的瞬时状态,或者是播放中途 Seek 之后,里面清空了。
第二阶段:忙碌的工作 (Executing 状态)
只有进入这个状态,它才开始吃数据吐数据。它内部细分为:
-
Flushed:待命状态。
-
Running:正在干活。
-
End of Stream (EOS):你告诉它"没货了",它处理完最后的存货后进入这个状态。
第三阶段:寿终正寝 (Released 状态)
- 调用
release(),释放硬件资源(非常重要!硬解资源是全系统共享的,不释放会导致其他 App 相机或播放器打不开)。
3. 如何与状态机交互?(关键 API)
在 Executing 状态下,代码的操作逻辑就像是在和工厂玩"接力赛":
-
dequeueInputBuffer(伸手要盆): 你问解码器:"喂,你现在有空的盆(Buffer)给我装压缩数据吗?" -
queueInputBuffer(递交货物) : 你把 H.264 数据装进盆里,并贴上标签(PTS),推回给解码器。 -
dequeueOutputBuffer(成品出库): 你问解码器:"解好一帧了吗?解好了把盆给我。" -
releaseOutputBuffer(还盆并显示): 你处理完这帧(或者直接让它显示在 Surface 上),把盆还给解码器,让它去接下一帧。
4. 为什么大家说 MediaCodec 难用?
-
非法状态异常 (
IllegalStateException) : 如果你在Stopped状态调用了dequeueInputBuffer,它会直接崩掉。你必须时刻清楚它现在在哪。 -
同步 vs 异步:
-
同步模式:你主动问它要盆(适合简单的离线转码)。
-
异步模式:你给它一个回调(Callback),它有了空盆自动通知你(Android 5.0+ 推荐,适合丝滑的播放器)。
-
5. 常见面试题:Seek(拖动进度条)时状态机发生了什么?
当你拖动进度条时,旧的解码数据已经没用了。
-
调用
flush():这会让MediaCodec立即回到Flushed状态。 -
丢弃所有旧 Buffer:之前在传送带上的所有帧都会被清空。
-
寻找新 I 帧 :从
Extractor找到新的关键帧,再次queueInputBuffer。
口述练习建议
"MediaCodec 是一个严格的状态机。在初始化并
configure后,调用start进入Executing状态。它通过BufferQueue机制与开发者交互:我们通过dequeueInputBuffer获取输入槽位,填充数据后queue回去;再通过dequeueOutputBuffer获取解码后的帧,最后releaseOutputBuffer将画面渲染到屏幕并归还缓冲区。在使用完后,必须显式调用release释放硬件资源。"
1. 同步模式 (Synchronous Mode)
同步模式逻辑直观,适合做离线视频处理(如视频剪辑、转码)。
Java
// 1. 初始化并进入 Configured 状态
MediaCodec codec = MediaCodec.createByCodecName(name);
codec.configure(format, surface, null, 0);
// 2. 开启状态机,进入 Executing 状态
codec.start();
// 3. 循环处理数据
while (!isEndOfStream) {
// --- 【左手:处理输入】 ---
// 问工厂要一个空的输入盆 (Timeout 为 10ms)
int inputBufferIndex = codec.dequeueInputBuffer(10000);
if (inputBufferIndex >= 0) {
ByteBuffer inputBuffer = codec.getInputBuffer(inputBufferIndex);
// 从 Extractor 读取压缩数据填充到 inputBuffer
int sampleSize = extractor.readSampleData(inputBuffer, 0);
if (sampleSize < 0) {
// 没有数据了,打上结束标记入队
codec.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
isEndOfStream = true;
} else {
// 把装满数据的盆推回给解码器,并附带 PTS 时间戳
codec.queueInputBuffer(inputBufferIndex, 0, sampleSize, extractor.getSampleTime(), 0);
extractor.advance();
}
}
// --- 【右手:处理输出】 ---
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
// 问工厂:有没有解好的成品?
int outputBufferIndex = codec.dequeueOutputBuffer(info, 10000);
if (outputBufferIndex >= 0) {
// 拿到解好的像素数据,true 表示渲染到配置的 Surface 上
codec.releaseOutputBuffer(outputBufferIndex, true);
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
// 重点:如果视频分辨率变了,或者是第一次拿到 SPS/PPS,会触发这个
MediaFormat newFormat = codec.getOutputFormat();
}
}
// 4. 释放资源
codec.stop();
codec.release();
2. 异步模式 (Asynchronous Mode)
Android 5.0+ 推荐方式。由于不阻塞主线程,它是高帧率播放器的首选。
Java
// 1. 创建实例
MediaCodec codec = MediaCodec.createByCodecName(name);
// 2. 设置回调(必须在 configure 之前!)
codec.setCallback(new MediaCodec.Callback() {
@Override
public void onInputBufferAvailable(MediaCodec codec, int index) {
// 工厂主动喊你:"有空盆了,快来装货!"
ByteBuffer inputBuffer = codec.getInputBuffer(index);
// 填充数据逻辑同同步模式...
int sampleSize = extractor.readSampleData(inputBuffer, 0);
codec.queueInputBuffer(index, 0, sampleSize, extractor.getSampleTime(), 0);
extractor.advance();
}
@Override
public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {
// 工厂主动喊你:"成品出来了,快接走!"
// 处理 info.presentationTimeUs (用于音画同步)
codec.releaseOutputBuffer(index, true);
}
@Override
public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {
// 视频元数据(宽、高、SPS、PPS)发生变化或初始确定时回调
Log.d("Codec", "New Format: " + format.toString());
}
@Override
public void onError(MediaCodec codec, MediaCodec.CodecException e) {
// 状态机出错,通常需要 stop 并 release
}
});
// 3. 配置并启动
codec.configure(format, surface, null, 0);
codec.start(); // 之后一切都在 Callback 驱动下运行
3. 两种模式的深度对比
| 维度 | 同步模式 (Sync) | 异步模式 (Async) |
|---|---|---|
| 控制权 | 开发者主动轮询 (Poll) | 状态机主动通知 (Push) |
| 线程性能 | 容易阻塞线程,需要自己管理循环 | 效率极高,内部通过 Handler 线程分发 |
| 复杂度 | 简单,像流水线 | 稍难,需要处理多线程并发逻辑 |
| 主要用途 | 视频剪辑、后台转码、录屏 | 直播播放器、高性能视频解码 |
4. 为什么注释里强调 onOutputFormatChanged?
在 Week 1 你学了 SPS/PPS 。当你调用 start() 之后,解码器其实还没法立刻解码,它必须先等到第一帧数据里的 SPS/PPS 被解析出来。
-
在同步模式下,你会收到一个
INFO_OUTPUT_FORMAT_CHANGED的返回值。 -
在异步模式下,会触发 onOutputFormatChanged。
此时你才能拿到视频真正的宽高、裁剪区域(Crop Rect)等关键渲染信息。
深入 MediaExtractor:数据的"精准投喂"
如果把 MediaCodec 比作加工厂,MediaExtractor 就是仓库管理员。它负责把 MP4 这个"大箱子"拆开,选出你要的那条线(视频或音频轨),并精准地把每一帧"货物"投递到传送带上。
以下是关于 MediaExtractor 深度使用的三个核心维度:
1. 核心流程:从"拆箱"到"选货"
在 Android 中使用 Extractor,必须遵循严格的顺序,否则它不知道该从哪里开始读取。
-
setDataSource: 告诉管理员箱子在哪(本地路径、URI 或 FileDescriptor)。 -
selectTrack: 这是初学者最容易忘的一步。MP4 里可能有多条轨道(不同语言的音频、不同分辨率的视频)。你必须明确告诉它:"我现在要读第 0 号轨道的数据"。 -
readSampleData: 把当前位置的一帧压缩数据拷贝到 ByteBuffer 里。 -
advance: 手动挡操作。读取完一帧后,你必须调用它,管理员才会把指针指向下一帧。如果不调用,你会死循环一直读同一帧。
2. 精准投喂的灵魂:Seek(寻址)
这是 Extractor 最有技术含量的地方。当用户拖动进度条到第 10 秒时,你不能直接从第 10 秒开始解。
为什么? 还记得 Week 1 学的 GOP 吗?
如果第 10 秒恰好是一个 P 帧,它没有参考的前置 I 帧,解码器就会报一堆错,屏幕上全是花屏。
seekTo(timeUs, mode) 的三种模式:
| 模式 | 行为描述 | 应用场景 |
|---|---|---|
SEEK_TO_PREVIOUS_SYNC |
寻找目标时间点之前最近的一个关键帧(I 帧)。 | 最常用。保证拖动后画面立刻能解出来,不花屏。 |
SEEK_TO_NEXT_SYNC |
寻找目标时间点之后最近的一个关键帧。 | 较少用,可能会导致跳过一段画面。 |
SEEK_TO_CLOSEST_SYNC |
寻找离目标点最近的关键帧。 | 兼顾准确度和速度。 |
实战细节 :如果你追求极致的"精准定位"(比如剪辑软件),通常是先
SEEK_TO_PREVIOUS_SYNC找到前一个 I 帧,然后解码器空跑,解出后续的帧但不显示,直到到达目标时间点再渲染。
3. 如何判断"投喂"结束?
当 readSampleData 返回 -1 时,说明这个轨道的货发完了。
这时你必须向 MediaCodec 发送一个特殊的信号弹:BUFFER_FLAG_END_OF_STREAM。
如果不发这个信号,解码器会一直蹲在那等下一帧,导致你的播放器最后几帧画面"憋"在解码器里出不来,表现为视频末尾卡顿一下。
4. 进阶:Extractor 的"坑"与性能
-
网络流支持差 :原生的
MediaExtractor虽支持 URL,但在网络不稳时表现极差(容易阻塞、超时不可控)。- 大厂策略 :通常会用 FFmpeg 的
avformat来代替原生 Extractor 处理网络流(如 HLS, DASH)。
- 大厂策略 :通常会用 FFmpeg 的
-
多线程安全 :
MediaExtractor不是线程安全的。如果你在主线程 Seek,在子线程 Read,极易崩溃。 -
内存泄露 :用完一定要
release(),否则文件句柄不释放,会导致 App 无法再次打开文件。
本周自测:数据的"身份证明"
当你通过 extractor.readSampleData 拿到一袋数据时,你还需要通过以下方法拿到它的"身份证",并一起塞给解码器:
-
getSampleTime(): 这一帧的 PTS(非常重要,决定了这帧画面什么时候显示)。 -
getSampleFlags(): 这一帧是不是 关键帧 (如果是SAMPLE_FLAG_SYNC,说明 GOP 重新开始了)。
如果把 MediaExtractor 比作仓库管理员 ,那么 DataSource 就是运输车队。管理员不能凭空变出货物,他必须先指定"货在哪里"以及"怎么运过来"。
1. 它们的关系:DataSource 是 Extractor 的"输入源"
在 Android 的标准 API 流程中,它是这样组织的:
-
DataSource (数据源):确定物理位置。可以是:
-
本地文件路径 (
/sdcard/video.mp4) -
网络 URL (
https://example.com/movie.m3u8) -
资源文件 (
res/raw/video) -
甚至是内存中的一个
FileDescriptor。
-
-
Extractor (解封装器):连接到 DataSource 后,开始"扫描"文件的头部(Header),寻找轨道信息(Track Info)。
代码层面的体现:
Java
MediaExtractor extractor = new MediaExtractor();
// 这步就是在指定 DataSource
extractor.setDataSource(context, uri, headers);
2. 为什么开发者常说 DataSource 是"第一道坑"?
虽然 setDataSource 看起来只是一行代码,但在实际 JD 对齐的工程能力中,这里学问很大:
A. 协议支持 (Protocols)
-
原生限制 :Android 原生的
MediaExtractor默认支持的文件格式(MP4, TS, MKV)和协议(HTTP/HTTPS)是有限的。 -
工程现状 :如果你要做直播(RTMP)或特殊的加密流,原生的 DataSource 就搞不定了。这时大厂会用 FFmpeg 重新实现一套自定义的
DataSource层。
B. 网络缓冲 (Buffering)
-
如果 DataSource 是网络地址,
MediaExtractor会尝试边下边读。 -
问题 :如果网速慢,
extractor.readSampleData就会阻塞(Block)。 -
优化 :在 DataSource 这一层,通常会做一个缓存队列(Buffer)。先让运输车队把数据拉到本地缓存,Extractor 再从缓存里拿,这样可以有效防止播放卡顿。
3. 播放器的完整分层(从底向上)
为了让你下周写代码时不乱,请记住这个层级图:
| 层级 | 角色 | 功能 | 例子 |
|---|---|---|---|
| 1. DataSource | 运输员 | 负责把二进制字节流拿过来。 | HttpDataSource, FileDataSource |
| 2. Extractor | 拆包员 | 识别格式,把字节流拆成音频/视频帧。 | MediaExtractor, MP4Extractor |
| 3. Decoder | 加工厂 | 把压缩帧还原成像素。 | MediaCodec |
| 4. Renderer | 展示员 | 负责画出来、响出来。 | Surface, AudioTrack |
4. 一个容易混淆的概念:MediaDataSource
Android 6.0 引入了一个类叫 MediaDataSource。它允许开发者自己写代码来提供数据。
-
场景:比如你的视频数据是加密的,或者存在一个特殊的数据库里。
-
做法 :你可以继承这个类,重写
readAt()方法。这样当 Extractor 需要数据时,它会回调你的方法,你解密后再传给它。
核心播放器逻辑:VideoPlayer 伪代码
Java
// 1. 设置数据源 (DataSource)
MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource(filePath); // 也可以是 URL 或 AssetFileDescriptor
// 2. 寻找视频轨并初始化解码器 (Demuxing & Init)
int trackIndex = selectVideoTrack(extractor);
MediaFormat format = extractor.getTrackFormat(trackIndex);
String mime = format.getString(MediaFormat.KEY_MIME);
MediaCodec decoder = MediaCodec.createDecoderByType(mime);
// 关键:将 Surface 传给 configure,解码后画面会自动渲染到屏幕上
decoder.configure(format, surface, null, 0);
decoder.start();
extractor.selectTrack(trackIndex);
// 3. 数据流转循环 (The Pumping Loop)
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean isInputEOS = false;
boolean isOutputEOS = false;
while (!isOutputEOS) {
// --- 【第一步:喂数据 (Input)】 ---
if (!isInputEOS) {
int inputIdx = decoder.dequeueInputBuffer(10000); // 10ms 超时
if (inputIdx >= 0) {
ByteBuffer inputBuffer = decoder.getInputBuffer(inputIdx);
// 从 Extractor 掏出一帧压缩数据
int sampleSize = extractor.readSampleData(inputBuffer, 0);
if (sampleSize < 0) {
// 读取完毕,发送结束标志
decoder.queueInputBuffer(inputIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
isInputEOS = true;
} else {
// 投递到解码器,并附带最重要的 PTS
decoder.queueInputBuffer(inputIdx, 0, sampleSize, extractor.getSampleTime(), 0);
extractor.advance(); // 推进到下一帧
}
}
}
// --- 【第二步:拿画面 (Output)】 ---
int outputIdx = decoder.dequeueOutputBuffer(info, 10000);
if (outputIdx >= 0) {
// 渲染同步逻辑(伪代码):
// 这里需要根据 info.presentationTimeUs 与系统时间对比,决定是否 sleep
// 否则视频会瞬间播完(解码多快播多快)
sleepUntilRenderTime(info.presentationTimeUs);
// render=true 表示将此 Buffer 发送到配置好的 Surface 渲染
decoder.releaseOutputBuffer(outputIdx, true);
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
isOutputEOS = true;
}
} else if (outputIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
// 这里可以拿到视频真实的宽、高、Crop矩形等信息
MediaFormat newFormat = decoder.getOutputFormat();
}
}
// 4. 释放资源
decoder.stop();
decoder.release();
extractor.release();
关键点解析:
1. 关于 Surface 的"魔法"
注意 decoder.configure(format, surface, ...)。在 Android 中,如果你配置了 Surface,MediaCodec 解码后的数据不会返回给 CPU 内存,而是直接留在 显存 (BufferQueue)中。当你调用 releaseOutputBuffer(index, true) 时,系统只需交换一下指针,画面就显示出来了。这就是 Zero-Copy (零拷贝)。
2. 关于 sleepUntilRenderTime (音画同步雏形)
这是你下周实战最容易漏掉的地方。
-
原因 :
MediaCodec解码速度极快(一秒钟能解几百帧)。 -
现象:如果你不写控制时间的逻辑,你的视频会像"闪电侠"一样几秒钟就播完了。
-
做法 :你需要记录一个
startTime,然后每次对比info.presentationTimeUs,如果还没到时间,就让线程Thread.sleep()一下。
3. 关于 INFO_OUTPUT_FORMAT_CHANGED
为什么不直接用 extractor.getTrackFormat() 拿到的宽高? 因为有些 H.264 视频存在 Crop(裁剪区) 。例如视频声明是 1080P,但实际有效画面可能只有 1080x800。只有等解码器解析了第一个 SPS 后,从 getOutputFormat() 拿到的数据才是最终准确的。
深入 Surface:画面的"最后触达"
在 Android 音视频开发中,Surface 并不是一个"控件",而是一条通往屏幕的隧道。
1. Surface 的本质:一个 BufferQueue 的生产者端
在底层,Surface 的工作原理是一个极其高效的**"双缓冲"或"三缓冲"队列**。
-
BufferQueue:这是核心。它像是一个装着几个空脸盆(GraphicBuffer)的架子。
-
Producer (生产者) :在我们的场景里,就是 MediaCodec。它负责把解码后的像素塞进盆里。
-
Consumer (消费者) :就是 SurfaceFlinger(系统服务)。它负责把盆里的像素贴到屏幕上。
2. 画面的"最后触达"经历了什么?(物理过程)
当你调用 decoder.releaseOutputBuffer(index, true) 时,底层发生了这几件事:
-
数据的搬运 :实际上并没有发生像素拷贝。解码器只是把这个 Buffer 的所有权交还给了 BufferQueue。
-
入队 (Queue) :这个 Buffer 被标记为
Filled(已装满),进入 BufferQueue 的待处理队列。 -
信号通知:BufferQueue 通知消费者(SurfaceFlinger):"有新货到了!"
-
合成 (Composition):在下一个屏幕刷新信号(V-Sync)到来时,SurfaceFlinger 把这个 Buffer 的数据取出来,配合其他 App 的窗口(状态栏、导航栏),交给 GPU 合成出一张最终的屏幕图像。
-
归还 (Release) :合成完成后,SurfaceFlinger 把 Buffer 标记为
Free(空闲),还给 BufferQueue,循环开始。
3. "零拷贝" (Zero-Copy) 为何重要?
这是音视频开发最核心的性能点。
-
如果没有 Surface:解码器解出像素 \\rightarrow 拷贝到内存 \\rightarrow 拷贝给显卡 \\rightarrow 渲染。这会消耗大量的 CPU 和内存带宽,导致手机发热、4K 视频卡顿。
-
有了 Surface:解码器直接把像素解到**显存(GraphicBuffer)**里。SurfaceFlinger 也是直接操作这块显存。
真相:像素数据从始至终都在显存里没动过,改变的只是这块内存的"访问权限"。
4. 两个关键组件:SurfaceView vs TextureView
在 Android 上展示这个 Surface,你有两种选择,它们的"触达"路径完全不同:
| 特性 | SurfaceView | TextureView |
|---|---|---|
| 渲染位置 | 拥有自己独立的 Window,在主窗口后面 | 在 App 的 View 层级中,像个普通 View |
| 打洞逻辑 | 系统会在你的 App 窗口上"挖个洞",让后面的 Surface 露出来 | 将视频帧转换为 GL 纹理,在你的 View 树里合成 |
| 性能 | 极高。数据直接给 SurfaceFlinger。 | 略低。需要先过一遍 GPU 合成到 View 树。 |
| 适用场景 | 全屏电影、游戏、视频通话。 | 抖音式列表、需要旋转/半透明/缩放视频的场景。 |
5. 一个深层次的坑:Surface 的生命周期
这是开发者最常犯错的地方。Surface 的生命周期和 Activity 是不同步的。
-
当你按 Home 键,
Activity只是onStop,但你的Surface可能会被销毁(Destroyed)。 -
后果 :如果你此时还在调用
MediaCodec.releaseOutputBuffer,系统会因为找不到接收方而直接抛出异常(IllegalStateException)。 -
对策 :必须监听
SurfaceHolder.Callback或SurfaceTextureListener。当onSurfaceDestroyed触发时,必须立刻停止解码线程!
总结:底层视角
Surface 是一个 Buffer 的传送带。
MediaCodec 负责填满 Buffer,releaseOutputBuffer(..., true) 负责启动传送带,SurfaceFlinger 在传送带末头负责把 Buffer 贴在屏幕上。
本周结课:你已经掌握了整个闭环!
-
数据源 :
DataSource->MediaExtractor -
加工厂 :
MediaCodec(Input/Output Buffer 流转) -
显示路径 :
Surface->BufferQueue->SurfaceFlinger-> 屏幕
你现在的理论深度,已经足以支持你写出一个高性能的播放器了。
显存是积累一定数据 再送到surfaceflinger吗?
简单直接的回答是:不是积累一堆,而是"按需、逐帧"地发送。 但在底层,为了保证画面的丝滑(不掉帧、不撕裂),它确实存在一个极小规模的缓存机制。
1. 物理真相:逐帧提交 (One by One)
当你调用 decoder.releaseOutputBuffer(index, true) 时,你实际上是下达了一个**"发货指令"**。
-
动作 :解码器把这一帧 (一个显存 Buffer)标记为"已填满",并推入
BufferQueue。 -
频率 :如果是 30fps 的视频,这个动作大约每 33ms 发生一次。
-
结论:它不会等攒够 10 帧再送,因为那样会导致严重的播放延迟。
2. 这里的"缓存"是怎么回事?(三缓冲机制)
虽然是逐帧送,但 Surface 底层的 BufferQueue 通常会准备 2 到 3 个显存盆(Buffer) 。这就是著名的 Double/Triple Buffering。
这个"小积累"的作用是:
-
Buffer A (正在显示):SurfaceFlinger 正在把它画在屏幕上。
-
Buffer B (正在解压):解码器正在往里写下一帧的数据。
-
Buffer C (准备就绪):如果解码器跑得快,解好了一帧,它会停在队列里等屏幕刷新。
这种"积累"是为了对抗波动。 万一解码器下一帧卡了一下(比如遇到复杂的 I 帧),屏幕还可以先显示已经准备好的 Buffer C,不至于让用户看到黑屏或卡顿。
3. 谁来决定什么时候"送"?(V-Sync 信号)
即使解码器把 Buffer 送到了 BufferQueue,画面也不会立刻出现在屏幕上。这里有一个**"守门员"**:V-Sync(垂直同步信号)。
-
原理 :手机屏幕通常每秒刷新 60 次(60Hz)。每隔 16.6ms,系统会发出一个 V-Sync 信号。
-
过程:
-
你调用
releaseOutputBuffer。 -
Buffer 进入队列"排队"。
-
V-Sync 信号到来。
-
SurfaceFlinger准时现身,从队列里取走最新的一帧,贴到屏幕上。
-
4. 特殊情况:如果解得太快会怎样?
如果你关闭了时间控制(音画同步),让 while 循环疯跑:
-
解码器瞬间填满了 BufferQueue(假设有 3 个盆)。
-
当它想塞第 4 个盆时,
dequeueInputBuffer或dequeueOutputBuffer就会阻塞(或者返回等待)。 -
现象:解码器被迫降速,变得和屏幕刷新率一样快。
总结:你的直觉对了一半
-
不对的地方:它不是为了节约成本去"攒一波"发送。
-
对的地方 :它确实利用 2-3 个 Buffer 做了微量积累 ,目的是为了平滑抖动,让解码器的"生产"和屏幕的"消费"能够完美错开。
深度思考题(带入 Week 2 实战)
如果一个视频是 120fps(每帧 8ms),但屏幕是 60Hz(每帧 16ms),会发生什么?
答案 :解码器会辛苦地解出所有帧,但
SurfaceFlinger每次刷新只会拿最新的一帧,中间的那一帧会被"丢弃"掉。这就是为什么高帧率视频在低刷新率屏幕上看起来没区别的原因。
底层原理聊到这里,你已经把从"比特流"到"光信号"的最后一段路走通了!
下周我们写代码时,你会亲眼看到 releaseOutputBuffer 是如何驱动这些 Buffer 流转的。
1. 传统路径:CPU 搬运工(低效)
如果没有 Surface 这种直接通道,像素数据需要经历一次漫长的"大循环":
-
解码 :CPU(或硬解后回传)把数据解压到 系统内存。
-
转换:CPU 把 YUV 格式转换成屏幕能读的 RGB 格式。
-
拷贝 :CPU 将几兆大小的像素阵列通过 系统总线 拷贝给显卡。
-
渲染:显卡拿到数据画出来。
代价 :对于一个 4K 60fps 的视频,每秒要拷贝约 1.5GB 的数据。这会直接占满内存带宽,导致手机发烫,其他 App 运行卡顿。
2. Surface 路径:显存"瞬移"(高效)
有了 Surface 和显卡直接对接后,情况变成了这样:
-
物理上的"原地踏步" : 由于
MediaCodec配置了 Surface,解码器解出来的像素其实直接就在显卡的显存(GraphicBuffer)里。 -
逻辑上的"所有权变更" : 当调用
releaseOutputBuffer(..., true)时,并没有发生数据搬运,只是发了一个**"令牌"**。-
解码器说:这块显存我写完了,令牌给你。
-
显卡(GPU)说:收到令牌,我现在去这块显存地址读数据并画出来。
-
3. 具体节省了哪些性能?
A. 极大的带宽节省(最重要)
-
原理:数据不需要在"系统内存(RAM)"和"显存(VRAM)"之间飞来飞去。
-
结果:节省了大量的电量。手机看视频能看 10 小时,很大程度归功于这种**"零拷贝" (Zero-Copy)** 机制。
B. 解放 CPU
-
原理:CPU 不再需要参与像素的搬运和 YUV 到 RGB 的色彩空间转换(这部分由 GPU 硬件单元瞬时完成)。
-
结果:CPU 占用率可能从 80% 降到 5%。你的 App 可以在播放视频的同时,流畅地处理评论区滚动或复杂的 UI 动画。
C. 降低延迟
-
原理:由于少了多次内存拷贝的耗时(一次 4K 拷贝可能要几毫秒),画面能更及时地响应 V-Sync 信号。
-
结果:音画同步更精准,直播延迟更低。
4. 总结:这是一种"特权通道"
你可以把 Surface 理解为 Android 系统给视频和游戏开的一条**"特权高速公路"**。
普通的 UI(如按钮、文字)通常要在主线程慢慢画、慢慢合成;而视频数据通过 Surface 绕过了复杂的 View 树层级,直接在硬件层完成投递。
底层真相 : 显卡并不是"被动接收数据",而是和解码器"共用一间仓库"。解码器在仓库左边装货,显卡在仓库右边出货。节省性能的本质,就是"不动数据,只动指针"。