浅析 MediaCodec 工作原理

字节流动

共 10564字,需浏览 22分钟

 ·

2021-12-12 22:03

原文链接: https://juejin.cn/post/7032224346491846687

1. MediaCodec工作原理

MediaCodec类Android提供的用于访问低层多媒体编/解码器接口,它是Android低层多媒体架构的一部分,通常与MediaExtractor、MediaMuxer、AudioTrack结合使用,能够编解码诸如H.264、H.265、AAC、3gp等常见的音视频格式。

广义而言,MediaCodec的工作原理就是处理输入数据以产生输出数据。具体来说,MediaCodec在编解码的过程中使用了一组输入/输出缓存区来同步或异步处理数据:首先,客户端向获取到的编解码器输入缓存区写入要编解码的数据并将其提交给编解码器,待编解码器处理完毕后将其转存到编码器的输出缓存区,同时收回客户端对输入缓存区的所有权;

然后,客户端从获取到编解码输出缓存区读取编码好的数据进行处理,待处理完毕后编解码器收回客户端对输出缓存区的所有权。不断重复整个过程,直至编码器停止工作或者异常退出。

MediaCodec原理

2. MediaCodec编码过程

在整个编解码过程中,MediaCodec的使用会经历配置、启动、数据处理、停止、释放几个过程,相应的状态可归纳为停止 (Stopped) ,执行 (Executing) 以及释放(Released)三个状态,而Stopped状态又可细分为未初始化(Uninitialized)、配置(Configured)、异常( Error),Executing状态也可细分为读写数据(Flushed)、运行(Running)和流结束(End-of-Stream)。MediaCodec整个状态结构图如下:


从上图可知,当MediaCodec被创建后会进入未初始化状态,待设置好配置信息并调用start()启动后,MediaCodec会进入运行状态,并且可进行数据读写操作。

如果在这个过程中出现了错误,MediaCodec会进入Stopped状态,我们就是要使用reset方法来重置编解码器,否则MediaCodec所持有的资源最终会被释放。

当然,如果MediaCodec正常使用完毕,我们也可以向编解码器发送EOS指令,同时调用stop和release方法终止编解码器的使用。

2.1 创建编/解码器

MediaCodec主要提供了createEncoderByType(String type)、createDecoderByType(String type)两个方法来创建编解码器,它们均需要传入一个MIME类型多媒体格式。常见的MIME类型多媒体格式如下:

● "video/x-vnd.on2.vp8" - VP8 video (i.e. video in .webm)
● "video/x-vnd.on2.vp9" - VP9 video (i.e. video in .webm)
● "video/avc" - H.264/AVC video
● "video/mp4v-es" - MPEG4 video
● "video/3gpp" - H.263 video
● "audio/3gpp" - AMR narrowband audio
● "audio/amr-wb" - AMR wideband audio
● "audio/mpeg" - MPEG1/2 audio layer III
● "audio/mp4a-latm" - AAC audio (note, this is raw AAC packets, not packaged in LATM!)
● "audio/vorbis" - vorbis audio
● "audio/g711-alaw" - G.711 alaw audio
● "audio/g711-mlaw" - G.711 ulaw audio

当然,MediaCodec还提供了一个createByCodecName (String name)方法,支持使用组件的具体名称来创建编解码器。但是该方法使用起来有些麻烦,且官方是建议最好是配合MediaCodecList使用,因为MediaCodecList记录了所有可用的编解码器。

当然,我们也可以使用该类对传入的 minmeType 参数进行判断,以匹配出MediaCodec对该mineType类型的编解码器是否支持。以指定MIME类型为“video/avc”为例,代码如下:

 private static MediaCodecInfo selectCodec(String mimeType) {
// 获取所有支持编解码器数量
int numCodecs = MediaCodecList.getCodecCount();
for (int i = 0; i < numCodecs; i++) {
// 编解码器相关性信息存储在MediaCodecInfo中
MediaCodecInfo codecInfo = MediaCodecList.getCodecInfoAt(i);
// 判断是否为编码器
if (!codecInfo.isEncoder()) {
continue;
}
// 获取编码器支持的MIME类型,并进行匹配
String[] types = codecInfo.getSupportedTypes();
for (int j = 0; j < types.length; j++) {
if (types[j].equalsIgnoreCase(mimeType)) {
return codecInfo;
}
}
}
return null;
}

