10、MediaCodec实战:视频硬解码、视频硬编码、音视频数据分离与合成

好,咱们今天聊点硬核的。MediaCodec,这个名字你肯定不陌生。Android上做音视频开发,绕不开它。说白了,它就是系统提供给咱们的一个底层多媒体编解码器接口。你可以用它来硬解码、硬编码,还能做音视频数据的分离与合成。

我个人习惯把MediaCodec比作一个“黑盒子”。你往里扔原始数据,它给你吐出解码后的数据。或者反过来,你扔原始图像,它给你编码成压缩流。嗯,听起来简单,但实际用起来,坑不少。我刚开始接触的时候,也被它的异步回调机制搞得晕头转向。

10.1 视频硬解码:从压缩流到原始帧

视频硬解码,就是把H.264、H.265这些压缩过的视频流,还原成原始的YUV或RGB帧。为什么要用硬解码?省电、效率高。CPU干这事太累了,交给GPU或专门的解码芯片,又快又稳。

核心流程其实就四步:

  1. 创建解码器:根据你的视频格式,比如H.264,创建一个MediaCodec实例。
  2. 配置解码器:设置好输入输出格式,比如分辨率、码率这些。
  3. 启动解码器:调用start()方法,开始干活。
  4. 循环处理数据:从输入缓冲区塞数据,从输出缓冲区取数据。

来看一段核心代码,我习惯这么写:

// 创建解码器,指定MIME类型
MediaCodec decoder = MediaCodec.createDecoderByType("video/avc");

// 配置解码器,MediaFormat是关键
MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);
decoder.configure(format, surface, null, 0);

// 启动
decoder.start();

// 循环处理
while (!isFinished) {
    // 获取输入缓冲区索引
    int inputIndex = decoder.dequeueInputBuffer(10000);
    if (inputIndex >= 0) {
        ByteBuffer inputBuffer = decoder.getInputBuffer(inputIndex);
        // 从文件或网络读取数据,填充到inputBuffer
        int sampleSize = readSampleData(inputBuffer);
        // 塞给解码器
        decoder.queueInputBuffer(inputIndex, 0, sampleSize, presentationTimeUs, 0);
    }

    // 获取输出缓冲区索引
    MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
    int outputIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000);
    if (outputIndex >= 0) {
        // 输出到Surface,或者自己处理数据
        decoder.releaseOutputBuffer(outputIndex, true);
    }
}

关键点:如果你只是想把视频渲染到屏幕上,直接把Surface传给configure()方法就行。解码器会自动把数据渲染上去,省事。但如果你要拿到原始帧做处理,比如滤镜、裁剪,那就得用ImageReader了。

我的经验:dequeueInputBuffer和dequeueOutputBuffer的超时时间,我一般设10毫秒。太短了CPU空转,太长了界面卡顿。这个值你可以根据实际场景微调。

10.2 视频硬编码:从原始帧到压缩流

编码和解码,流程是对称的。只不过数据流向反过来了。你给编码器原始帧,它给你吐出压缩后的H.264或H.265流。

编码的场景很多,比如录屏、摄像头采集、视频编辑。我记得有一次做直播推流,需要把摄像头采集的NV21数据编码成H.264,然后推送到服务器。那时候对MediaCodec的输入格式不熟,踩了不少坑。

编码器的配置稍微复杂一点:

// 创建编码器
MediaCodec encoder = MediaCodec.createEncoderByType("video/avc");

// 配置编码器
MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
format.setInteger(MediaFormat.KEY_BIT_RATE, 2000000); // 2Mbps
format.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); // 关键帧间隔1秒

encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);

// 获取输入Surface
Surface inputSurface = encoder.createInputSurface();
encoder.start();

// 然后你就可以通过这个Surface往编码器送数据了
// 比如用OpenGL ES渲染,或者用MediaCodec的输入缓冲区

注意:编码器的输入格式,我强烈建议用Surface模式。为什么?因为效率高。你通过Surface送数据,编码器内部会做格式转换和内存管理。如果你自己手动填ByteBuffer,很容易遇到颜色格式不匹配的问题。我曾经就被COLOR_FormatYUV420Planar和COLOR_FormatYUV420SemiPlanar搞到头大。

编码后的数据,从输出缓冲区取出来,就是标准的H.264码流。你可以直接存成文件,或者通过网络发送。

10.3 音视频数据分离:解封装的艺术

一个MP4文件里,视频和音频是交织在一起的。要分别处理它们,得先分离。Android上做这事,用MediaExtractor。

