Android原生编解码接口MediaCodec详解

作者:躬行之

了解了音视频的相关知识,可以先阅读同系列文章

MediaCodec 是 Android 中的编解码器组件,用来访问底层提供的编解码器,通常与 MediaExtractor、MediaSync、Mediamuxer、MediaCrypto、MediaDrm、Image、Surface 和 AudioTrack 一起使用,MediaCodec 几乎是 Android 播放器硬解码的标配,但是具体使用的是软编解码器还是硬解编解码器,还是和 MediaCodec 的配置相关,下面将从以下几个方面介绍 MediaCodec,主要内容如下

  1. MediaCodec处理的类型
  2. MediaCodec编解码的流程
  3. MediaCodec生命周期
  4. MediaCodec的创建
  5. MediaCodec的初始化
  6. MediaCodec的数据处理方式
  7. 自适应播放支持
  8. MediaCodec的异常处理

MediaCodec处理的类型

MediaCodec 支持处理三种数据类型,分别是压缩数据(compressed data)、原始音频数据(raw audio data)、原始视频数据(raw video data),可以使用 ByteBuffer 处理这三种数据,也就是后文中提到的缓冲区,对于原始视频数据,可以使用 Surface 来提高编解码器性能,但是不能访问原始视频数据,但是可以通过 ImageReader 访问原始视频帧,通过 Image 进而获取到与之对应的 YUV 数据等其他信息。

压缩缓冲区:用于解码器的输入缓冲区和用于编码器的输出缓冲区会包含 MediaFormat 的 KEY_MIME 对应类型的压缩数据,对于视频类型,通常是单个压缩视频帧,对于音频数据,这通常是一个编码的音频段,通常包含几毫秒的音频,因格式类型而定。

原始音频缓冲区:原始音频缓冲区包含 PCM 音频数据的整个帧,这是每一个通道按照通道顺序的一个样本,每个 PCM 音频样本都是 16 位带符号整数或浮点数(以本机字节顺序),如果要使用浮点 PCM 编码的原始音频缓冲区,需要如下配置:

mediaFormat.setInteger(MediaFormat.KEY_PCM_ENCODING, AudioFormat.ENCODING_PCM_FLOAT);

检查 MediaFormat 中的浮点 PCM 的方法如下:

 static boolean isPcmFloat(MediaFormat format) {
  return format.getInteger(MediaFormat.KEY_PCM_ENCODING, AudioFormat.ENCODING_PCM_16BIT)
      == AudioFormat.ENCODING_PCM_FLOAT;
 }

提取包含 16 位带符号整数音频数据的缓冲区的一个通道,可以使用以下代码

// Assumes the buffer PCM encoding is 16 bit.
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 = format.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;
}

原始视频缓冲区:在 ByteBuffer 模式下,视频缓冲区根据其 MediaFormat 的 KEY_COLOR_FORMAT 设置的值进行布局,可以从通过 MediaCodecInfo 相关方法获取设备受支持的颜色格式,视频编解码器可能支持三种颜色格式:

  • native raw video format:原始原始视频格式,由CodecCapabilities 的 COLOR_FormatSurface 常量标记,可以与输入或输出Surface一起使用。

  • flexible YUV buffers:灵活的 YUV 缓冲区,如 CodecCapabilities 的 COLOR_FormatYUV420Flexible 常量对应的颜色格式,可以通过 getInput、Outputimage 等于与输入、输出 Surface 以及 ByteBuffer 模式一起使用。

  • other specific formats:其他特定格式:通常仅在 ByteBuffer 模式下支持这些格式, 某些颜色格式是特定于供应商的,其他在均在 CodecCapabilities 中定义。

自 Android 5.1 开始,所有视频编解码器均支持灵活的 YUV 4:2:0 缓冲区。其中 MediaFormat#KEY_WIDTH 和 MediaFormat#KEY_HEIGHT 键指定视频帧的大小,在大多数情况下,视频仅占据视频帧的一部分,具体表示如下:

需要使用以下键从输出格式获取原始输出图像的裁剪矩形,如果输出格式中不存在这些键,则视频将占据整个视频帧,在使用任何 MediaFormat#KEY_ROTATION 之前,也就是在设置旋转之前,可以使用下面的方式计算视频帧的大小,参考如下:

 MediaFormat format = decoder.getoutputFormat();
 int width = format.getInteger(MediaFormat.KEY_WIDTH);
 if (format.containsKey("crop-left") && format.containsKey("crop-right")) {
    width = format.getInteger("crop-right") + 1 - format.getInteger("crop-left");
 }
 int height = format.getInteger(MediaFormat.KEY_HEIGHT);
 if (format.containsKey("crop-top") && format.containsKey("crop-bottom")) {
    height = format.getInteger("crop-bottom") + 1 - format.getInteger("crop-top");
 }

MediaCodec编解码的流程

MediaCodec 首先获取一个空的输入缓冲区,填充要编码或解码的数据,再将填充数据的输入缓冲区送到 MediaCodec 进行处理,处理完数据后会释放这个填充数据的输入缓冲区,最后获取已经编码或解码的输出缓冲区,使用完毕后释放输出缓冲区,其编解码的流程示意图如下:

各个阶段对应的 API 如下:

// 获取可用的输入缓冲区的索引
public int dequeueInputBuffer (long timeoutUs)
// 获取输入缓冲区
public ByteBuffer getInputBuffer(int index)
// 将填满数据的inputBuffer提交到编码队列
public final void queueInputBuffer(int index,int offset, int size, long presentationTimeUs, int flags)
// 获取已成功编解码的输出缓冲区的索引
public final int dequeueOutputBuffer(BufferInfo info, long timeoutUs)
// 获取输出缓冲区
public ByteBuffer getoutputBuffer(int index)
// 释放输出缓冲区
public final void releaSEOutputBuffer(int index, boolean render) 

MediaCodec生命周期

MediaCodec 有三种状态,分别是执行(Executing)、停止(Stopped)和释放(Released),其中执行和停止分别有三个子状态,执行的三个字状态分别是 Flushed、Running 和 Stream-of-Stream,停止的三个子状态分别是 Uninitialized、Configured 和 Error,MediaCodec 生命周期示意图如下:

同步模式下的生命周期

异步模式模式下的生命周期

如上图所示,三种状态的切换都是由 start、stop、reset、release 等触发,根据 MediaCodec 处理数据方式的不同,其生命周期会略有不同,如在异步模式下 start 之后立即进入 Running 子状态,如果已经处于 Flushed 子状态,则需再次调用 start 进入 Running 子状态,下面是各个子状态切换对应的关键 API 如下:

  • 停止状态(Stopped)
// 创建MediaCodec进入Uninitialized子状态
public static MediaCodec createByCodecName (String name)
public static MediaCodec createEncoderByType (String type)
public static MediaCodec createDecoderByType (String type)
// 配置MediaCodec进入Configured子状态,crypto和descrambler会在后文中进行说明
public void configure(MediaFormat format, Surface surface, MediaCrypto crypto, int flags)
public void configure(MediaFormat format, @Nullable Surface surface,int flags, MediaDescrambler descrambler)
// Error
// 编解码过程中遇到错误进入Error子状态
  • 执行状态(Executing)
// start之后立即进入Flushed子状态
public final void start()
// 第一个输入缓冲区出队的时候进入Running子状态
public int dequeueInputBuffer (long timeoutUs)
// 输入缓冲区与流结束标记排队时,编解码器将转换为End-of-Stream子状态
// 此时MediaCodec将不接受其他输入缓冲区,但会生成输出缓冲区
public void queueInputBuffer (int index, int offset, int flags)
  • 释放状态(Released)
// 编解码完成结束后释放MediaCodec进入释放状态(Released)
public void release ()

MediaCodec的创建

前面已经提到过当创建 MediaCodec 的时候进入Uninitialized 子状态,其创建方式如下:

// 创建MediaCodec
public static MediaCodec createByCodecName (String name)
public static MediaCodec createEncoderByType (String type)
public static MediaCodec createDecoderByType (String type)

使用 createByCodecName 时可以借助 MediaCodecList 获取支持的编解码器,下面是获取指定 MIME 类型的编码器:

/**
 * 查询指定MIME类型的编码器
 */