2.2 配置、启动编/解码器

编解码器配置使用的是MediaCodec的configure方法,该方法首先对MediaFormat存储的数据map进行提取,然后调用本地方法native_configure实现对编解码器的配置工作。

在配置时,configure方法需要传入format、surface、crypto、flags参数,其中format为MediaFormat的实例,它使用"key-value"键值对的形式存储多媒体数据格式信息;

surface用于指明解码器的数据源来自于该surface;crypto用于指定一个MediaCrypto对象,以便对媒体数据进行安全解密;flags指明配置的是编码器(CONFIGURE_FLAG_ENCODE)。

MediaFormat mFormat = MediaFormat.createVideoFormat("video/avc", 640 ,480);     // 创建MediaFormat
mFormat.setInteger(MediaFormat.KEY_BIT_RATE,600); // 指定比特率
mFormat.setInteger(MediaFormat.KEY_FRAME_RATE,30); // 指定帧率
mFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT,mColorFormat); // 指定编码器颜色格式
mFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL,10); // 指定关键帧时间间隔
mVideoEncodec.configure(mFormat,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE);

以上代码是在编码 H.264 时的配置方法,createVideoFormat("video/avc", 640 ,480)为"video/avc"类型(即H.264)编码器的MediaFormat对象,需要指定视频数据的宽高,如果编解码音频数据,则调用MediaFormat的createAudioFormat(String mime, int sampleRate,int channelCount)的方法。

除了一些诸如视频帧率、音频采样率等配置参数,这里需要着重讲解一下MediaFormat.KEY_COLOR_FORMAT配置属性,该属性用于指明video编码器的颜色格式,具体选择哪种颜色格式与输入的视频数据源颜色格式有关。

比如,我们都知道Camera预览采集的图像流通常为NV21或YV12,那么编码器需要指定相应的颜色格式,否则编码得到的数据可能会出现花屏、叠影、颜色失真等现象。

MediaCodecInfo.CodecCapabilities.存储了编码器所有支持的颜色格式,常见颜色格式映射如下:

原始数据                            编码器
NV12(YUV420sp) ---------> COLOR_FormatYUV420PackedSemiPlanar
NV21 ----------> COLOR_FormatYUV420SemiPlanar
YV12(I420) ----------> COLOR_FormatYUV420Planar

当编解码器配置完毕后,就可以调用 MediaCodec 的start()方法,该方法会调用低层native_start()方法来启动编码器,并调用低层方法ByteBuffer[] getBuffers(input)来开辟一系列输入、输出缓存区。start()方法源码如下:

public final void start() {
native_start();
synchronized(mBufferLock) {
cacheBuffers(true /* input */);
cacheBuffers(false /* input */);
}
}

2.3 数据处理

MediaCodec支持两种模式编解码器,即同步synchronous、异步asynchronous,所谓同步模式是指编解码器数据的输入和输出是同步的,编解码器只有处理输出完毕才会再次接收输入数据;

异步编解码器数据的输入和输出是异步的,编解码器不会等待输出数据处理完毕才再次接收输入数据。

这里,我们主要介绍下同步编解码,因为这种方式我们用得比较多。我们知道当编解码器被启动后,每个编解码器都会拥有一组输入和输出缓存区,但是这些缓存区暂时无法被使用,只有通过MediaCodec的dequeueInputBuffer/dequeueOutputBuffer方法获取输入输出缓存区授权,通过返回的ID来操作这些缓存区。

