4、MediaCodec硬编码入门:MediaCodec工作流程、配置编码器(H.264/AAC)、输入输出缓冲区处理
好,咱们今天来啃硬骨头——MediaCodec。说实话,这个API刚出来那会儿,我也觉得挺绕的。但一旦你摸清了它的脾气,就会发现它其实是个很优雅的设计。说白了,它就是一个状态机,你按规矩喂数据,它给你吐编码后的码流。
4.1 MediaCodec的工作流程:一个循环驱动的状态机
MediaCodec的核心,是一个异步的、循环驱动的模型。你想想看,它就像一条流水线:你从左边放进去原始数据(比如YUV帧或PCM音频),它在中间处理,然后从右边拿出编码后的数据(H.264或AAC)。
我个人习惯把它的生命周期分为三个阶段:停止(Stopped)、执行(Executing)和释放(Released)。其中停止状态又细分为未初始化、已配置和错误状态。执行状态则包含刷新、运行和流结束。
嗯,这里要注意:MediaCodec是状态敏感的。你必须在正确的状态下调用正确的方法。比如,你还没调用start()就去拿输出缓冲区,那肯定报错。我在项目中遇到过好几次这种低级错误,排查了半天才发现是状态没走对。
核心工作流程(伪代码视角):
// 1. 创建编码器
MediaCodec codec = MediaCodec.createEncoderByType(mimeType);
// 2. 配置编码器
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
// 3. 启动
codec.start();
// 4. 循环处理
while (!done) {
// 4a. 获取输入缓冲区
int inputIndex = codec.dequeueInputBuffer(timeoutUs);
if (inputIndex >= 0) {
ByteBuffer inputBuffer = codec.getInputBuffer(inputIndex);
// 填充数据...
codec.queueInputBuffer(inputIndex, offset, size, pts, flags);
}
// 4b. 获取输出缓冲区
BufferInfo info = new BufferInfo();
int outputIndex = codec.dequeueOutputBuffer(info, timeoutUs);
if (outputIndex >= 0) {
ByteBuffer outputBuffer = codec.getOutputBuffer(outputIndex);
// 处理编码后的数据...
codec.releaseOutputBuffer(outputIndex, false);
}
}
// 5. 停止并释放
codec.stop();
codec.release();
这个循环,说白了就是两个动作:喂数据和取数据。你不断往输入队列里塞原始帧,同时不断从输出队列里取编码后的包。这两个操作是异步的,但通常我们在同一个线程里用超时机制来轮询。
我的经验:超时时间不要设成0,也不要设太大。0会导致忙等,CPU飙升;太大又会导致延迟。我一般设成10000微秒(10毫秒),在实时性要求高的场景下会降到5000微秒。
4.2 配置编码器:H.264视频和AAC音频的参数详解
配置编码器,是决定编码质量和效率的关键一步。你通过MediaFormat对象来告诉编码器你想要什么。我刚开始做的时候,总觉得参数越多越好,结果反而出了问题。后来才明白,少即是多。
4.2.1 H.264视频编码器配置
对于H.264,有几个参数是必须设的,其他的可以交给编码器自己去优化。
| 参数 | Key | 说明 | 我的建议值 |
|---|---|---|---|
| MIME类型 | MediaFormat.KEY_MIME |
指定编码器类型 | "video/avc" |
| 宽度 | MediaFormat.KEY_WIDTH |
视频宽度(像素) | 640或1280 |
| 高度 | MediaFormat.KEY_HEIGHT |
视频高度(像素) | 480或720 |
| 比特率 | MediaFormat.KEY_BIT_RATE |
目标码率(bps) | 宽x高x3x帧率/8 |
| 帧率 | MediaFormat.KEY_FRAME_RATE |
目标帧率 | 15或30 |
| I帧间隔 | MediaFormat.KEY_I_FRAME_INTERVAL |
关键帧间隔(秒) | 1(直播场景) |
| 颜色格式 | MediaFormat.KEY_COLOR_FORMAT |
输入数据的颜色空间 | MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar |
这里有个坑,我必须要说。颜色格式这个参数,不同厂商的芯片支持的不一样。高通的一般支持COLOR_FormatYUV420SemiPlanar(也就是NV12),但有些老设备只支持COLOR_FormatYUV420Planar(I420)。我曾经在一款展讯芯片的手机上栽过跟头,画面全是绿的,就是因为颜色格式没匹配上。
避坑指南:配置前一定要先查询编码器支持的颜色格式列表。用MediaCodecList遍历CodecCapabilities.colorFormats,选一个你也能生成的格式。不要想当然地写死。
配置代码示例:
MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate);
format.setInteger(MediaFormat.KEY_FRAME_RATE, frameRate);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
format.setInteger(MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar);
// 可选:设置编码档次和级别,提高兼容性
format.setInteger("profile", 0x01); // Baseline Profile
format.setInteger("level", 0x200); // Level 3.1
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
4.2.2 AAC音频编码器配置
音频编码相对简单,但参数选不对,声音会出问题。AAC编码器常用的参数如下:
| 参数 | Key | 说明 | 我的建议值 |
|---|---|---|---|
| MIME类型 | MediaFormat.KEY_MIME |
指定编码器类型 | "audio/mp4a-latm" |
| 采样率 | MediaFormat.KEY_SAMPLE_RATE |
音频采样率(Hz) | 44100或48000 |
| 声道数 | MediaFormat.KEY_CHANNEL_COUNT |
音频声道数 | 1(直播)或2 |
| 比特率 | MediaFormat.KEY_BIT_RATE |
音频码率(bps) | 64000或128000 |
| AAC档次 | MediaFormat.KEY_AAC_PROFILE |
编码档次 | MediaCodecInfo.CodecProfileLevel.AACObjectLC |
配置代码示例:
MediaFormat audioFormat = MediaFormat.createAudioFormat("audio/mp4a-latm", sampleRate, channelCount);
audioFormat.setInteger(MediaFormat.KEY_BIT_RATE, 64000);
audioFormat.setInteger(MediaFormat.KEY_AAC_PROFILE,
MediaCodecInfo.CodecProfileLevel.AACObjectLC);
// 注意:音频编码器不需要设置颜色格式
codec.configure(audioFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
我的经验:直播场景下,音频采样率用44100Hz就够了,没必要上48000。码率64kbps对于单声道AAC-LC来说,音质已经很好了。码率再高,在弱网环境下反而容易卡顿。
4.3 输入输出缓冲区处理:数据流动的核心
缓冲区处理,是MediaCodec最核心也最容易出错的地方。你想想看,数据从你手里到编码器,再从编码器回到你手里,中间经过了好几个缓冲区队列。搞不清楚这些队列的状态,代码就会乱成一锅粥。
4.3.1 输入缓冲区:喂数据
输入缓冲区,是你把原始数据交给编码器的通道。流程是这样的:
- 申请缓冲区:调用
dequeueInputBuffer(timeout)。如果返回值大于等于0,说明你拿到了一个空闲的输入缓冲区索引。 - 填充数据:通过
getInputBuffer(index)拿到ByteBuffer,然后把你的YUV或PCM数据写进去。 - 归还缓冲区:调用
queueInputBuffer(index, offset, size, presentationTimeUs, flags),告诉编码器:数据准备好了,你拿去处理吧。
这里有个细节:presentationTimeUs是时间戳,单位微秒。对于视频,它应该是这一帧的显示时间;对于音频,它是这一帧的播放时间。时间戳必须单调递增,否则编码器会出问题。我见过有人忘了设时间戳,结果编码出来的视频花屏。
关键标志位:
MediaCodec.BUFFER_FLAG_KEY_FRAME:标记这一帧是关键帧(I帧)。你可以在需要时手动设置,比如场景切换时。MediaCodec.BUFFER_FLAG_END_OF_STREAM:标记输入结束。设了这个标志后,编码器会输出剩余数据并停止。
4.3.2 输出缓冲区:取数据
输出缓冲区的处理,是编码后的数据回到你手里的通道。流程类似:
- 获取输出缓冲区:调用
dequeueOutputBuffer(info, timeout)。返回值大于等于0,说明有编码后的数据准备好了。 - 读取数据:通过
getOutputBuffer(index)拿到ByteBuffer,读取里面的H.264或AAC数据。 - 释放缓冲区:调用
releaseOutputBuffer(index, render)。对于编码器,第二个参数传false,因为我们不需要渲染。
BufferInfo对象里包含了几个重要信息:
info.offset:有效数据的起始偏移量。info.size:有效数据的大小。info.presentationTimeUs:这一帧的时间戳。info.flags:标志位,比如BUFFER_FLAG_KEY_FRAME或BUFFER_FLAG_END_OF_STREAM。
避坑指南:千万不要直接操作getOutputBuffer返回的ByteBuffer的position和limit。编码器内部会管理这些。你只需要从info.offset开始读info.size个字节就行了。我曾经犯过这个错,结果读出来的数据全是乱的。
4.3.3 处理H.264的SPS/PPS
对于H.264编码器,输出的第一个数据包通常不是视频帧,而是SPS(序列参数集)和PPS(图像参数集)。这两个是解码器必须的配置信息,没有它们,解码器无法工作。
怎么判断?看info.flags是否包含MediaCodec.BUFFER_FLAG_CODEC_CONFIG。如果是,那这个输出缓冲区里就是SPS/PPS数据。你需要把它保存下来,在封装成RTMP或MP4时,放在视频数据前面。
代码示例:
if ((info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
// 这是SPS/PPS,保存起来
ByteBuffer configData = codec.getOutputBuffer(outputIndex);
byte[] configBytes = new byte[info.size];
configData.position(info.offset);
configData.get(configBytes, 0, info.size);
// 保存configBytes,后续封装时使用
}
4.4 完整的工作循环示例
把上面所有的知识点串起来,一个完整的编码循环大概长这样:
// 假设codec已经configure并start
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean inputDone = false;
boolean outputDone = false;
while (!outputDone) {
// 处理输入
if (!inputDone) {
int inputIndex = codec.dequeueInputBuffer(10000);
if (inputIndex >= 0) {
ByteBuffer inputBuffer = codec.getInputBuffer(inputIndex);
// 从你的数据源(比如Camera)读取一帧数据到inputBuffer
// ...
long pts = computePresentationTime(frameCount);
codec.queueInputBuffer(inputIndex, 0, dataSize, pts, 0);
frameCount++;
// 如果所有帧都送完了,发送结束标志
if (frameCount >= totalFrames) {
codec.queueInputBuffer(inputIndex, 0, 0, pts,
MediaCodec.BUFFER_FLAG_END_OF_STREAM);
inputDone = true;
}
}
}
// 处理输出
int outputIndex = codec.dequeueOutputBuffer(info, 10000);
if (outputIndex >= 0) {
ByteBuffer outputBuffer = codec.getOutputBuffer(outputIndex);
// 处理编码后的数据(保存、发送等)
// ...
codec.releaseOutputBuffer(outputIndex, false);
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
outputDone = true;
}
} else if (outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
// 没有数据可用,稍后再试
} else if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
// 输出格式发生了变化,通常发生在编码器刚启动时
MediaFormat newFormat = codec.getOutputFormat();
}
}
codec.stop();
codec.release();
我的经验:在实际项目中,我通常会把输入和输出处理放在不同的线程里。输入线程负责从Camera或麦克风读取数据并塞给编码器,输出线程负责从编码器取出数据并封装成RTMP包。这样可以充分利用多核CPU,提高吞吐量。
4.5 本章知识体系图
下面这张图,概括了MediaCodec硬编码的完整流程和核心概念。你可以把它当作一个快速参考。
嗯,到这里,MediaCodec硬编码的入门知识就讲完了。说白了,它就是一套标准的数据处理流水线。你只要把配置搞对,把循环写稳,剩下的就交给编码器去处理。下一节我们会深入实战,把这些代码真正跑起来,并封装成可复用的工具类。