18、硬件加速与同步:MediaCodec硬件解码、SurfaceView/TextureView渲染、GPU合成时序

各位同学,今天我们来聊聊硬件加速这条“快车道”上的同步问题。

说实话,软件解码在低分辨率时代还能凑合。但到了4K、8K,甚至高帧率HDR,CPU软解就是一条死路。我个人习惯,只要目标设备支持,一律走硬件解码。但硬件加速不是白给的——它把解码、渲染、合成这三件事扔给了不同的硬件单元,同步的复杂度一下子就上来了。

18.1 MediaCodec硬件解码:从CPU到GPU的“快递”

MediaCodec开启硬件解码很简单,但背后的数据流很多人没搞清。

你调用 configure(format, surface, null, 0) 时,如果传了一个Surface进去,MediaCodec就会直接输出到GPU纹理,绕过了CPU。这就是所谓的“零拷贝”路径。我在项目中遇到过,有些同学以为传了Surface就万事大吉,结果发现画面卡顿——其实是因为没处理好同步。

关键点: 硬件解码器输出的Buffer,本质上是GPU端的纹理ID(或GraphicBuffer)。你不能再像软解那样去读ByteBuffer。

那怎么拿到解码后的数据呢?

  • Surface模式: 输出直接到Surface,适合直接显示或传给MediaCodec的输入Surface做转码。
  • ByteBuffer模式: 输出到CPU内存,但硬件解码下这个操作开销很大,不推荐实时场景。

嗯,这里要注意:硬件解码器内部维护了一个Buffer队列。你调用 dequeueOutputBuffer() 时,返回的index对应的Buffer,在Surface模式下其实是一个“空壳”——真正的数据已经在GPU里了。你只需要把它交给渲染器就行。

18.2 SurfaceView vs TextureView:渲染容器的选择

这两个东西,说白了就是Android提供的两个“画板”。但它们的底层机制完全不同。

特性 SurfaceView TextureView
独立窗口 是(有独立的Surface) 否(嵌入View层级)
硬件合成 支持(直接走HWC) 不支持(需GPU合成)
动画/变换 困难(需额外处理) 容易(支持View动画)
同步复杂度 低(独立时序) 高(需与UI线程同步)

我个人习惯:如果只是全屏播放视频,无脑选SurfaceView。它有一个独立的合成层,硬件合成器(HWC)可以直接处理,延迟最低。但如果你要做弹幕、缩放、旋转这些花哨效果,TextureView更灵活——代价是它需要GPU把内容合成到View层级里,多了一步。

避坑指南: 我曾经在直播项目中用TextureView做小窗播放,结果发现CPU占用率比SurfaceView高了20%。后来换成SurfaceView + 自定义动画,才把性能拉回来。记住:每多一次GPU合成,就是一次同步开销

18.3 GPU合成时序:谁先谁后?

现在我们把解码和渲染连起来。硬件解码输出到Surface,Surface背后是一个BufferQueue。消费者(SurfaceView/TextureView)从队列里取Buffer,然后交给GPU合成。

问题来了:GPU什么时候开始合成?

答案是:等VSYNC信号。Android的渲染管线是双缓冲甚至三缓冲的。GPU合成器(HWC或GPU本身)会在每个VSYNC周期开始前,拿到当前可用的最新Buffer,然后合成并送显。

这里有个经典的坑:解码器输出太快,渲染器消费太慢。BufferQueue会堆积,导致延迟越来越高。反过来,如果解码器输出太慢,渲染器会饿死,画面卡顿。

注意: 硬件解码器有自己的时钟,而渲染器依赖VSYNC。这两个时钟是异步的。你必须在解码器和渲染器之间做同步,否则就会出现音画不同步。

怎么同步?我常用的方法是:

  1. 时间戳传递: 解码器输出的每个Buffer都带有一个PTS(显示时间戳)。渲染器在合成时,根据PTS决定是否显示。
  2. 丢帧策略: 如果解码器堆积了太多Buffer,且当前PTS已经落后于系统时钟,直接丢弃旧Buffer,只显示最新的。
  3. 动态调整: 如果发现渲染器跟不上,可以降低解码器的工作频率(比如跳帧解码)。

你想想看,如果解码器输出的是30fps,但屏幕刷新率是60Hz,那每个Buffer应该显示2个VSYNC周期。但如果解码器突然输出一个60fps的流,渲染器就必须在1个VSYNC内完成合成——这时候GPU的压力就大了。

18.4 实战:MediaCodec + SurfaceView 的同步代码骨架

光说不练假把式。我贴一段核心代码,展示如何把硬件解码和SurfaceView串起来,并做基本的同步。

// 初始化解码器,输出到Surface
MediaCodec decoder = MediaCodec.createDecoderByType(mimeType);
decoder.configure(format, surface, null, 0);
decoder.start();

// 渲染循环(在单独线程中)
while (!isStop) {
    int index = decoder.dequeueOutputBuffer(bufferInfo, 10000);
    if (index >= 0) {
        // 获取PTS
        long ptsUs = bufferInfo.presentationTimeUs;
        
        // 判断是否该显示
        if (ptsUs > getCurrentTimeUs()) {
            // 还没到时间,等待
            Thread.sleep((ptsUs - getCurrentTimeUs()) / 1000);
        } else if (ptsUs < getCurrentTimeUs() - 50000) {
            // 落后太多,丢帧
            decoder.releaseOutputBuffer(index, false);
            continue;
        }
        
        // 渲染(Surface模式下,releaseOutputBuffer(true)就会送显)
        decoder.releaseOutputBuffer(index, true);
    }
}
核心逻辑: 通过PTS和当前系统时钟的比较,决定是等待、显示还是丢帧。注意:releaseOutputBuffer(index, true) 的第二个参数表示是否渲染。传true,Buffer就会在下一个VSYNC被合成。

这段代码看起来简单,但实际项目中要考虑更多:

  • 如果解码器输出的是HDR内容,GPU合成时需要做色调映射。
  • 如果SurfaceView被遮挡或不可见,应该暂停渲染,避免浪费GPU资源。
  • 多路视频合成时,需要为每一路维护独立的PTS队列,并在GPU端做混合。

18.5 总结:硬件加速下的同步本质

说白了,硬件加速并没有改变同步的本质——它只是把同步的战场从CPU转移到了GPU。你需要关心的不再是“CPU什么时候解码完”,而是“GPU什么时候合成完”。

我个人经验是:永远不要假设硬件解码器的输出节奏是稳定的。它可能因为温控、功耗、带宽等原因突然变慢。你的同步逻辑必须能自适应。

最后,留一个思考题:如果使用TextureView,并且你在上面叠加了一个自定义的OpenGL渲染层,那么GPU合成时序会变成什么样?嗯,这个问题留给你自己去挖坑填坑吧。

公众号:蓝海资料掘金营,微信deep3321 —— 更多音视频底层干货,欢迎交流。