下面我们通过一段官方提供的代码,进行扩展分析:

 MediaCodec codec = MediaCodec.createByCodecName(name);
codec.configure(format, …);
MediaFormat outputFormat = codec.getOutputFormat(); // option B
codec.start();
for (;;) {
int inputBufferId = codec.dequeueInputBuffer(timeoutUs);
if (inputBufferId >= 0) {
ByteBuffer inputBuffer = codec.getInputBuffer(…);
// fill inputBuffer with valid data

codec.queueInputBuffer(inputBufferId, …);
}
int outputBufferId = codec.dequeueOutputBuffer(…);
if (outputBufferId >= 0) {
ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId); // option A
// bufferFormat is identical to outputFormat
// outputBuffer is ready to be processed or rendered.

codec.releaseOutputBuffer(outputBufferId, …);
} else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
// Subsequent data will conform to new format.
// Can ignore if using getOutputFormat(outputBufferId)
outputFormat = codec.getOutputFormat(); // option B
}
}
codec.stop();
codec.release();

从上面代码可知,当编解码器start后,会进入一个for(;;)循环,该循环是一个死循环,以实现不断地去从编解码器的输入缓存池中获取包含数据的一个缓存区,然后再从输出缓存池中获取编解码好的输出数据。

  • 获取编解码器的输入缓存区,写入数据

首先,调用MediaCodec的dequeueInputBuffer(long timeoutUs)方法从编码器的输入缓存区集合中获取一个输入缓存区,并返回该缓存区的下标index,如果index=-1说明暂时可用缓存区,当timeoutUs=0时dequeueInputBuffer会立马返回。

接着调用MediaCodec的getInputBuffer(int index),该方法会将index传入给本地方法getBuffer(true /* input */, index)返回该缓存区的ByteBuffer,并且将获得的ByteBuffer对象及其index存储到BufferMap对象中,以便输入结束后对该缓存区作释放处理,交还给编解码器。getInputBuffer(int index)源码如下:

    @Nullable
public ByteBuffer getInputBuffer(int index) {
ByteBuffer newBuffer = getBuffer(true /* input */, index);
synchronized(mBufferLock) {
invalidateByteBuffer(mCachedInputBuffers, index);
// mDequeuedInputBuffers是BufferMap的实例
mDequeuedInputBuffers.put(index, newBuffer);
}
return newBuffer;
}

然后,在获得输入缓冲区后,将数据填入数据并使用queueInputBuffer将其提交到编解码器中处理,同时将输入缓存区释放交还给编解码器。queueInputBuffer源码如下:

    public final void queueInputBuffer(
int index,
int offset, int size, long presentationTimeUs, int flags)

throws CryptoException
{
synchronized(mBufferLock) {
invalidateByteBuffer(mCachedInputBuffers, index);
// 移除输入缓存区
mDequeuedInputBuffers.remove(index);
}
try {
native_queueInputBuffer(
index, offset, size, presentationTimeUs, flags);
} catch (CryptoException | IllegalStateException e) {
revalidateByteBuffer(mCachedInputBuffers, index);
throw e;
}
}

由上述代码可知,queueInputBuffer主要通过调用低层方法native_queueInputBuffer实现,该方法需要传入5个参数,其中index是输入缓存区的下标,编解码器就是通过index找到缓存区的位置;

offset为有效数据存储在buffer中的偏移量;size为有效输入原始数据的大小;presentationTimeUs为缓冲区显示时间戳,通常为0;flags为输入缓存区标志,通常设置为 BUFFER_FLAG_END_OF_STREAM。

  • 获取编解码器的输出缓存区,读出数据

 首先,与上述通过dequeueInputBuffer和getInputBuffer获取输入缓存区类似,MediaCodec也提供了dequeueOutputBuffer和getOutputBuffer方法用来帮助我们获取编解码器的输出缓存区。

