Android MediaCodec教程(三):详解如何在同步与异步模式下,使用MediaCodec将视频解码到ByteBuffers,并在ImageView上展示

系列文章目录

  1. Android MediaCodec 简明教程(一):使用 MediaCodecList 查询 Codec 信息,并创建 MediaCodec 编解码器
  2. Android MediaCodec 简明教程(二):使用 MediaCodecInfo.CodecCapabilities 查询 Codec 支持的宽高,颜色空间等能力

@TOC


前言

在前两章中,我们已经对如何查询 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 的解码使用起来并不麻烦,但它的使用比较灵活,提供了多种方案,有两个问题需要你进行回答:

  1. 使用同步模式还是异步模式。同步模式流程简单,但效率更低;异步模式涉及更多线程,流程更加复杂但效率更高。
  2. 解码到 Surface 还是 ByteBuffers?使用 Surface 是官方推荐的更为高效的方案,而 ByteBuffer 在使用上更简易。

本章将使用 MediaCodec 解码到 ByteBuffers,给出同步和异步两种实现。而 Surface 的解码等下个博客再详细聊。

2.1 同步模式使用框架

同步模式的基本框架:

java 复制代码
 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 异步模式使用框架

java 复制代码
 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();

异步模式下涉及至少两个线程:

  1. 调用线程,即你调用 start() stop() 等方法的线程
  2. 回调线程,即 MediaCodec 调用回调函数的线程。 因此使用异步模式时,需要保证线程安全。

另外,异步模式下转态转移与同步模式略有不同:start 后直接到 running 状态。

2.3 数据结束时的处理方式

当你到达输入数据的末尾时,你必须通过在调用queueInputBuffer时指定BUFFER_FLAG_END_OF_STREAM标志来向编解码器发出信号。你可以在最后一个有效的输入缓冲区上做这个操作,或者提交一个额外的带有结束流标志的空输入缓冲区。如果使用空缓冲区,时间戳将被忽略。 编解码器将继续返回输出缓冲区,直到最终通过在dequeueOutputBuffer中设置的BufferInfo或通过onOutputBufferAvailable返回的BufferInfo中指定相同的结束流标志来标志输出流的结束。这可以在最后一个有效的输出缓冲区上设置,或者在最后一个有效的输出缓冲区之后的空缓冲区上设置。这样的空缓冲区的时间戳应该被忽略。 在标志输入流结束后,除非编解码器已经被刷新,或者停止并重新启动,否则不要提交额外的输入缓冲区。

在输入数据流结束时,需要通过指定特定的标志(BUFFER_FLAG_END_OF_STREAM)来通知编解码器。编解码器在处理完所有输入数据后,也会通过同样的方式标志输出数据流的结束。在数据流结束标志后,不应再提交新的输入数据,除非编解码器已经被刷新或重启。这是为了确保数据的完整性和编解码器的正确运行。

2.4 同步模式解码实例

kotlin 复制代码
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()
    }
  1. 创建并配置媒体提取器(MediaExtractor):媒体提取器用于从媒体文件中提取音频和视频数据。这里,它从资源文件h264_720p中提取数据。
  2. 选择要处理的轨道:这里选择的是视频轨道,其索引为0。
  3. 获取视频格式:通过getTrackFormat方法获取视频轨道的格式。
  4. 创建并配置媒体编解码器(MediaCodec):首先,通过MediaCodecList获取适合视频格式的解码器名称,然后通过该名称创建解码器。接着,使用视频格式和空的Surface(这样可以更容易地获取解码后的位图)来配置解码器。
  5. 开始解码:首先,从视频格式中获取最大输入大小,并创建一个相应大小的ByteBuffer。然后,创建一个MediaCodec.BufferInfo对象,用于保存解码后的数据信息。最后,定义两个标志位,分别表示输入和输出是否结束。
  6. 循环解码:在循环中,首先从媒体提取器中获取输入缓冲区的数据。然后,从解码器中获取输入缓冲区,并将提取器中的数据填充到解码器的输入缓冲区中。接着,从解码器中获取输出缓冲区,并将其转换为位图,然后在主线程中更新ImageView。最后,释放输出缓冲区,并使线程休眠30毫秒,以模拟30fps的帧率。
  7. 结束解码:在循环结束后,释放媒体提取器和解码器。

2.5 异步模式解码实例

kotlin 复制代码
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()
    }
  1. 创建并配置媒体提取器(MediaExtractor):媒体提取器用于从媒体文件中提取音频和视频数据。这里,它从资源文件h264_4k_30中提取数据。
  2. 选择要处理的轨道:这里选择的是视频轨道,其索引为0。
  3. 获取视频格式:通过getTrackFormat方法获取视频轨道的格式。
  4. 创建并配置媒体编解码器(MediaCodec):首先,通过MediaCodecList获取适合视频格式的解码器名称,然后通过该名称创建解码器。
  5. 获取最大输入大小,并创建一个相应大小的ByteBuffer。然后,创建一个MediaCodec.BufferInfo对象,用于保存解码后的数据信息。最后,定义两个原子布尔值,分别表示输入和输出是否结束。
  6. 设置编解码器的回调:在异步模式下,需要设置编解码器的回调,包括输入缓冲区可用、输出缓冲区可用、错误和输出格式改变等事件。在输入缓冲区可用时,从媒体提取器中获取数据并填充到编解码器的输入缓冲区中;在输出缓冲区可用时,将输出缓冲区的数据转换为位图,并在主线程中更新ImageView。
  7. 配置并启动编解码器。
  8. 等待处理完成:在循环中,如果输出没有结束且没有停止解码,则使线程休眠10毫秒。
  9. 结束解码:在循环结束后,释放媒体提取器和解码器。

总结

本文介绍了 Android MediaCodec 相关知识,并给出了 MediaCodec 同步和异步的解码流程和示例代码,所有代码你可以在 learnmediacodec 找到。

参考

相关推荐
拭心3 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
带电的小王5 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡6 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
元争栈道6 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频
阿甘知识库7 小时前
宝塔面板跨服务器数据同步教程:双机备份零停机
android·运维·服务器·备份·同步·宝塔面板·建站
元争栈道7 小时前
webview+H5来实现的android短视频(短剧)音视频播放依赖控件资源
android·音视频
MuYe8 小时前
Android Hook - 动态加载so库
android
居居飒8 小时前
Android学习(四)-Kotlin编程语言-for循环
android·学习·kotlin
Henry_He11 小时前
桌面列表小部件不能点击的问题分析
android
工程师老罗11 小时前
Android笔试面试题AI答之Android基础(1)
android