SAKA'S BLOG

安卓解码器MediaCodec解析

首先要推荐一篇关于mediacodec的国外博客bigflake,这篇博客对mediacodec的官方文档一些坑做了较好的说明,配合起来看更有效率。

mediacodec可以用来获得安卓底层的多媒体编码,可以用来编码和解码,它是安卓low-level多媒体基础框架的重要组成部分。它经常和 MediaExtractor, MediaSync, MediaMuxer, MediaCrypto, MediaDrm, Image, Surface, AudioTrack一起使用。

mediacodec

通过上图可以看出,mediacodec的作用是处理输入的数据生成输出数据。首先生成一个输入数据缓冲区,将数据填入缓冲区提供给codec,codec会采用异步的方式处理这些输入的数据,然后将填满输出缓冲区提供给消费者,消费者消费完后将缓冲区返还给codec。

首先明确一下下边将会提到的两种输入输出模式:
|模式名称|含义
|—|—
|surface模式|输入/输出以surface作为源
|ByteBuffer模式|输入/输出时以ByteBuffer作为源

数据格式

mediacodec接受三种数据格式:压缩数据,原始音频数据和原始视频数据。

这三种数据都可以使用ByteBuffer作为载体传输给mediacodec来处理。但是当使用原视频数据时,最好采用Surface作为输入源来替代ByteBuffer,这样效率更高,效果更好,因为surface使用的更底层的视频数据,不会映射或者复制到ByteBuffer缓冲区。在使用surface作为输入源时,开发者不能访问到到原始视频数据,但是可以使用ImageReader来获取到原始未加密的视频数据,这个地方我理解的是imagereader的工作流程是接受自己的surface数据来生成image,将imagereader的surface传给mediacodec作为解码器的输出surface,就可以访问解码的数据,但是必须是未加密的,这种方式同样比使用ByteBuffer更快,因为native缓冲区会直接映射到directbytebuffer区域,这是一块native和java共享的缓冲区。当使用ByteBuffer模式时可以使用Image来获取原始视频数据,mediacodec提供了两个方法,getInput/OutputImage(int)

压缩数据格式

压缩数据可以作为解码器的输入数据或者编码器的输出数据,需要指定数据格式,这样编码/解码器才能知道如何处理这些压缩数据。当使用视频时,一般是包含完整的一帧数据,也就是我们要输入给解码器一帧完整的数据或者从编码器得到一帧完整的数据。

常用的就是让mediacodec解码H264数据,我们必须将分割符和NALU单元作为一个完整的数据帧传给解码器才能正确解码。

对于音频数据,这通常是单个访问单元(编码音频片段通常包含由格式类型指示的几毫秒的音频),但是这个要求稍微宽松,因为缓冲器可能包含多个编码的音频存取单元。