但是与dequeueInputBuffer不同的是,dequeueOutputBuffer还需要传入一个MediaCodec.BufferInfo对象。MediaCodec.BufferInfo是MediaCodec的一个内部类,它记录了编解码好的数据在输出缓存区中的偏移量和大小

  public final static class BufferInfo {
public void set(
int newOffset, int newSize, long newTimeUs, @BufferFlag int newFlags)
{
offset = newOffset;
size = newSize;
presentationTimeUs = newTimeUs;
flags = newFlags;
}
public int offset // 偏移量
public int size; // 缓存区有效数据大小
public long presentationTimeUs; // 显示时间戳
public int flags; // 缓存区标志

@NonNull
public BufferInfo dup() {
BufferInfo copy = new BufferInfo();
copy.set(offset, size, presentationTimeUs, flags);
return copy;
}
};

然后,通过dequeueOutputBuffer的源码可知,当dequeueOutputBuffer返回值>=0时,输出缓存区的数据才是有效的。

当调用本地方法native_dequeueOutputBuffer返回INFO_OUTPUT_BUFFERS_CHANGED时,会调用cacheBuffers方法重新获取一组输出缓存区mCachedOutputBuffers(ByteBuffer[])。

这就解释了如果我们使用getOutputBuffers方法(API21后被弃用,使用getOutputBuffer(index)代替)来获取编解码器的输出缓存区,那么就需要在调用dequeueOutputBuffer判断其返回值,如果返回值为MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED,则需要重新获取输出缓存区集合。

此外,这里还要dequeueOutputBuffer的另外两个返回值:MediaCodec.INFO_TRY_AGAIN_LATER、MediaCodec.INFO_OUTPUT_FORMAT_CHANGED,前者表示获取编解码器输出缓存区超时,后者表示编解码器数据输出格式改变,随后输出的数据将使用新的格式。

因此,我们需要在调用dequeueOutputBuffer判断返回值是否为INFO_OUTPUT_FORMAT_CHANGED,需要通过MediaCodec的getOutputFormat重新设置MediaFormt对象。

  public final int dequeueOutputBuffer(
@NonNull BufferInfo info, long timeoutUs)
{
int res = native_dequeueOutputBuffer(info, timeoutUs);
synchronized(mBufferLock) {
if (res == INFO_OUTPUT_BUFFERS_CHANGED) {
// 将会调用getBuffers()底层方法
cacheBuffers(false /* input */);
} else if (res >= 0) {
validateOutputByteBuffer(mCachedOutputBuffers, res, info);
if (mHasSurface) {
mDequeuedOutputInfos.put(res, info.dup());
}
}
}
return res;
}

最后,当输出缓存区的数据被处理完毕后,通过调用MediaCodec的releaseOutputBuffer释放输出缓存区,并交还给编解码器,该输出缓存区将不能被使用,直到下一次通过dequeueOutputBuffer获取。

releaseOutputBuffer方法接收两个参数:Index、render,其中,Index为输出缓存区索引;render表示当配置编码器时指定了surface,那么应该置为true,输出缓存区的数据将被传递到surface中。源码如下:

   public final void releaseOutputBuffer(int index, boolean render) {
BufferInfo info = null;
synchronized(mBufferLock) {
invalidateByteBuffer(mCachedOutputBuffers, index);
mDequeuedOutputBuffers.remove(index);
if (mHasSurface) {
info = mDequeuedOutputInfos.remove(index);
}
}
releaseOutputBuffer(index, render, false /* updatePTS */, 0 /* dummy */);
}



推荐阅读:

Android FFmpeg 实现带滤镜的微信小视频录制功能

全网最全的 Android 音视频和 OpenGL ES 干货,都在这了

一文掌握 YUV 图像的基本处理

抖音传送带特效是怎么实现的?

所有你想要的图片转场效果,都在这了

面试官:如何利用 Shader 实现 RGBA 到 NV21 图像格式转换?

我用 OpenGL ES 给小姐姐做了几个抖音滤镜

浏览 49
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报