fun selectCodec(mimeType: String): MediaCodecInfo? {
    val mediaCodecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
    val codeInfos = mediaCodecList.codecInfos
    for (codeInfo in codeInfos) {
        if (!codeInfo.isEncoder) continue
        val types = codeInfo.supportedTypes
        for (type in types) {
            if (type.equals(mimeType, true)) {
                return codeInfo
            }
        }
    }
    return null
}

当然 MediaCodecList 也提供了相应的获取编解码器的方法,如下:

// 获取指定格式的编码器
public String findEncoderForFormat (MediaFormat format)
// 获取指定格式的解码器
public String findDecoderForFormat (MediaFormat format)

对于上述方法中的参数 MediaFormat 格式中不能包含任何帧率的设置,如果已经设置了帧率需要将其清除再使用。

上面提到了 MediaCodecList,这里简单说一下,使用 MediaCodecList 可以方便的列出当前设备支持的所有的编解码器,创建 MediaCodec 的时候要选择当前格式支持的编解码器,也就是选择的编解码器需支持对应的 MediaFormat,每个编解码器都被包装成一个 MediaCodecInfo 对象,据此可以查看该编码器的一些特性,比如是否支持硬件加速、是软解还是硬解编解码器等,常用的简单如下:

// 是否软解
public boolean isSoftwareOnly ()
// 是Android平台提供(false)还是厂商提供(true)的编解码器
public boolean isvendor ()
// 是否支持硬件加速
public boolean isHardwareAccelerated ()
// 是编码器还是解码器
public boolean isEncoder ()
// 获取当前编解码器支持的合适
public String[] getSupportedTypes ()
// ...

软解和硬解应该是音视频开发中必须掌握的,当使用 MediaCodec 的时候不能说全是硬解,到底使用硬解还是软解还是要看使用的编码器,一般厂商提供的编解码器都是硬解编解码器,比如高通(qcom)等,一般如系统提供的则是软解编解码器,如带有 android 字样的编解码器,下面是本人(MI 10 Pro)自己手机的部分编解码器:

// 硬解编解码器
OMX.qcom.video.encoder.heic
OMX.qcom.video.decoder.avc
OMX.qcom.video.decoder.avc.secure
OMX.qcom.video.decoder.mpeg2
OMX.google.gsm.decoder
OMX.qti.video.decoder.h263sw
c2.qti.avc.decoder
...
// 软解编解码器
c2.android.aac.decoder
c2.android.aac.decoder
c2.android.aac.encoder
c2.android.aac.encoder
c2.android.amrnb.decoder
c2.android.amrnb.decoder
...

MediaCodec初始化

创建 MediaCodec 之后进入 Uninitialized 子状态,此时需要对其进行一些设置如指定 MediaFormat、如果使用的是异步处理数据的方式,在 configure 之前要设置 MediaCodec.Callback,关键 API 如下:

// 1\. MediaFormat
// 创建MediaFormat
public static final MediaFormat createVideoFormat(String mime,int width,int height)
// 开启或关闭功能,具体参见MediaCodeInfo.CodecCapabilities
public void setFeatureEnabled(@NonNull String feature, boolean enabled)
// 参数设置
public final void setInteger(String name, int value)

// 2\. setCallback
// 如果使用的是异步处理数据的方式,在configure 之前要设置 MediaCodec.Callback
public void setCallback (MediaCodec.Callback cb)
public void setCallback (MediaCodec.Callback cb, Handler handler)

// 3\. 配置
public void configure(MediaFormat format, MediaDescrambler descrambler)

上面 configure 配置中涉及到几个参数,其中 surface 表示解码器要渲染的 Surface,flags 则是指定当前编解码器是作为编码器还是解码器来使用的,crypto 和 descrambler 都和解密有关,比如某些 vip 视频就需要特定的密钥来配合解码,只有用户登录校验后才会对视频内容进行解密,要不然某些需要付费才能观看的视频下载之后就能随意传播了,更多细节可以查看音视频中的数字版权技术。

