Android 音视频通话核心二 —— 视频解码详解记录

6 阅读8分钟

一、整体流程

[网络接收 H.264 NAL]decoderH264(data, len, pts) 
↓ 
MediaCodec InputBuffer (H.264) 
↓ 
MediaCodec 硬解码 (AVC Baseline) 
↓ 
MediaCodec OutputBuffer → Surface (直接渲染) 
↓ 
[设备端屏幕显示]

二、解码器初始化:Surface 零拷贝渲染


**与编码侧的关键差异**:
- 编码侧:YUV  MediaCodec  H.264  网络
- 解码侧:网络  H.264  MediaCodec  Surface(零拷贝,不经过 Java  ByteBuffer)

---

## 二、解码器初始化:Surface 零拷贝渲染

### 2.1 核心配置

```java
mVideoCodec = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
MediaFormat mFormat = MediaFormat.createVideoFormat(MIMETYPE_VIDEO_AVC, width, height);
mFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 1024 * 1024);
mFormat.setInteger(MediaFormat.KEY_ROTATION, 90);  //  关键
mFormat.setInteger(MediaFormat.KEY_PROFILE, AVCProfileBaseline);
mFormat.setInteger(MediaFormat.KEY_LEVEL, AVCLevel31);

mVideoCodec.configure(mFormat, surface, null, 0);  //  surface 传入
mVideoCodec.start();

关键点

  1. Surface 直接渲染

    • configure(format, surface, null, 0) 第三个参数传 surface,解码器输出直接渲染到 Surface。
    • 后续 releaseOutputBuffer(index, true) 第二个参数传 true,表示渲染到 Surface。
    • 零拷贝:解码后的 YUV 数据不经过 Java 层,直接从 GPU 渲染到屏幕,省一次内存拷贝。
  2. KEY_MAX_INPUT_SIZE = 1MB

    • 解码器输入缓冲区大小。H.264 关键帧可能很大(720p IDR 可达几百 KB),1MB 是保守值。
    • 如果实际接收到的帧超过 1MB,queueInputBuffer 会报 IllegalArgumentException
  3. Profile/Level 与编码侧保持一致

    • 编码侧配置了 Baseline + Level 3.1,解码侧必须一致,否则可能无法解码。

2.2 重初始化机制(支持分辨率切换)

public void startVideo(int width, int height, Surface surface) throws IOException {
    synchronized (codecLock) {
        if (mVideoCodec != null) {
            mVideoCodec.stop();
            mVideoCodec.release();
            mVideoCodec = null;
        }
        if (mVideoExecutor != null && !mVideoExecutor.isShutdown()) {
            mVideoExecutor.shutdownNow();
        }
        initVideo(width, height, surface);
    }
}

场景:通话过程中切换前后摄像头、或网络层协商变更分辨率时,需要销毁旧解码器,按新分辨率重建。

  • 先停旧解码器,再建新解码器,避免两个解码器同时占用 VPU 资源。
  • codecLock 保证重初始化过程中,解码线程不会操作旧解码器。

三、KEY_ROTATION:解码器层的方向纠正

3.1 为什么需要旋转?

  • 小程序端是竖屏(宽高比 9:16),设备端摄像头是横屏安装(宽高比 16:9)。
  • 如果直接解码横屏 H.264 到竖屏 Surface,画面会逆时针旋转 90°,人像是横躺的。

3.2 代码逻辑

if (BuildConfig.FLAVOR.equals("zgll")) {
    if (TweCallStateManager.INSTANCE.isIncomingCall()) {
        mFormat.setInteger(MediaFormat.KEY_ROTATION, 0);
    } else {
        mFormat.setInteger(MediaFormat.KEY_ROTATION, 90);
    }
} else {
    mFormat.setInteger(MediaFormat.KEY_ROTATION, 90);
}

  • 普通设备:固定旋转 90°,纠正横竖屏差异。

  • zgll 设备

    • 接听(小程序打进来) :画面方向已经正确,不需要旋转(0)。
    • 拨打(设备端发起) :需要旋转 90° 纠正。
  • 为什么接听和拨打不一样? 因为摄像头安装方向固定,但两端谁是"发起方"决定了信令协商时的画面方向约定。接听时小程序已经按设备方向发送了,拨打时小程序按自己竖屏发送,需要设备端纠正。

注意KEY_ROTATION 并非所有芯片都支持。如果某些设备设置后无效,需要回退到 Java 层旋转(但你的代码里没做 fallback,文章里可以提一句"经测试目标设备均支持")。


四、解码循环:InputBuffer / OutputBuffer

public int decoderH264(byte[] data, int len, long pts) {
    currentVideoPts = pts;
    if (isStopping || mVideoExecutor == null || mVideoExecutor.isShutdown()) return -1;
    if (mVideoCodec == null) return -2;

    mVideoExecutor.execute(() -> {
        synchronized (codecLock) {
            if (mVideoCodec == null || isStopping) return;

            try {
                // 1. 输入 H.264 数据
                ByteBuffer[] inputBuffers = mVideoCodec.getInputBuffers();
                int inputBufferIndex = mVideoCodec.dequeueInputBuffer(-1);
                if (inputBufferIndex >= 0) {
                    ByteBuffer inputBuffer = inputBuffers[inputBufferIndex];
                    inputBuffer.clear();
                    inputBuffer.put(data, 0, len);
                    mVideoCodec.queueInputBuffer(inputBufferIndex, 0, len, 0, 0);  // ← pts=0
                }

                // 2. 输出并渲染到 Surface
                MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
                int outputBufferIndex = mVideoCodec.dequeueOutputBuffer(bufferInfo, 0);
                while (outputBufferIndex >= 0) {
                    mVideoCodec.releaseOutputBuffer(outputBufferIndex, true);  // ← true = 渲染
                    outputBufferIndex = mVideoCodec.dequeueOutputBuffer(bufferInfo, 0);
                }
            } catch (IllegalStateException e) {
                // 解码器状态异常(如正在 stop)
            }
        }
    });
    return 0;
}

关键点

  1. getInputBuffers() 数组方式

    • 这是 API 21 之前的兼容写法。getInputBuffer(index) 是 API 21+ 的新方式。
    • 教育硬件很多是 Android 差异较大,用数组方式兼容性最好。
  2. dequeueInputBuffer(-1)

    • 阻塞等待,直到有可用输入缓冲区。视频解码不能丢帧,否则画面卡顿。
    • 与编码侧的 0(非阻塞)不同,解码侧通常用阻塞或较长超时。
  3. queueInputBuffer 的 pts = 0

    mVideoCodec.queueInputBuffer(inputBufferIndex, 0, len, 0, 0);
    

    • 代码里把 pts 传进来但没用,直接写 0
    • 解释:实时通话场景,设备端只负责"收到就解码显示",不需要严格按时间戳排序(没有倍速、快进、倒放需求)。但如果后续要做音画同步,这里应该用真实 pts。
  4. releaseOutputBuffer(index, true)

    • 第二个参数 true灵魂。如果传 false,解码后的画面不会显示到 Surface,数据直接丢弃。
    • true 时,MediaCodec 内部把 OutputBuffer 的图像数据直接送给 SurfaceFlinger 合成显示,Java 层无需关心 YUV 数据。

五、资源释放:与编码器对称

public void stopVideo() {
    isStopping = true;

    // 1. 停止接收新帧
    if (mVideoExecutor != null) {
        mVideoExecutor.shutdown();
        try {
            if (!mVideoExecutor.awaitTermination(2, TimeUnit.SECONDS)) {
                mVideoExecutor.shutdownNow();
            }
        } catch (InterruptedException e) {
            mVideoExecutor.shutdownNow();
        }
        mVideoExecutor = null;
    }

    // 2. 释放解码器
    synchronized (codecLock) {
        if (mVideoCodec != null) {
            try {
                mVideoCodec.stop();
                mVideoCodec.release();
            } catch (Exception e) {
                Log.e(TAG, "Error stopping decoder: " + e.getMessage());
            } finally {
                mVideoCodec = null;
            }
        }
    }
}

与编码侧的差异

  • 编码侧:isStopping + codecLock + awaitTermination + 重建线程池。
  • 解码侧:逻辑几乎一致,但不需要重建线程池(下次 startVideo 会新建 mVideoExecutor)。

六、调试技巧:H.264 裸流保存与离线分析

private void saveRawDataStream(byte[] data) {
    decoderH264executor.submit(() -> {
        if (fos != null) {
            try {
                fos.write(data);
                fos.flush();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    });
}

6.1 为什么保存裸流?

线上出现花屏/黑屏时,需要判断是网络丢包还是解码器 Bug

  • 如果裸流用 ffplay 播放正常 → 解码器问题
  • 如果裸流用 ffplay 播放也花屏 → 网络/发送端问题

6.2 如何用 ffplay 播放

# 直接播放 H.264 裸流(无容器)
ffplay -f h264 -framerate 15 /sdcard/videoDecoder.h264

# 或先封装成 MP4 再播放
ffmpeg -framerate 15 -i /sdcard/videoDecoder.h264 -c copy output.mp4

6.3 用 Elecard StreamEye 分析

  • 查看每一帧的 NAL 类型(SPS/PPS/IDR/P)
  • 检查 IDR 间隔是否均匀
  • 检查是否有丢帧(序号不连续)

七、踩坑记录

坑 1:画面方向不对,人像横躺

  • 现象:小程序端竖屏拍摄,设备端显示时画面逆时针旋转 90°。
  • 解决MediaFormat.KEY_ROTATION 让解码器内部处理旋转,零 CPU 开销。

坑 2:首帧黑屏,几秒后才出画面

  • 现象:接通后前 3 秒黑屏,之后正常。
  • 根因:编码侧首帧不是 IDR(或 SPS/PPS 丢失),解码器无法初始化解码上下文。
  • 排查:保存裸流,用 ffplay 播放,发现首帧是 P 帧。
  • 解决:编码侧强制首帧 IDR + SPS/PPS 合并(见上篇编码文章)。

坑 3:切换分辨率后崩溃

  • 现象:通话中切换前后摄像头,应用崩溃。
  • 根因:旧解码器还在运行,新解码器又创建,MediaCodec 实例冲突。
  • 解决startVideo() 里先 stop() + release() 旧解码器,再创建新的。

坑 4:releaseOutputBuffer 传 false,画面不显示

  • 现象:解码器无报错,但 Surface 一直黑屏。
  • 根因releaseOutputBuffer(outputBufferIndex, false),数据直接丢弃,没有送给 Surface。
  • 解决:必须传 true

坑 5:解码器 stop 时抛 IllegalStateException

  • 现象:挂断时崩溃。
  • 根因stopVideo() 释放解码器时,decoderH264 的线程正在 dequeueOutputBuffer
  • 解决isStopping 标志 + codecLock + awaitTermination 等待线程结束。

八、问题

Q1:解码侧为什么用 Surface 直接渲染,而不是输出到 ByteBuffer?

Surface 渲染是零拷贝方案。解码器输出直接送给 SurfaceFlinger,不经过 Java 层内存。如果输出到 ByteBuffer,还需要手动做 YUV → RGB 转换再绘制,CPU 和内存开销都大。

Q2:KEY_ROTATION 和 Java 层 YUV 旋转有什么区别?

KEY_ROTATION 是解码器内部在 GPU/硬件层面做旋转,零开销。Java 层旋转需要把解码后的 YUV 数据读回内存,做矩阵变换,再写回,数据量大且慢。

Q3:为什么 queueInputBuffer 的 pts 传 0?

实时通话没有快进/倒放需求,设备端收到即解码即显示,不需要严格时间戳排序。但如果要做音画同步,应该用真实 pts。

Q4:H.264 裸流保存后,如何确认是编码问题还是解码问题?

用 ffplay 或 VLC 直接播放裸流。如果播放正常,说明码流没问题,是解码器渲染或 Surface 生命周期问题。如果播放也花屏,说明码流本身有问题,查编码侧或网络层。

Q5:解码器需要像编码器那样处理 SPS/PPS 吗?

不需要。解码器收到带 SPS/PPS 的 H.264 流后,内部会自动提取并配置。但如果首帧没有 SPS/PPS,解码器会报错或黑屏,所以编码侧必须保证首帧带 SPS/PPS + IDR。


九、总结

本文从解码器初始化、KEY_ROTATION 方向纠正、Surface 零拷贝渲染、解码循环、裸流调试五个环节,拆解了 Android 设备端 H.264 硬解码全链路。核心要点:

  • Surface 直接渲染是实时视频解码的最优方案,零拷贝低延迟
  • KEY_ROTATION 在解码器层解决横竖屏方向差异,避免 Java 层 YUV 旋转
  • Profile/Level 与编码侧保持一致,确保兼容性
  • pts=0 简化实时播放,但音画同步场景需传入真实时间戳
  • H.264 裸流保存 + ffplay 离线分析是定位花屏/黑屏的高效手段
  • isStopping + codecLock + awaitTermination 保障并发安全