5、同步解码实战:同步模式代码框架、Buffer轮询与状态机、时间戳管理、渲染与显示同步

各位同学,今天我们来啃一块硬骨头——同步解码。说实话,很多做Android视频开发的朋友,一听到“同步”两个字就觉得简单,不就是等解码完再渲染吗?嗯,理论上是这样,但实际落地的时候,坑多得很。

我在早期做直播回放项目时,就吃过同步模式的亏。当时觉得异步回调太复杂,干脆用同步模式,结果画面卡成PPT。后来才明白,同步模式不是“傻等”,而是一套精密的流水线管理。今天我就把这套方法论拆开揉碎了讲给你听。

5.1 同步模式代码框架

先搭个架子。同步解码的核心思路很简单:你给MediaCodec一个输入Buffer,它给你一个输出Buffer,中间没有回调,全靠你主动轮询

我个人习惯把同步解码封装成一个类,叫SyncDecoder。核心流程就三步:

  1. 配置:设置MIME类型、分辨率、码率等参数
  2. 循环:不断喂数据、取数据
  3. 清理:释放资源

来看一个最简框架:

public class SyncDecoder {
    private MediaCodec codec;
    private MediaFormat format;
    private ByteBuffer[] inputBuffers;
    private ByteBuffer[] outputBuffers;
    private MediaCodec.BufferInfo bufferInfo;

    public void init(String mime, int width, int height) {
        format = MediaFormat.createVideoFormat(mime, width, height);
        codec = MediaCodec.createDecoderByType(mime);
        codec.configure(format, surface, null, 0);
        codec.start();
        inputBuffers = codec.getInputBuffers();
        outputBuffers = codec.getOutputBuffers();
        bufferInfo = new MediaCodec.BufferInfo();
    }

    public void decodeLoop() {
        // 主循环,后面详细展开
    }

    public void release() {
        codec.stop();
        codec.release();
    }
}

注意,getInputBuffers()getOutputBuffers()在API 21之后已经标记为deprecated,但我建议你继续用——为什么?因为同步模式下,Buffer数组不会动态变化,用起来反而更直观。我在项目中一直这么用,没出过问题。

5.2 Buffer轮询与状态机

Buffer轮询是同步解码的心脏。说白了,就是在一个while循环里,不断问MediaCodec:“有空的输入Buffer吗?有填好的输出Buffer吗?”

这里有个关键点:状态机。MediaCodec内部维护了一套状态,你得按规矩来。我画了一张图,帮你理清逻辑:

同步解码Buffer轮询状态机 START dequeueInputBuffer queueInputBuffer dequeueOutputBuffer 循环 说明:虚线箭头表示循环轮询,直到解码器输出可用

核心代码长这样:

public void decodeLoop(byte[] inputData, long ptsUs) {
    // 1. 获取输入Buffer
    int inputIndex = codec.dequeueInputBuffer(TIMEOUT_US);
    if (inputIndex >= 0) {
        ByteBuffer inputBuf = inputBuffers[inputIndex];
        inputBuf.clear();
        inputBuf.put(inputData);
        codec.queueInputBuffer(inputIndex, 0, inputData.length, ptsUs, 0);
    }

    // 2. 获取输出Buffer
    int outputIndex = codec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US);
    switch (outputIndex) {
        case MediaCodec.INFO_TRY_AGAIN_LATER:
            // 没准备好,下次再试
            break;
        case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
            // 格式变了,更新配置
            MediaFormat newFormat = codec.getOutputFormat();
            break;
        case MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED:
            // Buffer数组变了(API 21+ 不常见)
            outputBuffers = codec.getOutputBuffers();
            break;
        default:
            // 正常输出
            ByteBuffer outputBuf = outputBuffers[outputIndex];
            // 处理数据...
            codec.releaseOutputBuffer(outputIndex, true); // true表示渲染到Surface
            break;
    }
}
⚠️ 注意: TIMEOUT_US不要设太大。我一般用1000微秒(1毫秒)。设大了会阻塞主线程,设小了CPU空转。1毫秒是个平衡点。

5.3 时间戳管理