切记,一般都不要传递给mediacodec不是完整帧的数据,除非是标记了BUFFER_FLAG_PARTIAL_FRAME的数据。`BUFFER_FLAG_PARTIAL_FRAME指示了缓冲区只包含帧的一部分,并且解码器应该对数据进行批处理,直到没有该标志的缓冲区在解码帧之前出现。但是这个标记是API26之后引入的,一般用不到。

原始音频数据

原始音频缓冲区包含整个PCM音频数据帧,这是通道顺序中每个通道的一个样本。 每个采样都是以本地字节顺序的16位有符号整数(ENCODING_PCM_16BIT)。
一段示例代码来获取采样数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
short[] getSamplesForChannel(MediaCodec codec, int bufferId, int channelIx) {
ByteBuffer outputBuffer = codec.getOutputBuffer(bufferId);
MediaFormat format = codec.getOutputFormat(bufferId);
ShortBuffer samples = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer();
int numChannels = formet.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
if (channelIx < 0 || channelIx >= numChannels) {
return null;
}
short[] res = new short[samples.remaining() / numChannels];
for (int i = 0; i < res.length; ++i) {
res[i] = samples.get(i * numChannels + channelIx);
}
return res;
}

原始视频数据

关于视频色彩格式的内容我也不是很了解,只是用h264和yuv数据较多。

视频编解码支持三种色彩格式:

  • native raw video format : COLOR_FormatSurface,可以用来处理surface模式的数据输入输出。
  • flexible YUV buffers : 例如COLOR_FormatYUV420Flexible,可以用来处理surface模式的输出输出,在使用ByteBuffer模式的时候可以用getInput/OutputImage(int)方法来获取image数据。
  • specific formats: 支持ByteBuffer模式,有一些厂家会定制, 其他的在MediaCodecInfo.CodecCapabilities中可以看到,格式较多,不列举. 假如是flexible format, 同样可以使用Image来处理数据,getInput/OutputImage(int)。

LOLLIPOP_MR1(api22)以后,mediacodec支持所有的flexible YUV 4:2:0格式。

生命周期中的状态

mediacodec分为三种状态,Stopped, Executing和Released。一张图表示:

mediacodec state

Stopped状态包含三个子状态:Uninitialized, Configured和Error,Executing同样包含三个状态:Flushed, Running 和End-of-Stream。

在mediacodec的使用过程中必须遵守图里标出的流程,否则会发生错误。

以解码器为例,讲解一下使用流程。当使用工厂方法创建mediacodec并且指定为解码后,进入Uninitialized状态,调用configure方法后,进入Configured状态,然后调用start方法进入Executing状态。

进入Executing状态后,首先到达Flush状态,此时mediacodec会持有所有的数据,当第一个inputbufffer从队列中取出时,立即进入Running状态,这个时间很短。然后就可以调用dequeueInputBuffer和getInputBuffer来获取用户可用的缓冲区,用户填满数据后调用queueinputbuffer方法返回给解码器,解码器大部分时间都会工作在Running状态。当想inputbufferqueue中输入一帧标记EndOfStream的时候,进入End-of-Stream状态,在这种状态下,解码器不再接受任何新的数据输入,缓冲区中的数据和标记EndOfStream最终会执行完毕。在任何时候都可以调用flush方法回到Flush状态。

调用stop方法会使mediacode进入Uninitialized状态,这时候可以执行configure方法来进入下一循环。当mediacodec使用完毕后必须调用release方法来释放所有的资源。

在某些情况下,例如取出缓冲区索引时,mediacodec会发生错误进入Error状态,此时调用reset方法来是mediacodec重新处于Uninitialized状态,或者调用release来结束解码。

下面介绍一下mediacodec工作的详细过程。

创建

meidacodec提供了单个工厂方法来创建实例。

1
2
3
MediaCodec createDecoderByType (String type)
MediaCodec createEncoderByType (String type)
MediaCodec createByCodecName (String name)

前两个方法相似,传入字符串类型的创建种类,即可创建编码/解码器。参数一定不能为空,否则抛出NullPointerException。假如传入的参数不是系统指定的某中类型,同样会抛出IllegalArgumentException。看一下支持的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
"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/hevc" - H.265/HEVC 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。
但是官方推荐的是使用第三个方法,避免用户传入不正确的媒体类型。重点介绍一下。
首先是创建MediaFormat。这个玩意在初始化的时候需要指定一系列的键值对,这些设置的值都是mediacodec在编码/解码过程中用到的值。
上一张恶心的图来说明一下:

mediacodec

假如是本地文件或者网络流,可以用MediaExtractor.getTrackFormat这个方法来提取MediaFormat信息。写一个简单的方法来测试这个玩意:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void getMediaFormatFrom() {
MediaExtractor extractor = new MediaExtractor();
try {
File file=new File(Environment.getExternalStorageDirectory(),"ddmsrec.mp4");
Log.d(TAG,file.getAbsolutePath());
extractor.setDataSource(file.getAbsolutePath());
} catch (IOException e) {
e.printStackTrace();
}
int numTracks = extractor.getTrackCount();
for (int i = 0; i < numTracks; ++i) {
MediaFormat format = extractor.getTrackFormat(i);
String mime = format.getString(MediaFormat.KEY_MIME);
Log.d(TAG, "mime="+mime);
}
extractor.release();
}

这段代码的作用提取该文件的MediaFormat的信息。输出结果如下

1
mime=video/avc

这个方法同样适用于获取url地址的流。
然后可以调用MediaFormat.setFeatureEnabled方法来激活一些特性(使用MediaCodec createDecoderByType (String type)MediaCodec createEncoderByType (String type)时不支持这个功能)。

假如你知道确切的名称,可以直接传给createByCodecName函数,否则的话需要从MediaCodecList类中找出合适的类型来作为参数传入。那么这个名字如何获取呢,官方推荐的方式是采用String findDecoderForFormat (MediaFormat format)/String findEncoderForFormat (MediaFormat format)来获取这个名称,看一下函数原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private String findCodecForFormat(boolean encoder, MediaFormat format) {
String mime = format.getString(MediaFormat.KEY_MIME);
for (MediaCodecInfo info: mCodecInfos) {
if (info.isEncoder() != encoder) {
continue;
}
try {
MediaCodecInfo.CodecCapabilities caps = info.getCapabilitiesForType(mime);
if (caps != null && caps.isFormatSupported(format)) {
return info.getName();
}
} catch (IllegalArgumentException e) {
// type is not supported
}
}
return null;
}

在api21(LOLLIPOP)时,必须清楚mediaformat的frame_rate参数。format.setString(MediaFormat.KEY_FRAME_RATE, null)

这个函数实际上需要的只有MediaFormat这个类的KEY_MIME对应的值。这样我们就可以搞点事情,在未创建MediaFormat的时候获取支持的类型。修改代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static MediaCodecInfo selectCodec(boolean encoder,String mimeType) {
MediaCodecList list = new MediaCodecList(REGULAR_CODECS);
MediaCodecInfo[] infos = list.getCodecInfos();
for (MediaCodecInfo m : infos) {
if (m.isEncoder()!=encoder) {
continue;
}
for (String type : m.getSupportedTypes()) {
if (type.equalsIgnoreCase(mimeType)) {
Log.d(TAG, "the selected encoder is :" + m.getName());
return m;
}
}
}
return null;
}

使用的时候只需要判断String结果就可以了。

创建一个加密的解码器

KITKAT_WATCH及之前的版本,MediaCodecList没有列出这些可用的组件,但是这些在系统中是可以使用的,只需要在名字后边添加.secure即可。假如系统不支持该名字的加密版则会抛出一个IO异常。

从LOLLIPOP开始,使用FEATURE_SecurePlayback来创建一个加密解码器。

初始化

创建解码器之后,我们需要为这个祖宗来设置一系列的参数来初始化它。后边将会讲到处理数据的两种方式:同步和异步。假如是异步的方式的话,必须要在configure之前来为它设置回调函数(回调函数在处理数据的时候会讲到)。设置好回调函数后,就可以调用configure函数了。
函数有两个方法:

1
2
void configure (MediaFormat format, Surface surface, MediaCrypto crypto, int flags)
void configure (MediaFormat format, Surface surface, int flags, MediaDescrambler descrambler)

假如不涉及加密和解密(这里不展开加密方法和解密方法了),这两个方法没什么区别,mediaformat传入刚才获取的值,surface可以为空。在不解码生成outputbuffers的时候或者不想将生成的outputbuffers渲染到surface的时候,可以设置为null。flag有两种模式,CONFIGURE_FLAG_ENCODE(值等于1)为编码模式,其他的值为解码模式。

假如想处理原始视频帧,需要将原始视频帧编码为类似于h264或者其他格式,需要调用createInputSurface()方法产生一个surface,并且必须在configure之后,这个surface上目前是空数据,然后调用start方法,当有数据注入到surface时,mediacodec就能立即获取到并解码。假如是api23之后,也可以使用Surface createPersistentInputSurface ()来创建一个surface,其他的编码器可以调用setInputSurface(Surface)方法来继续使用这个surface。

指定的特殊数据

AAC audio, MPEG4, H.264, H.265 video格式的数据作为输入源解码的时候,需要指定一个特殊的前缀设置信息,这个信息通常包含在数据中,但是需要自己提取出来,在mediacodec执行start之后提交这些数据,比如h264的sps和pps,在queueinputbuffer的时候flag设置为BUFFER_FLAG_CODEC_CONFIG提交给解码器。同样这些数据可以在configure的时候提交给mediacodec,效果和前边的一样:

1
2
mediaFormat.setByteBuffer("csd-0", ByteBuffer.wrap(sps));//sps是一个包含sps信息的byte数组
mediaFormat.setByteBuffer("csd-1", ByteBuffer.wrap(pps));//pps是一个包含pps信息的byte数组

当调用start方法启动时这些信息同样会传给mediacodec。你绝不能直接提交这些数据。 如果格式不包含编解码器特定数据,则可以根据格式要求,选择使用指定数量的缓冲区以正确的顺序提交它。 在H.264 AVC的情况下,还可以连接所有编解码器专用数据并将其作为单个编解码器配置缓冲区提交。

再来一张恶心的图:(所有带*的值必须加上前缀”\x00\x00\x00\x01”)
codec

注意当提交配置信息后可能flush的时候丢失信息,必须重新提交这个信息。提交信息是时间戳将被忽略。

编码器在收到这些信息后将会同样输出带有BUFFER_FLAG_CODEC_CONFIG标记的outputbuffer。

数据处理

进入正题了。每个codec都有一片属于自己的输入/输出缓冲区,每个缓冲区都有bufferID来指向。在调用start方法后,用户不能访问任何的inputbuffer和outbuffer,需要通过以下两种方式
|模式|输入方法|输出方法
|—|—|—
|同步模式|dequeueInputBuffer()|dequeueOutputBuffer()
|异步模式|MediaCodec.Callback.OnInputBufferAvailabe()|MediaCodec.Callback.OnInputBufferAvailabe()

当获得inputbuffer(输入方法执行后)后,所有权交给了用户,这些缓冲区由用户填满数据后需要使用queueInputBuffer(加密数据的话请使用queueSecureInputBuffer)提交缓冲区,提交后缓冲区后所有权交给了codec。注意不要为多个帧提供相同的时间戳,除非是配置信息,也就是标记为BUFFER_FLAG_CODEC_CONFIG的帧可以随意使用时间戳。

当获得outputbuffer(输出方法执行)后,用户可访问一个只读的缓冲区,当使用完毕后,请调用releaseOutputBuffer方法来将缓冲区返回给codec。

我们可以不立即queueinputbuffer/releaseOutputBuffer到编解码器,但用户持有input/outputbuffer可能会使编解码器停止工作,并且此行为取决于设备。 编解码器有可能在产生输出缓冲区之前暂停,直到所有未完成的缓冲区queueinputbuffer/releaseOutputBuffer。 因此,用户最好每次获得缓冲区后执行释放操作。

异步模式

从LOLLIPOP开始,首选方法是在调用configure方法之前通过设置回调来异步处理数据。 异步模式会稍微改变状态转换步骤,在running状态时必须在调用flush()之后调用start()方法,将编解码器转换为Running子状态并开始接收输入缓冲区。 同样,在初始调用开始时,codec将直接移至Running子状态,并通过回调开始传递可用的输入缓冲区。来一张蛋疼的图示意:

codec

官方给出的一段典型代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
MediaCodec codec = MediaCodec.createByCodecName(name);
MediaFormat mOutputFormat; // member variable
codec.setCallback(new MediaCodec.Callback() {
@Override
void onInputBufferAvailable(MediaCodec mc, int inputBufferId) {
ByteBuffer inputBuffer = codec.getInputBuffer(inputBufferId);
// fill inputBuffer with valid data

codec.queueInputBuffer(inputBufferId, …);
}

@Override
void onOutputBufferAvailable(MediaCodec mc, int outputBufferId, …) {
ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId); // option A
// bufferFormat is equivalent to mOutputFormat
// outputBuffer is ready to be processed or rendered.

codec.releaseOutputBuffer(outputBufferId, …);
}

@Override
void onOutputFormatChanged(MediaCodec mc, MediaFormat format) {
// Subsequent data will conform to new format.
// Can ignore if using getOutputFormat(outputBufferId)
mOutputFormat = format; // option B
}

@Override
void onError(…) {

}
});
codec.configure(format, …);
mOutputFormat = codec.getOutputFormat(); // option B
codec.start();
// wait for processing to complete
codec.stop();
codec.release();

设置回调方法必须在mediacodec创建之后,并且在configure方法之前。mediacodec共有四个方法:

1
2
3
4
5
6
7
void onError(MediaCodec codec, MediaCodec.CodecException e)//发生错误时回调此方法

void onInputBufferAvailable(MediaCodec codec, int index)//当inputbuffer可用时回调此方法

void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info)
//当output方法可用时回调此方法
void onOutputFormatChanged(MediaCodec codec, MediaFormat format)//当输出格式变化时回调此方法

所有方法中的codec就是我们创建的那个mediacodec,index是指向缓冲区的BufferId,利用这个index用户可以获得缓冲区;format是变化后的Mediaformat,在h264流里边这个就是sps和pps。

看一下几个重要的方法

1
ByteBuffer getInputBuffer (int index)

该方法会返回一个已清空、可写入的input缓冲区,通过调用ByteBuffer.put(data)方法将data中的数据放到缓冲区后,也可以进行其他处理,然后调用void queueInputBuffer (int index, int offset, int size, long presentationTimeUs, int flags)就可以将缓冲区返回给codec。index是回调函数中返回的index,offset是缓冲区提交数据的起始未知,可以不从0开始,size是需要提交的长度,presentationTimeUs是时间戳,这个时间戳最好是按帧率来计算(单位:ns),当使用surface作为输出时,这个时间会作为食品的时间戳来显示;flags一般三个值:BUFFER_FLAG_CODEC_CONFIG:配置信息,BUFFER_FLAG_END_OF_STREAM:结束标志,BUFFER_FLAG_KEY_FRAME:关键帧,不建议使用。在执行此方法后index指向的缓冲区将不可访问,继续使用将会抛出异常。

1
ByteBuffer getOutputBuffer (int index)

用法同getinputbuffer一样。

1
2
void releaseOutputBuffer (int index,boolean render)
void releaseOutputBuffer (int index,long renderTimestampNs)

这两个方法都会释放index所指向的缓冲区。假如使用了surface,第二个参数传入传入true将会把数据先输出给surface,当surface不再使用时立即返回给codec,传入long型时:
如果在SurfaceView上渲染缓冲区,则可以使用时间戳在特定时间渲染缓冲区(在缓冲区时间戳之后或之后的VSYNC处)。为了达到这个目的,时间戳需要合理地接近当前的nanoTime()。目前,这是在一(1)秒内设定的。一些注意事项:

该缓冲区将不会返回到编解码器,直到时间戳已经过去并且该缓冲区不再被Surface使用。
缓冲区会按顺序处理,因此您可能会阻止后续缓冲区显示在Surface上。如果您想对用户操作做出反应,这很重要。停止视频或寻求。
如果将多个缓冲区发送到要在同一个VSYNC上渲染的Surface,则会显示最后一个缓冲区,其他将被放弃。
如果时间戳不与当前系统时间“合理接近”,Surface将忽略时间戳,并在最早的可行时间显示缓冲区。在这种模式下,它不会丢帧。
为获得最佳性能和质量,当您在所需渲染时间之前约两个VSYNC的时间时调用此方法。对于60Hz的显示器,这是大约33毫秒。

这段话的大概意思就是不要使用这个方法。

同步方法

getInput/OutputBuffers()已经被废弃,使用getInput/OutputBuffer(int)来获取缓冲区。

一段官方代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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();

几个重要方法:

1
int dequeueInputBuffer (long timeoutUs)

返回值是缓冲区的BufferId,假如返回值为-1则表示缓冲区不能使用。传入的参数为正,则是最长等待时间,为0则会立即返回缓冲区的id,负数则会无限等待。

End-of-stream

当结束输入数据时,发送如下代码即可:

1
codec.queueInputBuffer(index,0,0,0,BUFFER_FLAG_END_OF_STREAM);//第三个时间戳可以随意设置

前边提到过,接受到这个信号后,codec将不再接受任何新的数据,在这个信号之前的数据会全部输出。

使用surface作为输出

使用surface做为输出时与使用Bytebuffer基本一致,只是在surface模式下所有的bytebuffer和image全部为null。

1
2
3
releaseOutputBuffer(bufferId, false);  //不会渲染到surface上
releaseOutputBuffer(bufferId, true); //使用默认的时间戳渲染视频
releaseOutputBuffer(bufferId, timestamp) //使用指定的时间戳渲染视频