此外某些特定格式比如 AAC 音频以及 MPEG4、H.264、H.265 视频格式,这些格式的含一些用于 MediaCodec 的初始化特定的数据,当解码处理这些压缩格式时,必须在 start 之后且在任何帧数据处理之前将这些特定数据提交给 MediaCodec,即在对 queueInputBuffer 的调用中使用标志 BUFFER_FLAG_CODEC_CONfig 标记此类数据,这些特定的数据也可以通过 MediaFormat 设置 ByteBuffer 的方式进行配置,如下:

// csd-0、csd-1、csd-2同理
val bytes = byteArrayOf(0x00.toByte(), 0x01.toByte())
mediaFormat.setByteBuffer("csd-0", ByteBuffer.wrap(bytes))

其中 csd-0、csd-1 这些键可以从 MediaExtractor#getTrackFormat 获取的MediaFormat中获取,这些特定的数据会在start 时自动提交给 MediaCodec,无需直接提交此数据,如果在输出缓冲区或格式更改之前调用了 flush,则会丢失提交的特定数据,就需要在 queueInputBuffer 的调用中使用标志 BUFFER_FLAG_CODEC_CONfig 标记这类数据。

Android 使用以下特定于编解码器的数据缓冲区,为了正确配置 Mediamuxer 轨道,还需要将它们设置为轨道格式,每个参数集和标有(*)的编解码器专用数据部分必须以“ \ x00 \ x00 \ x00 \ x01”的起始代码开头,参考如下:

编码器在收到这些信息后将会同样输出带有BUFFER_FLAG_CODEC_CONfig标记的 outputbuffer,此时这些数据就是特定数据,不是媒体数据。

MediaCodec数据处理方式

每个创建已经创建的编解码器都维护一组输入缓冲区,有两种处理数据的方式,同步和异步方式,根据 API 版本不同有所区别,在 API 21 也就是从 Android5.0 开始,推荐使用 ButeBuffer 的方式进行数据的处理,在此之前只能使用 ButeBuffer 数组的方式进行数据的处理,如下:

MediaCodec,也就是编解码器的数据处理,主要是获取输入、输出缓冲区、提交数据给编解码器、释放输出缓冲区这几个过程,同步方式和异步方式的不同点在于输入缓冲区和输出缓冲区的其关键 API 如下:

// 获取输入缓冲区(同步)
public int dequeueInputBuffer (long timeoutUs)
public ByteBuffer getInputBuffer (int index)
// 获取输出缓冲区(同步)
public int dequeueOutputBuffer (MediaCodec.BufferInfo info, long timeoutUs)
public ByteBuffer getoutputBuffer (int index)
// 输入、输出缓冲区索引从MediaCodec.Callback的回调中获取,在获取对应的输入、输出缓冲区(异步)
public void setCallback (MediaCodec.Callback cb)
public void setCallback (MediaCodec.Callback cb, Handler handler)
// 提交数据
public void queueInputBuffer (int index, int flags)
public void queueSecureInputBuffer (int index, MediaCodec.CryptoInfo info, int flags)
// 释放输出缓冲区
public void releaSEOutputBuffer (int index, boolean render)
public void releaSEOutputBuffer (int index, long renderTimestampNs)

下面主要介绍介绍适用于 Android 5.0 之后的 ButeBuffer 的方式,

Android 5.0 开始 Deprecated 了 ButeBuffer 数组的方式,官网上提到 ButeBuffer 相较 ButeBuffer 数组的方式做了一定优化,故在设备满足条件的情况下尽量使用 ButeBuffer 对应的 API,且推荐使用异步模式处理数据,同步和异步处理方式代码参考如下:

  • 同步处理模式
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();
    // 使用有效数据填充输入缓冲区
    …
    codec.queueInputBuffer(inputBufferId,);
  }
  int outputBufferId = codec.dequeueOutputBuffer();
  if (outputBufferId >= 0) {
    ByteBuffer outputBuffer = codec.getoutputBuffer(outputBufferId);
    MediaFormat bufferFormat = codec.getoutputFormat(outputBufferId); // option A
    // bufferFormat与outputFormat是相同的
    // 输出缓冲区已准备后被处理或渲染了
    …
    codec.releaSEOutputBuffer(outputBufferId,);
  } else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
    // 输出格式改变,后续采用新格式,此时使用getoutputFormat()获取新格式
    // 如果使用getoutputFormat(outputBufferId)获取特定缓冲区的格式,则无需监听格式变化
    outputFormat = codec.getoutputFormat(); // option B
  }
 }
 codec.stop();
 codec.release();

