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 找到。

参考

相关推荐
似霰30 分钟前
安卓adb shell串口基础指令
android·adb
音视频牛哥1 小时前
Linux平台实现低延迟的RTSP、RTMP播放
音视频开发·视频编码·直播
fatiaozhang95273 小时前
中兴云电脑W102D_晶晨S905X2_2+16G_mt7661无线_安卓9.0_线刷固件包
android·adb·电视盒子·魔百盒刷机·魔百盒固件
CYRUS_STUDIO4 小时前
Android APP 热修复原理
android·app·hotfix
鸿蒙布道师4 小时前
鸿蒙NEXT开发通知工具类(ArkTs)
android·ios·华为·harmonyos·arkts·鸿蒙系统·huawei
鸿蒙布道师4 小时前
鸿蒙NEXT开发网络相关工具类(ArkTs)
android·ios·华为·harmonyos·arkts·鸿蒙系统·huawei
大耳猫4 小时前
【解决】Android Gradle Sync 报错 Could not read workspace metadata
android·gradle·android studio
ta叫我小白5 小时前
实现 Android 图片信息获取和 EXIF 坐标解析
android·exif·经纬度
心走6 小时前
鸿蒙WebRTC编译指南&踩坑(Native 编译指导)
harmonyos·音视频开发
dpxiaolong6 小时前
RK3588平台用v4l工具调试USB摄像头实践(亮度,饱和度,对比度,色相等)
android·windows