2. MediaCodec 工作流程与状态机详解

做 Android 多媒体开发,绕不开 MediaCodec。你可以把它想象成一条编解码流水线——输入原始数据,输出压缩或解压后的数据。但这条流水线有严格的操作步骤和内部状态,一旦搞错顺序或者没处理好状态,卡顿、闪退甚至死锁就找上门了。

我个人习惯把 MediaCodec 的生命周期拆成两大部分:工作流程(你“怎么调” API)和状态机(底层“怎么运转”)。理解了这个,后面排查卡顿的根因才能准。

2.1 工作流程:七步走

标准的使用流程其实就七个步骤,每个步骤都有坑。我列个清单,你对照着看。

  1. 创建解码器/编码器
    用 MediaCodec.createDecoderByType(mimeType) 或 createEncoderByType。注意:MimeType 必须和实际码流一致,否则后面 configure 会报错。
  2. 配置参数
    configure(format, surface, crypto, flags)。如果是解码到 Surface,要传 Surface 对象;如果只是 Buffer 模式传 null。我曾在项目中遇到过把尺寸写错,结果画面被拉伸成碎片。
  3. 启动
    start()。这个调用会让状态机从 Configured 切换到 Executing。很多人忘了调这一步,就在那 deque 缓冲区,然后一直返回 -1。嗯,注意看日志。
  4. 循环:输入数据
    dequeueInputBuffer(timeoutUs) → 拿到 index → queueInputBuffer(index, offset, size, presentationTimeUs, flags)。
    timeoutUs 一般设 0 或 10000,千万别设 -1 阻塞在 UI 线程,会 ANR。
  5. 循环:输出数据
    dequeueOutputBuffer(info, timeoutUs) → 根据 info 的状态处理 → releaseOutputBuffer(index, render)。
    要检查 info.flags 里的 BUFFER_FLAG_END_OF_STREAM 来结束循环。
  6. 停止
    stop()。注意:停止后可以重新 configure + start 复用,不必每次都重复创建。
  7. 释放
    release()。必须调,否则内存泄漏。
个人小经验:我曾经调试一个直播推流 app,发现 MediaCodec 编码器在 start 之后偶尔返回 INFO_TRY_AGAIN_LATER 持续几百毫秒。后来发现是 Surface 输入帧率不稳定,导致内部 Buffer 分配来不及。解决办法是增大 inputBufferSize 或改用异步模式。

2.2 状态机:五态 + 一个错误态

为什么要有状态机?因为 MediaCodec 内部资源是有限的,比如硬件编解码器实例、DMA 缓冲区。状态机保证了外部调用不会乱来。比如你没 Configure 就 Start,它会直接抛异常。

我简化一下,把状态归纳为 5 个主要态 + Error 态:

状态含义常见进入方式
Uninitialized刚创建,未配置new MediaCodec()
Configured已配置,可 startconfigure()
Executing正在编解码start()
—— Flushed队列清空,属于 Executing 内的子状态flush()
—— Running正常处理数据start 后的默认子状态
—— End-of-Stream输入结束,等待输出消耗完输入标记 BUFFER_FLAG_END_OF_STREAM
Error不可恢复错误内部异常或不合规操作

注意:Flushed、Running、End-of-Stream 其实都是 Executing 内部的子状态。真正对外暴露的只有 getState() 返回的 Uninitialized、Configured、Executing、Error。但理解内部子状态对调优很有帮助。

我曾经踩过的坑:在解码 H.265 时,连续 100 帧后 MediaCodek 突然进入 Error 状态,而且没有任何异常日志。后来发现是厂商的硬件解码器不支持 Profile 的某些 Level,configure 通过了但运行时崩溃。我的教训是:一定要监听 MediaCodec.Callback.onError,并备选软件解码方案。

为什么会出现 Flushed 子状态?你想想看,当你调 flush() 时,内部会把所有 input/output buffer 清空,重新等待数据。这个操作常用于 seek 或者清晰度切换。但要注意:flush 后 presentationTimeUs 不能从 0 开始,否则解码器会认为你重置了时间线,产生 PTS 抖动。

2.3 同步 vs 异步:我选后者