MediaExtractor的作用,就是从容器文件中提取出压缩后的音视频数据包。它不负责解码,只负责“拆包”。

流程很简单:

  1. 创建MediaExtractor实例。
  2. 设置数据源,可以是文件路径,也可以是FileDescriptor。
  3. 遍历轨道,找到视频轨和音频轨。
  4. 选中轨道,然后循环读取数据包。

看代码:

MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource(filePath);

int videoTrackIndex = -1;
int audioTrackIndex = -1;

// 遍历轨道
for (int i = 0; i < extractor.getTrackCount(); i++) {
    MediaFormat format = extractor.getTrackFormat(i);
    String mime = format.getString(MediaFormat.KEY_MIME);
    if (mime.startsWith("video/")) {
        videoTrackIndex = i;
    } else if (mime.startsWith("audio/")) {
        audioTrackIndex = i;
    }
}

// 选中视频轨,开始读取
extractor.selectTrack(videoTrackIndex);
while (true) {
    ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
    int sampleSize = extractor.readSampleData(buffer, 0);
    if (sampleSize < 0) break;

    long presentationTimeUs = extractor.getSampleTime();
    int flags = extractor.getSampleFlags();

    // 这里拿到的是压缩后的数据包,可以送给解码器
    // ...

    extractor.advance();
}

核心要点:MediaExtractor读取出来的数据,是压缩后的。也就是说,视频轨读出来的是H.264包,音频轨读出来的是AAC包。这些数据可以直接喂给MediaCodec解码器。

10.4 音视频数据合成:重新封装

合成,就是把处理后的音视频数据,重新打包成一个容器文件,比如MP4。Android上做这事,用MediaMuxer。

MediaMuxer的使用,比MediaExtractor稍微复杂一点。因为它需要你先添加轨道,然后一帧一帧地写入数据。

基本步骤:

  1. 创建MediaMuxer实例,指定输出文件路径和格式。
  2. 添加视频轨和音频轨,得到轨道索引。
  3. 循环写入数据包,注意要按时间戳顺序写入。
  4. 写入完成后,调用stop()和release()。

代码示例:

MediaMuxer muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);

// 假设你已经有了视频和音频的MediaFormat
int videoTrackIndex = muxer.addTrack(videoFormat);
int audioTrackIndex = muxer.addTrack(audioFormat);

muxer.start();

// 写入视频数据
while (hasVideoData) {
    ByteBuffer buffer = getVideoData();
    MediaCodec.BufferInfo info = getVideoBufferInfo();
    muxer.writeSampleData(videoTrackIndex, buffer, info);
}

// 写入音频数据
while (hasAudioData) {
    ByteBuffer buffer = getAudioData();
    MediaCodec.BufferInfo info = getAudioBufferInfo();
    muxer.writeSampleData(audioTrackIndex, buffer, info);
}

muxer.stop();
muxer.release();

重要提醒:MediaMuxer要求写入的数据包,时间戳必须是单调递增的。如果你乱序写入,生成的文件可能会播放异常。我遇到过一个问题,编码器输出的B帧时间戳不是递增的,导致合成后的视频花屏。解决办法是在写入前,先按时间戳排序。

10.5 实战:音视频分离与合成完整流程

把上面这些串起来,就是一个完整的音视频处理流程。我画了一张图,帮你理清思路:

音视频分离与合成完整流程 输入MP4文件 MediaExtractor 分离 视频轨 (H.264/H.265) 音频轨 (AAC/MP3) MediaCodec 解码 MediaCodec 解码 原始视频帧 (YUV/RGB) 原始音频数据 (PCM) 可在此处做滤镜、裁剪、变速等处理

你看,流程很清晰。从输入文件开始,用MediaExtractor分离出视频轨和音频轨。然后分别用MediaCodec解码,得到原始帧和原始音频数据。这时候你可以做各种处理,比如加滤镜、调音量、变速。处理完之后,再用MediaCodec编码回去,最后用MediaMuxer合成新的MP4文件。

我的建议:如果你只是做简单的裁剪或拼接,可以不经过解码编码,直接用MediaExtractor和MediaMuxer操作压缩数据。这样效率更高,而且不会损失画质。但如果你要做滤镜、特效,那就必须解码到原始帧了。

好了,这一章的内容就到这里。MediaCodec的用法,说白了就是“配置-启动-循环处理”这个套路。你多写几遍,自然就熟了。遇到问题别慌,先检查格式配置,再检查时间戳,大部分坑都能填上。


公众号:蓝海资料掘金营,微信deep3321