9. 视频输出与渲染:Surface与ANativeWindow、视频帧的提交与显示、VSync与帧率控制
视频解码完了,数据在内存里躺着。下一步是什么?
得把它画到屏幕上,对吧?
这个环节,我习惯叫它「最后一公里」。很多开发者把精力都花在了解码和封装上,结果到了渲染这步,掉帧、花屏、延迟全来了。说实话,这部分的坑,比解码本身还多。
9.1 Surface 与 ANativeWindow:你看到的不是真正的窗口
先搞清楚一个概念:Android 里你看到的 UI,其实不是直接画在屏幕上的。
每个 Activity 有一个 Window,每个 Window 背后有一个 Surface。Surface 是什么?它是一块「画布」,但又不是普通的画布——它背后连着一套 Buffer 管理机制。
我刚开始接触 Stagefright 时,一直搞不懂 Surface 和 ANativeWindow 的关系。后来翻源码才发现,ANativeWindow 其实就是 Surface 的 C 层封装。说白了,Surface 是 Java 层的,ANativeWindow 是 Native 层的,两者指向同一套 BufferQueue。
核心关系:
Surface:Java 层,提供给 MediaCodec 或 MediaPlayer 使用ANativeWindow:Native 层,提供给 Stagefright 的 OMX 组件或 Codec2 使用- 底层共享同一个
BufferQueue生产者-消费者模型
你想想看,视频帧从解码器出来,要送到显示设备。中间经过了多少层?
解码器 → 输出 Buffer → Surface → BufferQueue → SurfaceFlinger → HWC → 屏幕。
每一层都有讲究。我曾经在一个项目里遇到 Surface 配置不对,导致解码器输出的颜色格式和 Surface 不匹配,画面直接偏绿。查了两天才发现是 setVideoScalingMode 没设对。
9.2 视频帧的提交与显示:从解码器到屏幕的旅程
解码器输出一帧数据后,怎么交给 Surface?
流程其实很清晰:
- 解码器输出 Buffer:MediaCodec 或 OMX 组件输出一帧解码后的 YUV 或 RGBA 数据
- dequeueOutputBuffer:客户端从解码器取出输出 Buffer
- releaseOutputBuffer:将 Buffer 交还给解码器,同时通知 Surface 去消费
- Surface 入队:Buffer 进入 BufferQueue 的生产者队列
- SurfaceFlinger 消费:在下一个 VSync 信号到来时,SurfaceFlinger 取出 Buffer 合成
- 显示:合成后的画面通过 HWC 或 GPU 送到屏幕
这里有个关键点:releaseOutputBuffer 的第二个参数——时间戳。
我的经验:时间戳一定要准。如果你传了 0 或者乱传,SurfaceFlinger 合成时会出现画面抖动。我曾经在直播项目里遇到过,就是因为时间戳没对齐,导致画面每隔几秒跳一下。后来改成用解码器输出的 presentationTimeUs,问题就解决了。
来看一段典型的代码流程:
// MediaCodec 输出循环
while (!isEOS) {
int outputIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US);
if (outputIndex >= 0) {
// 拿到解码后的数据
ByteBuffer outputBuffer = outputBuffers[outputIndex];
// 提交给 Surface 显示
mediaCodec.releaseOutputBuffer(outputIndex, true);
// 第二个参数 true 表示渲染到 Surface
// 或者不渲染,只获取数据
// mediaCodec.releaseOutputBuffer(outputIndex, false);
}
}
注意看 releaseOutputBuffer 的第二个参数。传 true 表示「渲染」,传 false 表示「只释放不渲染」。如果你既要显示又要处理数据,那就得走 Surface 的 awaitNewImage 或者自己拷贝一份。
9.3 VSync 与帧率控制:为什么你的视频会卡?
视频播放卡顿,90% 的原因和 VSync 有关。
VSync 是什么?它是屏幕刷新的同步信号。Android 屏幕一般是 60Hz,也就是每 16.6ms 刷新一次。SurfaceFlinger 会在每个 VSync 信号到来时,去 BufferQueue 里取一帧数据合成。
问题来了:如果你的解码器输出帧率是 30fps,也就是每 33ms 一帧。而屏幕是 60Hz,每 16.6ms 刷新一次。这中间怎么对齐?
注意:不要以为解码器输出一帧,屏幕就立刻显示一帧。中间有 BufferQueue 做缓冲。如果生产者(解码器)速度比消费者(SurfaceFlinger)快,BufferQueue 会堆积,导致延迟增加。如果生产者比消费者慢,画面就会掉帧。
我遇到过最典型的一个坑:某款低端手机上播放 60fps 视频,画面一顿一顿的。查了半天发现是 BufferQueue 的 maxDequeuedBufferCount 设得太小,解码器输出 Buffer 被 SurfaceFlinger 卡住了。后来调大了这个值,问题就解决了。
帧率控制的核心,其实就是让解码器的输出节奏和 VSync 对齐。Android 提供了两种方式:
| 方式 | 原理 | 适用场景 |
|---|---|---|
| 自动同步 | SurfaceFlinger 通过 BufferQueue 的 frameAvailableListener 回调,在 VSync 到来时消费 Buffer |
大多数播放器场景 |
| 手动控制 | 应用层通过 Choreographer 或 SurfaceControl 控制提交时机 |
游戏、低延迟直播 |
我个人建议:除非你有特殊需求,否则别自己搞帧率控制。让 SurfaceFlinger 去处理就好。你只需要保证解码器输出的时间戳准确,Buffer 提交及时。
9.4 核心流程图:视频帧从解码到显示
下面这张图,是我自己画的一个简化流程。你看完应该能对整个链路有个直观认识。
你看,整个链路其实不复杂。但每个环节都可能出问题。我总结几个常见的坑:
- Buffer 提交太快:解码器输出速度超过屏幕刷新率,BufferQueue 堆积,延迟越来越高
- Buffer 提交太慢:SurfaceFlinger 等不到新 Buffer,重复显示上一帧,画面卡顿
- 时间戳不准:SurfaceFlinger 合成时无法正确插帧,导致画面抖动
- 颜色格式不匹配:解码器输出的是 NV12,Surface 要求的是 RGBA,中间没有做格式转换
避坑指南:我曾经在一个项目中,解码器输出的是 10bit HDR 数据,但 Surface 配置的是 8bit 的 RGBA_8888。结果画面颜色完全不对。后来在配置 MediaCodec 时,显式指定了 COLOR_FormatSurface,让解码器直接输出到 Surface,绕过了颜色格式转换的问题。
嗯,说到这,我想强调一点:视频渲染这块,不要过度优化。Android 的 SurfaceFlinger 和 BufferQueue 已经做了大量工作。你只需要保证:
- 解码器输出时间戳准确
- Surface 配置正确(颜色格式、尺寸)
- Buffer 提交节奏稳定
做到这三点,大部分播放场景都不会有问题。剩下的,交给系统去处理。
公众号:蓝海资料掘金营,微信deep3321