系列文章目录
- Android MediaCodec 简明教程(一):使用 MediaCodecList 查询 Codec 信息,并创建 MediaCodec 编解码器
- Android MediaCodec 简明教程(二):使用 MediaCodecInfo.CodecCapabilities 查询 Codec 支持的宽高,颜色空间等能力
文章目录
前言
在前两章中,我们已经对如何查询 Codec 和 Codec 的支持特性有了深入的理解,这是通过学习 MediaCodecList 和 MediaCodecInfo.CodecCapabilities 实现的。在确认设备的 Codec 支持特定视频后,我们可以创建相应的 MediaCodec 进行视频解码。
本章,我们将探讨如何使用 MediaCodec 进行视频解码。MediaCodec 支持同步和异步两种模式,同时也支持使用 Surface 或 ByteBuffers 进行数据处理。尽管官方推荐使用 Surface,但考虑到这是一个入门教程,我们将从简单的开始。使用 Surface 的复杂度比使用 ByteBuffers 更高,因此,我们将在本文中讨论如何在同步和异步模式下,使用 MediaCodec 将视频解码到 ByteBuffers。
本文所有代码你可以在 learnmediacodec 找到。
一、MediaCodec 是什么?
Android MediaCodec 是 Android 提供的一个 API,用于访问底层的多媒体硬件组件,如视频和音频编解码器。这个 API 提供了一个标准的方式来处理多媒体数据,使得开发者可以更容易地在 Android 平台上进行音视频开发。
MediaCodec 可以处理原始的音视频数据,包括编码(将原始数据转换为压缩格式)和解码(将压缩格式转换为原始数据)。这使得开发者可以更方便地进行音视频流的处理,比如实现音视频的播放、录制、编辑等功能。
1.1 MediaCodec 像是一个工厂
上图是 Android 官网中对 MediaCodec 工作原理的描述,简单来说,编解码器(codec)是一种处理输入数据以生成输出数据的工具。它异步地处理数据,并使用一组输入和输出缓冲区。你需要请求(或接收)一个空的输入缓冲区(input buffer),将其填充数据并发送给编解码器进行处理。编解码器使用这些数据,并将其转换为一个空的输出缓冲区。最后,你需要请求(或接收)一个已填充的输出缓冲区(output buffer),消耗其内容并将其释放回编解码器。
MediaCodec可以被类比为一个工厂的生产线。输入缓冲区就像是原材料仓库,输出缓冲区就像是成品仓库。原材料(即待编解码的数据)首先被送入原材料仓库(即输入缓冲区),然后工厂(即MediaCodec)根据生产需求,从原材料仓库中取出原材料进行加工处理(即编解码操作)。加工处理完成后,成品(即编解码后的数据)被放入成品仓库(即输出缓冲区)。最后,消费者(即应用程序)从成品仓库中取出成品进行使用。
在这个过程中,原材料仓库和成品仓库都不止一个,这样可以保证工厂的连续生产,提高生产效率。同时,工厂的生产过程是异步的,也就是说,工厂在加工处理原材料的同时,消费者可以从成品仓库中取出成品进行使用,这样可以提高整体的效率。
写到这里,有个问题涌入脑中: MediaCodec 中有几个 input buffer 和 output buffer 呢?Android MediaCodec的输入缓冲区和输出缓冲区的数量并没有固定的值,它们的数量取决于MediaCodec的实现和设备的性能。但是,通常情况下,MediaCodec至少会有一个输入缓冲区和一个输出缓冲区。
在实际使用中,MediaCodec通常会有多个输入缓冲区和输出缓冲区。这是因为,通过使用多个缓冲区,MediaCodec可以在一个缓冲区正在被处理(例如,正在进行编解码操作)的同时,另一个缓冲区可以被填充或消耗数据,这样可以提高处理效率。
具体的数量可以通过调用MediaCodec的getInputBuffers()和getOutputBuffers()方法来获取,这两个方法都会返回一个ByteBuffer数组,数组的长度就是缓冲区的数量。例如在笔者的测试机上有 5 个 input buffer 和 20 个 output buffer。
1.2 MediaCodec 的转态流转
上图是 MediaCodec 状态的流转图(同步模式)。
在其生命周期中,编解码器(codec)在概念上存在于三种状态之一:停止(Stopped)、执行(Executing)或释放(Released)。停止状态实际上是三种状态的集合:未初始化(Uninitialized)、已配置(Configured)和错误(Error),而执行状态在概念上经历三个子状态:刷新(Flushed)、运行(Running)和流结束(End-of-Stream)。
当你使用工厂方法之一创建编解码器时,编解码器处于未初始化状态。首先,你需要通过configure(…)方法配置它,这会将其转移到已配置状态,然后调用start()方法将其转移到执行状态。在此状态下,你可以通过上述的缓冲区队列操作处理数据。
执行状态有三个子状态:刷新、运行和流结束。在start()方法后,编解码器立即处于刷新子状态,此时它持有所有的缓冲区。一旦第一个输入缓冲区被出队,编解码器就转移到运行子状态,它在这个状态下度过了大部分的生命周期。当你将带有流结束标记的输入缓冲区入队时,编解码器转移到流结束子状态。在此状态下,编解码器不再接受更多的输入缓冲区,但仍然生成输出缓冲区,直到输出上达到流结束。对于解码器,你可以在执行状态下的任何时候使用flush()方法返回到刷新子状态。
调用stop()方法将编解码器返回到未初始化状态,然后它可以再次被配置。当你完成编解码器的使用后,你必须通过调用release()方法释放它。
在极少数情况下,编解码器可能会遇到错误并转移到错误状态。这通过从队列操作的无效返回值,或有时通过异常来通知。调用reset()方法可以使编解码器再次可用。你可以从任何状态调用它,将编解码器返回到未初始化状态。否则,调用release()方法转移到终止的已释放状态。
在不同的状态下,能够进行的操作是不同的,如果转态不匹配 MediaCodec 会抛出异常。在使用MediaCodec的过程中,你需要根据其当前的状态来执行相应的操作。例如,只有在已配置状态下,你才能启动MediaCodec;只有在执行状态下,你才能处理数据;只有在停止或执行状态下,你才能释放MediaCodec。如果在错误状态下,你需要调用reset()方法来重置MediaCodec,使其回到未初始化状态。
二、使用 MediaCodec 进行解码
MediaCodec 的解码使用起来并不麻烦,但它的使用比较灵活,提供了多种方案,有两个问题需要你进行回答:
- 使用同步模式还是异步模式。同步模式流程简单,但效率更低;异步模式涉及更多线程,流程更加复杂但效率更高。
- 解码到 Surface 还是 ByteBuffers?使用 Surface 是官方推荐的更为高效的方案,而 ByteBuffer 在使用上更简易。
本章将使用 MediaCodec 解码到 ByteBuffers,给出同步和异步两种实现。而 Surface 的解码等下个博客再详细聊。
2.1 同步模式使用框架
同步模式的基本框架:
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();
2.2 异步模式使用框架
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(…) {
…
}
@Override
void onCryptoError(…) {
…
}
});
codec.configure(format, …);
mOutputFormat = codec.getOutputFormat(); // option B
codec.start();
// wait for processing to complete
codec.stop();
codec.release();
异步模式下涉及至少两个线程:
- 调用线程,即你调用 start() stop() 等方法的线程
- 回调线程,即 MediaCodec 调用回调函数的线程。
因此使用异步模式时,需要保证线程安全。
另外,异步模式下转态转移与同步模式略有不同:start 后直接到 running 状态。
2.3 数据结束时的处理方式
当你到达输入数据的末尾时,你必须通过在调用queueInputBuffer时指定BUFFER_FLAG_END_OF_STREAM标志来向编解码器发出信号。你可以在最后一个有效的输入缓冲区上做这个操作,或者提交一个额外的带有结束流标志的空输入缓冲区。如果使用空缓冲区,时间戳将被忽略。
编解码器将继续返回输出缓冲区,直到最终通过在dequeueOutputBuffer中设置的BufferInfo或通过onOutputBufferAvailable返回的BufferInfo中指定相同的结束流标志来标志输出流的结束。这可以在最后一个有效的输出缓冲区上设置,或者在最后一个有效的输出缓冲区之后的空缓冲区上设置。这样的空缓冲区的时间戳应该被忽略。
在标志输入流结束后,除非编解码器已经被刷新,或者停止并重新启动,否则不要提交额外的输入缓冲区。
在输入数据流结束时,需要通过指定特定的标志(BUFFER_FLAG_END_OF_STREAM)来通知编解码器。编解码器在处理完所有输入数据后,也会通过同样的方式标志输出数据流的结束。在数据流结束标志后,不应再提交新的输入数据,除非编解码器已经被刷新或重启。这是为了确保数据的完整性和编解码器的正确运行。
2.4 同步模式解码实例
private fun decodeToBitmap() {
// create and configure media extractor
val mediaExtractor = MediaExtractor()
resources.openRawResourceFd(R.raw.h264_720p).use {
mediaExtractor.setDataSource(it)
}
val videoTrackIndex = 0
mediaExtractor.selectTrack(videoTrackIndex)
val videoFormat = mediaExtractor.getTrackFormat(videoTrackIndex)
// create and configure media codec
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val codecName = codecList.findDecoderForFormat(videoFormat)
val codec = MediaCodec.createByCodecName(codecName)
// configure with null surface so that we can get decoded bitmap easily
codec.configure(videoFormat, null, null, 0)
// start decoding
val maxInputSize = videoFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
val inputBuffer = ByteBuffer.allocate(maxInputSize)
val bufferInfo = MediaCodec.BufferInfo()
val timeoutUs = 10000L // 10ms
var inputEnd = false
var outputEnd = false
codec.start()
while (!outputEnd && !stopDecoding) {
val isExtractorReadEnd =
getInputBufferFromExtractor(mediaExtractor, inputBuffer, bufferInfo)
if (isExtractorReadEnd) {
inputEnd = true
}
// get codec input buffer and fill it with data from extractor
// timeoutUs is -1L means wait forever
val inputBufferId = codec.dequeueInputBuffer(-1L)
if (inputBufferId >= 0) {
if (inputEnd) {
codec.queueInputBuffer(inputBufferId, 0, 0, 0, BUFFER_FLAG_END_OF_STREAM)
} else {
val codecInputBuffer = codec.getInputBuffer(inputBufferId)
codecInputBuffer!!.put(inputBuffer)
codec.queueInputBuffer(
inputBufferId,
0,
bufferInfo.size,
bufferInfo.presentationTimeUs,
0
)
}
}
// get output buffer from codec and render it to image view
// NOTE! dequeueOutputBuffer with -1L is will stuck here, so wait 10ms here
val outputBufferId = codec.dequeueOutputBuffer(bufferInfo, timeoutUs)
if (outputBufferId >= 0) {
if (bufferInfo.flags and BUFFER_FLAG_END_OF_STREAM != 0) {
outputEnd = true
}
if (bufferInfo.size > 0) {
// get output image from codec, is a YUV image
val outputImage = codec.getOutputImage(outputBufferId)
// convert YUV image to bitmap so that we can render it to image view
val bitmap = yuvImage2Bitmap(outputImage!!)
// post to main thread to update image view
imageView.post {
imageView.setImageBitmap(bitmap)
}
// remember to release output buffer after rendering
codec.releaseOutputBuffer(outputBufferId, false)
// sleep 30ms to simulate 30fps
Thread.sleep(30)
}
}
mediaExtractor.advance()
}
mediaExtractor.release()
codec.stop()
codec.release()
}
- 创建并配置媒体提取器(MediaExtractor):媒体提取器用于从媒体文件中提取音频和视频数据。这里,它从资源文件h264_720p中提取数据。
- 选择要处理的轨道:这里选择的是视频轨道,其索引为0。
- 获取视频格式:通过getTrackFormat方法获取视频轨道的格式。
- 创建并配置媒体编解码器(MediaCodec):首先,通过MediaCodecList获取适合视频格式的解码器名称,然后通过该名称创建解码器。接着,使用视频格式和空的Surface(这样可以更容易地获取解码后的位图)来配置解码器。
- 开始解码:首先,从视频格式中获取最大输入大小,并创建一个相应大小的ByteBuffer。然后,创建一个MediaCodec.BufferInfo对象,用于保存解码后的数据信息。最后,定义两个标志位,分别表示输入和输出是否结束。
- 循环解码:在循环中,首先从媒体提取器中获取输入缓冲区的数据。然后,从解码器中获取输入缓冲区,并将提取器中的数据填充到解码器的输入缓冲区中。接着,从解码器中获取输出缓冲区,并将其转换为位图,然后在主线程中更新ImageView。最后,释放输出缓冲区,并使线程休眠30毫秒,以模拟30fps的帧率。
- 结束解码:在循环结束后,释放媒体提取器和解码器。
2.5 异步模式解码实例
private fun decodeToBitmapAsync() {
// create and configure media extractor
val mediaExtractor = MediaExtractor()
resources.openRawResourceFd(R.raw.h264_4k_30).use {
mediaExtractor.setDataSource(it)
}
val videoTrackIndex = 0
mediaExtractor.selectTrack(videoTrackIndex)
val videoFormat = mediaExtractor.getTrackFormat(videoTrackIndex)
// create and configure media codec
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val codecName = codecList.findDecoderForFormat(videoFormat)
val codec = MediaCodec.createByCodecName(codecName)
val maxInputSize = videoFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
val inputBuffer = ByteBuffer.allocate(maxInputSize)
val bufferInfo = MediaCodec.BufferInfo()
val inputEnd = AtomicBoolean(false)
val outputEnd = AtomicBoolean(false)
// set codec callback in async mode
codec.setCallback(object : MediaCodec.Callback() {
override fun onInputBufferAvailable(codec: MediaCodec, inputBufferId: Int) {
val isExtractorReadEnd =
getInputBufferFromExtractor(mediaExtractor, inputBuffer, bufferInfo)
if (isExtractorReadEnd) {
inputEnd.set(true)
codec.queueInputBuffer(inputBufferId, 0, 0, 0, BUFFER_FLAG_END_OF_STREAM)
} else {
val codecInputBuffer = codec.getInputBuffer(inputBufferId)
codecInputBuffer!!.put(inputBuffer)
codec.queueInputBuffer(
inputBufferId,
0,
bufferInfo.size,
bufferInfo.presentationTimeUs,
bufferInfo.flags
)
mediaExtractor.advance()
}
}
override fun onOutputBufferAvailable(
codec: MediaCodec,
outputBufferId: Int,
info: MediaCodec.BufferInfo
) {
if (info.flags and BUFFER_FLAG_END_OF_STREAM != 0) {
outputEnd.set(true)
}
if(info.size > 0){
val outputImage = codec.getOutputImage(outputBufferId)
val bitmap = yuvImage2Bitmap(outputImage!!)
runOnUiThread{
imageView.setImageBitmap(bitmap)
}
codec.releaseOutputBuffer(outputBufferId, false)
Thread.sleep(30)
}
}
override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
e.printStackTrace()
}
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
// do nothing
}
})
codec.configure(videoFormat, null, null, 0)
codec.start()
// wait for processing to complete
while (!outputEnd.get() && !stopDecoding) {
Thread.sleep(10)
}
mediaExtractor.release()
codec.stop()
codec.release()
}
- 创建并配置媒体提取器(MediaExtractor):媒体提取器用于从媒体文件中提取音频和视频数据。这里,它从资源文件h264_4k_30中提取数据。
- 选择要处理的轨道:这里选择的是视频轨道,其索引为0。
- 获取视频格式:通过getTrackFormat方法获取视频轨道的格式。
- 创建并配置媒体编解码器(MediaCodec):首先,通过MediaCodecList获取适合视频格式的解码器名称,然后通过该名称创建解码器。
- 获取最大输入大小,并创建一个相应大小的ByteBuffer。然后,创建一个MediaCodec.BufferInfo对象,用于保存解码后的数据信息。最后,定义两个原子布尔值,分别表示输入和输出是否结束。
- 设置编解码器的回调:在异步模式下,需要设置编解码器的回调,包括输入缓冲区可用、输出缓冲区可用、错误和输出格式改变等事件。在输入缓冲区可用时,从媒体提取器中获取数据并填充到编解码器的输入缓冲区中;在输出缓冲区可用时,将输出缓冲区的数据转换为位图,并在主线程中更新ImageView。
- 配置并启动编解码器。
- 等待处理完成:在循环中,如果输出没有结束且没有停止解码,则使线程休眠10毫秒。
- 结束解码:在循环结束后,释放媒体提取器和解码器。
总结
本文介绍了 Android MediaCodec 相关知识,并给出了 MediaCodec 同步和异步的解码流程和示例代码,所有代码你可以在 learnmediacodec 找到。