早期 MediaCodec 只有同步模式(你主动 deque 循环)。但 API 21 之后,官方推荐用异步回调模式。为什么?因为它能更优雅地处理背压和帧率匹配。

同步模式下,你必须在循环里反复调用 dequeueInputBuffer 和 dequeueOutputBuffer。如果某个缓冲区被占满,你会卡在 timeout 里,容易导致帧延迟。异步模式则通过 setCallback 注册,当有可用输入/输出 buffer 时回调你,你不用自己轮询。

// 异步模式示例(API 21+)
MediaCodec codec = MediaCodec.createDecoderByType("video/avc");
codec.setCallback(new MediaCodec.Callback() {
    @Override
    public void onInputBufferAvailable(MediaCodec codec, int index) {
        // 填充数据并 queue
        ByteBuffer buffer = codec.getInputBuffer(index);
        // 从网络/文件读取...
        codec.queueInputBuffer(index, 0, size, pts, flags);
    }

    @Override
    public void onOutputBufferAvailable(MediaCodec codec, int index, BufferInfo info) {
        // 处理输出帧
        codec.releaseOutputBuffer(index, true);
    }

    @Override
    public void onError(MediaCodec codec, MediaCodec.CodecException e) {
        // 及时降级
    }

    @Override
    public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {
        // 宽高变化时重新配置渲染器
    }
});
codec.configure(format, surface, null, 0);
codec.start();
性能调优建议:异步模式下,onOutputBufferAvailable 回调是运行在内部 binder 线程,千万别在里面做耗时操作(比如写文件、绘制 UI),否则会阻塞解码器输出,导致帧率骤降。我习惯把数据通过 Handler 抛到工作线程处理。

2.4 状态转换图:一图胜千言

下面这张 SVG 展示了 MediaCodec 的完整状态机和工作流程。我把核心节点、转换箭头和说明都画出来了,方便你对照。

MediaCodec 状态机与工作流程 Uninitialized Configured Executing (子状态) Flushed Running End-of-Stream Error configure() start() queue data EOS flag flush() 异常/不合规操作 release() 然后重新 create 说明: 1. 所有状态转换都需要按照箭头方向严格执行,跳过会抛异常。 2. Executing 内部三个子状态:Flushed(清空)、Running(正常处理)、End-of-Stream(输入结束)。 3. 发生错误只能通过 release() 回到 Uninitialized,然后重新创建。不可逆。

2.5 避坑指南:我的血泪史

最后我整理几个实战中容易忽略的点,你写代码时可以逐个对照。

  • 一定要检查 dequeueOutputBuffer 的返回值。 不要只处理 INFO_TRY_AGAIN_LATER,还要处理 INFO_OUTPUT_FORMAT_CHANGED(宽高变化)和 INFO_OUTPUT_BUFFERS_CHANGED(缓冲区重新分配)。忽略后者有时会导致 getOutputBuffer(index) 返回 null。
  • 同步模式下设 timeoutUs = 0? 如果场景需要精确控制帧率(比如直播编码),建议设一个合理的值(1000~10000 μs),不要一直自旋,否则 CPU 发热会导致降频。
  • Surface 模式下别忘记 releaseOutputBuffer(index, true) 的第二个参数。 只有传 true 才会渲染到 Surface,否则画面会一直卡在上一帧。我曾经调试了一个小时才发现是这里忘了传 true。
  • 编码器输入数据的时间戳必须单调递增。 硬件编码器对 PTS 乱序非常敏感,可能导致画面撕裂或卡顿。我在对接第三方推流时遇到过对方发来的 I 帧 PTS 比上一帧还小,结果编码器直接报错。
  • 异步模式下注意线程安全。 Callback 运行在 Binder 线程池,不要直接操作 UI 或者耗时 I/O。用 Handler 派发到自己的处理线程。
一个小技巧:如果卡顿定位困难,可以打开 adb shell dumpsys media.codec 查看当前 MediaCodec 实例的状态、buffer 使用情况、pending input/output 数量。我曾用它发现某个厂商解码器在 4K 60fps 下 input 队列始终不满,原来是 Surface 生产端帧率不对。