具体可以参考上一篇文章中的案例:Camera2、MediaCodec录制mp4

  • 异步处理模式
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();

当要处理的数据结束时(End-of-stream),需要标记流的结束,可以在最后一个有效的输入缓冲区上使用 queueInputBuffer 提交数据的时候指定 flags 为 BUFFER_FLAG_END_OF_STREAM 标记其结束,也可以在最后一个有效输入缓冲区之后提交一个空的设置了流结束标志的输入缓冲区来标记其结束,此时不能够再提交输入缓冲区,除非编解码器被 flush、stop、restart,输出缓冲区继续返回直到最终通过在 dequeueOutputBuffer 或通过 Callback#onOutputBufferAvailable 返回的 BufferInfo 中指定相同的流结束标志,最终通知输出流结束为止。

如果使用了一个输入 Surface 作为编解码器的输入,此时没有可访问的输入缓冲区,输入缓冲区会自动从这个 Surface 提交给编解码器,相当于省略了输入的这个过程,这个输入 Surface 可由 createInputSurface 方法创建,此时调用 signalEndOfInputStream 将发送流结束的信号,调用后,输入表面将立即停止向编解码器提交数据,关键 API 如下:

// 创建输入Surface,需在configure之后、start之前调用
public Surface createInputSurface ()
// 设置输入Surface
public void setInputSurface (Surface surface)
// 发送流结束的信号
public void signalEndOfInputStream ()

同理如果使用了输出 Surface,则与之相关的输出缓冲区的相关功能将会被代替,可以通过 setoutputSurface 设置一个 Surface 作为编解码器的输出,可以选择是否在输出 Surface 上渲染每一个输出缓冲区,关键 API 如下:

// 设置输出Surface
public void setoutputSurface (Surface surface)
// false表示不渲染这个buffer,true表示使用认的时间戳渲染这个buffer
public void releaSEOutputBuffer (int index, boolean render)
// 使用指定的时间戳渲染这个buffer
public void releaSEOutputBuffer (int index, long renderTimestampNs)

自适应播放支持

当 MediaCodec 作为视频解码器的时候,可以通过如下方式检查解码器是否支持自适应播放,也就是此时解码器是否支持无缝的分辨率修改

// 是否支持某项功能,CodecCapabilities#FEATURE_AdaptivePlayback对应对应自适应播放支持
public boolean isFeatureSupported (String name)

此时只有在将解码器配置在 Surface 上解码时,自适应播放的功能才会被激活,视频解码时当 strat 或 flush 调用后,只有关键帧(key-frame)才能完全独立解码,也就是通常说的 I 帧,其他帧都是据此来解码的,不同格式对应关键帧如下:

不同的解码器对自适应播放的支持能力不同,其 seek 操作后处理也是不同,这部分内容暂时留到后续具体实践后再做整理。

MediaCodec的异常处理

关于 MediaCodec 使用过程中的异常处理,这里提一下 CodecException 异常,一般是由编解码器内部异常导致的,比如媒体内容损坏、硬件故障、资源耗尽等,可以通过如下方法判断以做进一步的处理:

// true表示可以通过stop、configure、start来恢复
public boolean isRecoverable ()
// true表示暂时性问题,编码或解码操作会在后续重试进行
public boolean isTransient ()

如果 isRecoverable 和 isTransient 都是返回 false,则需要通过 reset 或 release 操作释放资源后重新工作,两者不可能同时返回 true。

相关文章

Android性能优化——之控件的优化 前面讲了图像的优化,接下...
前言 上一篇已经讲了如何实现textView中粗字体效果,里面主要...
最近项目重构,涉及到了数据库和文件下载,发现GreenDao这个...
WebView加载页面的两种方式 一、加载网络页面 加载网络页面,...
给APP全局设置字体主要分为两个方面来介绍 一、给原生界面设...
前言 最近UI大牛出了一版新的效果图,按照IOS的效果做的,页...