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 输入缓冲区:喂数据

输入缓冲区,是你把原始数据交给编码器的通道。流程是这样的:

  1. 申请缓冲区:调用dequeueInputBuffer(timeout)。如果返回值大于等于0,说明你拿到了一个空闲的输入缓冲区索引。
  2. 填充数据:通过getInputBuffer(index)拿到ByteBuffer,然后把你的YUV或PCM数据写进去。
  3. 归还缓冲区:调用queueInputBuffer(index, offset, size, presentationTimeUs, flags),告诉编码器:数据准备好了,你拿去处理吧。

这里有个细节:presentationTimeUs是时间戳,单位微秒。对于视频,它应该是这一帧的显示时间;对于音频,它是这一帧的播放时间。时间戳必须单调递增,否则编码器会出问题。我见过有人忘了设时间戳,结果编码出来的视频花屏。

关键标志位:

  • MediaCodec.BUFFER_FLAG_KEY_FRAME:标记这一帧是关键帧(I帧)。你可以在需要时手动设置,比如场景切换时。
  • MediaCodec.BUFFER_FLAG_END_OF_STREAM:标记输入结束。设了这个标志后,编码器会输出剩余数据并停止。

4.3.2 输出缓冲区:取数据

输出缓冲区的处理,是编码后的数据回到你手里的通道。流程类似:

  1. 获取输出缓冲区:调用dequeueOutputBuffer(info, timeout)。返回值大于等于0,说明有编码后的数据准备好了。
  2. 读取数据:通过getOutputBuffer(index)拿到ByteBuffer,读取里面的H.264或AAC数据。
  3. 释放缓冲区:调用releaseOutputBuffer(index, render)。对于编码器,第二个参数传false,因为我们不需要渲染。

BufferInfo对象里包含了几个重要信息:

  • info.offset:有效数据的起始偏移量。
  • info.size:有效数据的大小。
  • info.presentationTimeUs:这一帧的时间戳。
  • info.flags:标志位,比如BUFFER_FLAG_KEY_FRAMEBUFFER_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硬编码核心流程 状态机 Stopped (Uninitialized → Configured → Error) → Executing (Flushed → Running → End-of-Stream) → Released 关键方法: configure() → start() → stop() → release() 配置编码器 (MediaFormat) H.264: MIME, 宽高, 比特率, 帧率, I帧间隔, 颜色格式 AAC: MIME, 采样率, 声道数, 比特率, AAC档次 注意: 颜色格式需查询编码器支持列表 输入输出缓冲区处理 输入: dequeueInputBuffer → getInputBuffer → queueInputBuffer 输出: dequeueOutputBuffer → getOutputBuffer → releaseOutputBuffer 注意: 时间戳必须单调递增, 处理SPS/PPS 核心要点总结 1. MediaCodec是状态机,必须在正确状态下调用对应方法 2. 配置参数要合理,颜色格式和编码档次需查询兼容性 3. 缓冲区处理是循环驱动的,注意时间戳和标志位的正确使用

嗯,到这里,MediaCodec硬编码的入门知识就讲完了。说白了,它就是一套标准的数据处理流水线。你只要把配置搞对,把循环写稳,剩下的就交给编码器去处理。下一节我们会深入实战,把这些代码真正跑起来,并封装成可复用的工具类。