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?

流程其实很清晰:

  1. 解码器输出 Buffer:MediaCodec 或 OMX 组件输出一帧解码后的 YUV 或 RGBA 数据
  2. dequeueOutputBuffer:客户端从解码器取出输出 Buffer
  3. releaseOutputBuffer:将 Buffer 交还给解码器,同时通知 Surface 去消费
  4. Surface 入队:Buffer 进入 BufferQueue 的生产者队列
  5. SurfaceFlinger 消费:在下一个 VSync 信号到来时,SurfaceFlinger 取出 Buffer 合成
  6. 显示:合成后的画面通过 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 大多数播放器场景
手动控制 应用层通过 ChoreographerSurfaceControl 控制提交时机 游戏、低延迟直播

我个人建议:除非你有特殊需求,否则别自己搞帧率控制。让 SurfaceFlinger 去处理就好。你只需要保证解码器输出的时间戳准确,Buffer 提交及时。

9.4 核心流程图:视频帧从解码到显示

下面这张图,是我自己画的一个简化流程。你看完应该能对整个链路有个直观认识。

视频帧从解码到显示的核心流程 解码器 dequeueOutputBuffer 输出Buffer releaseOutputBuffer Surface 入队 BufferQueue SurfaceFlinger HWC / GPU 屏幕 VSync 信号 注:虚线表示 VSync 信号触发 SurfaceFlinger 消费 Buffer

你看,整个链路其实不复杂。但每个环节都可能出问题。我总结几个常见的坑:

  • Buffer 提交太快:解码器输出速度超过屏幕刷新率,BufferQueue 堆积,延迟越来越高
  • Buffer 提交太慢:SurfaceFlinger 等不到新 Buffer,重复显示上一帧,画面卡顿
  • 时间戳不准:SurfaceFlinger 合成时无法正确插帧,导致画面抖动
  • 颜色格式不匹配:解码器输出的是 NV12,Surface 要求的是 RGBA,中间没有做格式转换

避坑指南:我曾经在一个项目中,解码器输出的是 10bit HDR 数据,但 Surface 配置的是 8bit 的 RGBA_8888。结果画面颜色完全不对。后来在配置 MediaCodec 时,显式指定了 COLOR_FormatSurface,让解码器直接输出到 Surface,绕过了颜色格式转换的问题。

嗯,说到这,我想强调一点:视频渲染这块,不要过度优化。Android 的 SurfaceFlinger 和 BufferQueue 已经做了大量工作。你只需要保证:

  1. 解码器输出时间戳准确
  2. Surface 配置正确(颜色格式、尺寸)
  3. Buffer 提交节奏稳定

做到这三点,大部分播放场景都不会有问题。剩下的,交给系统去处理。


公众号:蓝海资料掘金营,微信deep3321