一、整体流程
[网络接收 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();
关键点:
-
Surface 直接渲染:
configure(format, surface, null, 0)第三个参数传surface,解码器输出直接渲染到 Surface。- 后续
releaseOutputBuffer(index, true)第二个参数传true,表示渲染到 Surface。 - 零拷贝:解码后的 YUV 数据不经过 Java 层,直接从 GPU 渲染到屏幕,省一次内存拷贝。
-
KEY_MAX_INPUT_SIZE = 1MB:
- 解码器输入缓冲区大小。H.264 关键帧可能很大(720p IDR 可达几百 KB),1MB 是保守值。
- 如果实际接收到的帧超过 1MB,
queueInputBuffer会报IllegalArgumentException。
-
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;
}
关键点:
-
getInputBuffers() 数组方式:
- 这是 API 21 之前的兼容写法。
getInputBuffer(index)是 API 21+ 的新方式。 - 教育硬件很多是 Android 差异较大,用数组方式兼容性最好。
- 这是 API 21 之前的兼容写法。
-
dequeueInputBuffer(-1) :
- 阻塞等待,直到有可用输入缓冲区。视频解码不能丢帧,否则画面卡顿。
- 与编码侧的
0(非阻塞)不同,解码侧通常用阻塞或较长超时。
-
queueInputBuffer 的 pts = 0:
mVideoCodec.queueInputBuffer(inputBufferIndex, 0, len, 0, 0);- 代码里把 pts 传进来但没用,直接写
0。 - 解释:实时通话场景,设备端只负责"收到就解码显示",不需要严格按时间戳排序(没有倍速、快进、倒放需求)。但如果后续要做音画同步,这里应该用真实 pts。
- 代码里把 pts 传进来但没用,直接写
-
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 保障并发安全