8、MediaCodec解码实战:H.264/H.265硬解码、Surface与ByteBuffer输出、低延迟解码
各位同学,今天我们来啃一块硬骨头——MediaCodec硬解码。说实话,这个API刚出来那会儿,我也被它折腾得不轻。但一旦摸透了它的脾气,你会发现它其实是Android多媒体开发中最趁手的工具之一。
咱们这节课不讲虚的,直接上实战。我会带着你一步步搭起一个完整的硬解码流水线,把H.264和H.265的码流喂进去,然后分别用Surface和ByteBuffer把画面吐出来。最后再聊聊低延迟解码的那些坑——嗯,这些坑我基本都踩过。
核心知识点一览:
- MediaCodec的异步与同步模式选择
- H.264/H.265码流的基本结构
- Surface输出 vs ByteBuffer输出
- 低延迟解码的关键参数配置
8.1 解码器创建与配置
先说说怎么创建一个解码器。MediaCodec提供了两种创建方式:按MIME类型创建,或者按具体格式创建。我个人习惯用前者,因为更灵活。
// 根据MIME类型创建解码器
MediaCodec decoder = MediaCodec.createDecoderByType("video/avc"); // H.264
// 或者
MediaCodec decoder = MediaCodec.createDecoderByType("video/hevc"); // H.265
创建完之后,需要配置MediaFormat。这里有几个关键参数,我当年第一次写的时候漏掉了CSD,结果解码器一直报错,折腾了大半天。
MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);
// 设置CSD(Codec Specific Data)—— 这个必须有!
format.setByteBuffer("csd-0", csd0Buffer);
format.setByteBuffer("csd-1", csd1Buffer);
// 设置解码帧率,影响解码器内部资源分配
format.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
// 低延迟模式(Android 10+)
format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1);
小提示:CSD数据从哪里来?对于H.264,csd-0对应SPS(序列参数集),csd-1对应PPS(图像参数集)。你可以从码流中提取,也可以让MediaExtractor帮你解析。我建议用后者,省心。
8.2 输入输出缓冲区管理
配置好之后,就要开始处理数据了。MediaCodec有两种工作模式:同步模式和异步模式。Android 5.0之后推荐用异步模式,但很多老项目还在用同步模式。我给你们都讲讲。
同步模式(传统方式)
decoder.configure(format, surface, null, 0);
decoder.start();
// 输入线程
new Thread(() -> {
while (!isEOS) {
int inputIndex = decoder.dequeueInputBuffer(10000); // 10ms超时
if (inputIndex >= 0) {
ByteBuffer inputBuffer = decoder.getInputBuffer(inputIndex);
// 从码流中读取数据填充inputBuffer
int sampleSize = readSampleData(inputBuffer);
decoder.queueInputBuffer(inputIndex, 0, sampleSize, pts, flags);
}
}
}).start();
// 输出线程
new Thread(() -> {
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
while (!isEOS) {
int outputIndex = decoder.dequeueOutputBuffer(info, 10000);
if (outputIndex >= 0) {
// 如果是Surface输出,这里直接渲染
decoder.releaseOutputBuffer(outputIndex, true); // true表示渲染到Surface
// 如果是ByteBuffer输出,需要手动处理
// ByteBuffer outputBuffer = decoder.getOutputBuffer(outputIndex);
// decoder.releaseOutputBuffer(outputIndex, false);
}
}
}).start();
你看,同步模式就是两个线程来回倒腾。但这里有个坑——dequeueInputBuffer的超时时间设多少合适?设太短CPU空转,设太长又可能丢帧。我一般设10ms,兼顾了响应和功耗。
异步模式(推荐)
decoder.setCallback(new MediaCodec.Callback() {
@Override
public void onInputBufferAvailable(MediaCodec codec, int index) {
ByteBuffer inputBuffer = codec.getInputBuffer(index);
// 填充数据
int sampleSize = readSampleData(inputBuffer);
codec.queueInputBuffer(index, 0, sampleSize, pts, flags);
}
@Override
public void onOutputBufferAvailable(MediaCodec codec, int index, BufferInfo info) {
// Surface输出
codec.releaseOutputBuffer(index, true);
// 或者ByteBuffer输出
// ByteBuffer outputBuffer = codec.getOutputBuffer(index);
// 处理数据...
// codec.releaseOutputBuffer(index, false);
}
@Override
public void onError(MediaCodec codec, MediaCodec.CodecException e) {
// 错误处理
}
@Override
public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {
// 输出格式变化,比如分辨率动态改变
}
});
decoder.configure(format, surface, null, 0);
decoder.start();
异步模式的好处很明显——不用自己管理线程,回调来了就处理。但要注意,回调是在Binder线程中执行的,不要在里面做耗时操作。
注意:异步模式下,onOutputBufferAvailable回调中如果处理ByteBuffer数据,建议把数据拷贝出来再处理,不要持有Buffer引用。否则可能阻塞解码器内部流水线。
8.3 Surface输出 vs ByteBuffer输出
这两种输出方式,说白了就是「直接显示」和「拿到数据自己玩」的区别。
| 对比维度 | Surface输出 | ByteBuffer输出 |
|---|---|---|
| 性能 | 极高,零拷贝 | 较低,需要内存拷贝 |
| 适用场景 | 播放器、视频通话 | 截图、滤镜、AI分析 |
| 颜色格式 | 由Surface决定,通常是NV12 | 可指定,常见YUV420或RGBA |
| 实现复杂度 | 简单,releaseOutputBuffer传true即可 | 复杂,需要处理颜色转换 |
Surface输出是最推荐的方式。你想想看,解码器直接渲染到Surface上,数据都不用经过App进程,效率自然高。我在做视频播放器的时候,Surface输出能做到4K 60fps毫无压力。
ByteBuffer输出呢?适合那些需要对每一帧做处理的场景。比如你要做截图、加滤镜、或者跑AI模型。但要注意,ByteBuffer输出的颜色格式通常是YUV420,如果你要转成Bitmap,还得做颜色空间转换。
// ByteBuffer输出时获取YUV数据
ByteBuffer outputBuffer = decoder.getOutputBuffer(outputIndex);
// 获取Y、U、V分量的偏移和步长
MediaFormat outputFormat = decoder.getOutputFormat();
int stride = outputFormat.getInteger("stride");
int sliceHeight = outputFormat.getInteger("slice-height");
// 然后根据YUV420的布局去解析数据...
我的经验:如果你只是要显示画面,永远用Surface输出。只有当你真的需要操作像素数据时,才考虑ByteBuffer。别为了「灵活」而牺牲性能,不值当。
8.4 低延迟解码配置
低延迟解码,说白了就是让解码器尽快吐出第一帧。这在视频通话、直播场景中特别重要。谁也不想对着屏幕等半天才看到对方的脸,对吧?
Android 10开始,MediaCodec提供了一个专门的参数:
format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1);
这个参数告诉解码器:别缓存太多帧,有数据就赶紧处理。但代价是可能会牺牲一些压缩效率,因为解码器没法做帧间参考优化了。
除了这个参数,还有几个技巧可以降低延迟:
- 减小输入缓冲区数量:默认是4个,可以尝试设为2个
- 使用异步模式:减少线程切换开销
- 及时释放输出缓冲区:不要持有Buffer不放
- 合理设置PTS:PTS要连续且准确,乱序会导致解码器内部重排
// 低延迟配置完整示例
MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);
format.setByteBuffer("csd-0", csd0);
format.setByteBuffer("csd-1", csd1);
format.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1);
// 可选:限制输入缓冲区数量
format.setInteger("max-input-buffers", 2);
曾经踩过的坑:我在一个项目中把KEY_LOW_LATENCY设为了1,结果在某些老设备上解码器直接崩溃了。后来发现是厂商实现不完整。所以建议在代码中做版本判断:Android 10+才启用这个参数,低版本用其他方式优化。
8.5 实战:完整解码流程
好了,理论说完了,咱们来写一个完整的解码器。这个例子用异步模式,Surface输出,并开启了低延迟。
public class H264Decoder {
private MediaCodec decoder;
private Surface surface;
private boolean isRunning;
public void init(Surface surface, int width, int height, byte[] sps, byte[] pps) {
this.surface = surface;
MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);
format.setByteBuffer("csd-0", ByteBuffer.wrap(sps));
format.setByteBuffer("csd-1", ByteBuffer.wrap(pps));
format.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1);
}
try {
decoder = MediaCodec.createDecoderByType("video/avc");
decoder.setCallback(new DecoderCallback());
decoder.configure(format, surface, null, 0);
decoder.start();
isRunning = true;
} catch (IOException e) {
e.printStackTrace();
}
}
public void feedData(byte[] data, long pts, int flags) {
// 外部调用,将码流数据送入解码器
// 实际项目中通常配合MediaExtractor或网络接收线程
}
public void release() {
isRunning = false;
if (decoder != null) {
decoder.stop();
decoder.release();
decoder = null;
}
}
private class DecoderCallback extends MediaCodec.Callback {
@Override
public void onInputBufferAvailable(MediaCodec codec, int index) {
// 从队列中取出待解码数据
// 填充到codec.getInputBuffer(index)
// 然后codec.queueInputBuffer(...)
}
@Override
public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {
// 直接渲染到Surface
codec.releaseOutputBuffer(index, true);
}
@Override
public void onError(MediaCodec codec, MediaCodec.CodecException e) {
// 错误处理
}
@Override
public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {
// 输出格式变化
}
}
}
这个框架搭好了,剩下的就是往里面填数据了。实际项目中,数据可能来自网络、本地文件或者摄像头。不管来源是什么,解码器的接口都是一样的。
总结一下这节课的核心:
- 创建解码器时,CSD数据必不可少,否则解码器不干活
- Surface输出性能最优,ByteBuffer输出适合需要处理像素的场景
- 低延迟解码需要KEY_LOW_LATENCY参数配合合理的缓冲区管理
- 异步模式是主流选择,代码更简洁,性能更好
嗯,这节课的内容就到这儿。硬解码这块儿,说白了就是「配置-输入-输出」三个环节。只要把每个环节的细节抠到位,解码器就能跑得又快又稳。各位同学回去可以自己搭个Demo试试,把H.264和H.265的码流都跑一遍,感受一下区别。