时间戳是同步解码的灵魂。没有时间戳,你解码出来的画面就是一堆乱序的帧。

MediaCodec要求每个输入Buffer都带上PTS(Presentation Time Stamp),单位是微秒。输出Buffer的bufferInfo.presentationTimeUs会原样返回这个值。

我在项目中遇到过一个问题:时间戳溢出。有一次处理一个超长视频,PTS累加到了几十亿微秒,结果int类型溢出了,画面开始乱跳。后来我改用long类型,才彻底解决。

时间戳管理的几个要点:

  • 单调递增:PTS必须严格递增,不能回退。B帧场景下,解码顺序和显示顺序不同,但PTS依然要递增。
  • 单位统一:全部用微秒。如果你从其他地方拿到的是毫秒或纳秒,记得转换。
  • 起始对齐:第一帧的PTS从0开始,或者从你指定的起始时间开始。

来看一个时间戳生成示例:

// 假设帧率是30fps,每帧间隔约33333微秒
long frameIntervalUs = 1000000 / 30; // 33333
long pts = 0;

for (int i = 0; i < totalFrames; i++) {
    // 喂数据
    int inputIndex = codec.dequeueInputBuffer(TIMEOUT_US);
    if (inputIndex >= 0) {
        ByteBuffer buf = inputBuffers[inputIndex];
        buf.put(frameData[i]);
        codec.queueInputBuffer(inputIndex, 0, frameData[i].length, pts, 0);
        pts += frameIntervalUs; // 递增
    }
    // 取数据...
}
💡 小技巧: 如果你处理的是实时流(比如摄像头),PTS最好用System.nanoTime() / 1000来生成。这样能保证时间戳和系统时钟对齐,渲染时不容易卡顿。

5.4 渲染与显示同步

最后一步,也是最容易出问题的一步——渲染同步。

同步模式下,渲染通常通过releaseOutputBuffer(index, true)来完成。第二个参数true表示“立即渲染到Surface”。但这里有个陷阱:渲染时机

你想想看,如果你解码一帧就渲染一帧,那播放速度完全取决于解码速度。如果解码比显示快,画面会飞;如果解码慢,画面会卡。

正确的做法是:根据PTS控制渲染节奏。我常用的方案是“基于系统时钟的同步”:

long startTime = System.nanoTime() / 1000; // 起始时间,微秒

while (解码循环) {
    int outputIndex = codec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US);
    if (outputIndex >= 0) {
        long expectedTime = startTime + bufferInfo.presentationTimeUs;
        long currentTime = System.nanoTime() / 1000;

        if (currentTime < expectedTime) {
            // 还没到显示时间,等一会儿
            long sleepUs = expectedTime - currentTime;
            if (sleepUs > 0) {
                Thread.sleep(sleepUs / 1000, (int)(sleepUs % 1000) * 1000);
            }
        }

        // 渲染
        codec.releaseOutputBuffer(outputIndex, true);
    }
}

这段代码的核心逻辑是:计算当前帧应该显示的时间,如果还没到,就休眠等待。这样能保证视频以正确的速度播放。

🔑 关键点: 休眠精度问题。Thread.sleep在Android上精度不高,尤其在高帧率(60fps)下,误差可能达到几毫秒。我建议用Object.wait(long)或者Condition.awaitNanos()来替代,精度更高。

另外,别忘了处理丢帧场景。如果解码器输出太快,而显示跟不上,你可以选择跳过某些帧。判断依据很简单:如果当前PTS比系统时间落后太多(比如超过100毫秒),就直接释放Buffer但不渲染:

long drift = currentTime - expectedTime;
if (drift > 100_000) { // 落后超过100毫秒
    codec.releaseOutputBuffer(outputIndex, false); // 不渲染,直接丢弃
    continue;
}

我曾经在一个低端机上遇到过这种情况:解码速度只有15fps,但显示要求30fps。如果不丢帧,画面会越来越卡,最后直接ANR。后来加了丢帧逻辑,虽然画面有点跳,但至少能流畅播放。

好了,同步解码的核心内容就这些。记住三个关键词:轮询、时间戳、渲染同步。把这三点吃透,同步解码对你来说就是小菜一碟。