Android Camera2采集并编码为H.264

前言

本篇博文主要讲述的是基于Android原生MediaCodec通过Camera2 API进行图像数据采集并编码为H.264的实现过程,如果对此感兴趣的不妨驻足观看,也欢迎大家大家对本文中描述不当或者不正确的地方进行指正。如果对于Camera2预览还不熟悉的可以观看博主上一篇博文:Android 基于Camera2 API进行摄像机图像预览

MediaCodec简介

MediaCodec 主要是用于对音视频进行编解码。它通常与 MediaExtractor、MediaMuxer、Surface 和 AudioTrack 等组件一起使用。MediaCodec 支持硬件加速,可以利用设备的硬件资源来提高编解码的性能。

1、MediaCodec 编解码流程

MediaCodec 采用异步方式处理数据,使用一组输入输出缓冲区(ByteBuffer)。编解码流程大致如下:

  • 请求一个空的输入缓冲区,填充满数据后传递给 MediaCodec 处理。

  • MediaCodec 处理完数据后,将结果输出至一个空的输出缓冲区中。

  • 从 MediaCodec 获取输出缓冲区的数据,消耗掉里面的数据后,释放回编解码器。

具体流程可以参考下图:

2、MediaCodec 生命周期

从上图可以看出MediaCodec 的生命周期包括三种状态:Stopped、Executing、Released。

  • Stopped,分三种子状态:

    • Configured,MediaCodec实例创建后,调用configure方法后就进入了Configured状态

    • Uninitialized,MediaCodec实例被创建后,在调用configure方法前都处于该状态;

    • Error,MediaCodec遇到错误时进入该状态,通常可能是队列操作返回错误或异常导致的;

  • Executing,分三种状态

    • Flushed,在调用start()方法后MediaCodec立即进入Flushed子状态,此时MediaCodec会拥有所有的缓存。可以在Executing状态的任何时候通过调用flush()方法返回到Flushed子状态;

    • Running,一旦第一个输入缓存(input buffer)被移出队列,MediaCodec就转入Running子状态,这种状态占据了MediaCodec的大部分生命周期。通过调用stop()方法转移到Uninitialized状态;

    • End of Stream,将一个带有end-of-stream标记的输入buffer入队列时,MediaCodec将转入End-of-Stream子状态。在这种状态下,MediaCodec不再接收之后的输入buffer,但它仍然产生输出buffer直到end-of-stream标记输出

  • Released,当使用完MediaCodec后,必须调用release()方法释放其资源。调用 release()方法进入最终的Released状态;

编码实现

老规矩,依然需要对MediaCodec进行封装,在封装之前我们需要先设计下对应的接口,而根据上面了解到的MediaCodec生命周期中的几个状态我们需要对其进行记录,方便对外获取,因此接口设计如下:

interface ICodec {
    enum class State{
        IDLE,
        START,
        STOP,
        CLOSE
    }
    fun start()
    fun stop()
    fun close()
    fun getState():State
}

interface IVideoEncoder: ICodec 

这里的close对应的是MediaCodec的release接口。ICodec设计思想是考虑到后续会扩展出不止VideocEncoder还会有Decoder因此只包含了最原始的几个接口设计,针对Encoder和Decoder区别性的接口可以基于ICodec进行扩展继续添加,因为IVideoEncoder暂时没有需要额外添加的接口所以只是单纯的继承自ICodec,后面有需要再添加即可。

接着让我们再编写一个VideoCodec类并实现IVideoEncoder接口,开始我们的编码实现。

kotlin 复制代码
class VideoEncoder(private var params:VideoEncParams = VideoEncParams()):IVideoEncoder {
    private var state = ICodec.State.IDLE
   
    private var enccoder:MediaCodec? = null

    private var inputSurface:Surface? = null

    private var encodeThread:Thread? = null

    private var callback:EncoderCallback? = null
    
    fun setEncoderCallback(callback:EncoderCallback){
        this.callback = callback
    }
    
    interface EncoderCallback{
        fun onCallback(data:ByteArray,frameFlags:Int)
    }

VideoEncoder实现了IVideoEncoder接口并且构造时需要传入VideoEncParams,VideoEncParams我们等下再看,inputSurface是编码输入源,encodeThread用于异步进行编码,callback则是对外提供编码后数据的回调,回调并不仅仅只包含编码后的帧数据,还包含有一个Int类型的frameFlags,这个frameFlags可以理解为编码这一帧的类型,例如SPS帧或者关键帧等,现在我们回过头来看下传入的VideoEncParams:

class VideoEncParams(
    var mime:String = "video/avc",
    var codecWidth:Int = 1920,
    var codecHeight:Int = 1080,
    var bitRate:Int = 2048,
    var fps:Int = 30,
    var keyInterval:Float = 1f,
    var rotation:Int = 90
)

参数貌似有点多,但实际编码过程中的传参根据需要可能会更多,只是我这里罗列的这些参数已经满足我们当前示例的需要了,这些参数的意义等下在配置到编码器的时候我们再详细解释,继续往下。

kotlin 复制代码
    override fun start() {
        if(state == ICodec.State.START) return
        state =  ICodec.State.START
        if(enccoder == null){
            enccoder = MediaCodec.createEncoderByType(params.mime).apply {
                configure(createMediaFormat(),null,null,MediaCodec.CONFIGURE_FLAG_ENCODE)
            }
            inputSurface = enccoder?.createInputSurface()
        }
        enccoder?.start()
        encodeThread = Thread(encodeTask).apply { start() }
    }

这里我们实现了IVideoEncoder的第一个接口函数,也就是我们启动编码器的接口,函数的第一行代码做了一个保护,防止多次调用同一个编码器的start,如果全局变量enccoder为null就通过MediaCodec.createEncoderByType函数进行创建,创建时需要传入一个String类型的参数,这里我们传入的是params中保存的mime,上面我们也看到了这里的mime是"video/avc"。之后调用编码器configure函数进行了初始化配置,通过上面MediaCodec的生命周期可知,MediaCodec在编解码之前必须要先通过configure函数进行配置,才可以正常使用。

而这里的configure需要传入的参数有点多,我们一个一个看:

第一个参数类型是MediaFormat,createMediaFormat()函数的实际作用就是这里对VideoEncParams进行了转换将其转换成了MediaFormat对象。

第二个参数类型为Surface,是设置解码器的显示Surface我们这里用的是编码器,因此直接设置为null。

第三个参数类型为MediaCrypto,主要是与媒体数据加解密有关,我们这边不需要因此也设置为null。

第四个参数类型为int,设置为MediaCodec.CONFIGURE_FLAG_ENCODE配置MediaCodec为编码器。

现在我们来看下createMediaFormat()函数实现。

    private fun  createMediaFormat(): MediaFormat {
       var mediaFormat =  MediaFormat.createVideoFormat(params.mime,params.codecWidth,params.codecHeight)
        //设置比特率
        mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE,params.bitRate)
        //设置帧率
        mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE,params.fps)
        //设置颜色模式
        mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT,MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
        //设置关键帧频率 帧/秒
        mediaFormat.setFloat(MediaFormat.KEY_I_FRAME_INTERVAL,params.keyInterval)
        //设置摄像机旋转角度
        mediaFormat.setInteger(MediaFormat.KEY_ROTATION,params.rotation)
        LogPrint.debug("createMediaFormat mediaFormat:$mediaFormat")
        return mediaFormat
    }

第一行代码是创建MediaFormat对象,MediaFormat可以理解为媒体数据的描述信息,通过createVideoFormat(),意思是创建一个与视频相关的描述信息对象,这里依然传入了mime,之后两个参数依次传入了需要编码视频的宽高。

MediaFormat设置媒体描述信息的方式是以键值对的方式传入的,键是字符串,值可以是int、long、float、String或ByteBuffer。

这里代码都有注释,就不再额外赘述。

让我们继续回到start()函数,当configure配置结束之后,通过enccoder获取到了一个Surface(inputSurface),该Surface可以理解为编码器的输入队列。

当编码器创建就绪之后,就创建了一个encodeThread线程,那么encodeTask的实现逻辑就是编码的关键逻辑了。

kotlin 复制代码
    private var encodeTask:Runnable = Runnable {
        //编码输出信息的对象,赋值由MediaCodec实现
        var bufferInfo = MediaCodec.BufferInfo()

        //当前可用的ByteBuffer索引
        var outputBufferIndex:Int

        var encBuf:ByteArray

        try {
            //死循环获取,也可以对MediaCodec设置callback获取
            while (state == ICodec.State.START) {
                //获取下一个可用的输出缓冲区,如果存在可用,返回值大于等于0,bufferInfo也会被赋值,最大等待时间是100000微秒,其实就是100毫秒
                outputBufferIndex = enccoder?.dequeueOutputBuffer(bufferInfo, 100000)!!

                //如果当前输出缓冲区没有可用的,返回负值,不同值含义不一样,有需要做判定即可
                if (outputBufferIndex < 0) {
                    continue;
                }

                val outputBuffer: ByteBuffer = enccoder?.getOutputBuffer(outputBufferIndex)!!

                //调整数据位置,从offset开始。这样我们一会儿读取就不用传offset偏差值了。
                outputBuffer.position(bufferInfo.offset)
                //改完位置,那肯定要改极限位置吧,不然你数据不就少了数据末尾长度为offset的这一小部分?这两步不做也可以,get的时候传offset也一样
                outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
                encBuf = ByteArray(bufferInfo.size)
                //获取编码数据
                outputBuffer.get(encBuf, 0, bufferInfo.size)

                callback?.onCallback(encBuf, bufferInfo.flags)

                //释放数据,不释放就一直在,MediaCodec数据满了可不行
                enccoder!!.releaseOutputBuffer(outputBufferIndex, false)
            }
        }catch (e:Exception){
            enccoder?.release()
            enccoder = null
            inputSurface?.release()
            inputSurface = null
            Log.e("VideoEncoder","VideoEncoder error:",e)
        }
    }

这块代码稍微有点多,我们来逐行看下,第一行代码是创建了一个BufferInfo对象,该对象主要是用来描述编码数据信息。第二行代码是当前MediaCodec输出buffer的索引位置,不理解的可以再回头看下上面给出的MediaCodec编解码流程图,第三行代码创建了一个ByteArray对象encBuf用于保存MediaCodec编码后的数据。

再往下就是核心的编码代码了,while循环中,通过MediaCodec的dequeueOutputBuffer获取当前可用的编码完成后的Buffer索引,第一个参数是上面我们创建的BufferInfo对象,第二个参数是等待时间,如果获取成功,MediaCodec会将缓存信息保存到BufferInfo对象中,并且返回Buffer的索引。

enccoder?.getOutputBuffer(outputBufferIndex)!!通过index获取输出Buffer。再之后是根据bufferInfo中的描述信息获取正确的缓存数据并保存到我们上面定义的encBuf中。

callback?.onCallback(encBuf, bufferInfo.flags)拿到编码数据之后通过该接口同步出去,这里这个代码可能有点不太合理,因为callback?.onCallback外部使用者如果使用不当可能会影响编码,理论上应该用队列同步出去会更好,这里我们先这样,后续我再进行优化。

while循环最后一行代码就是释放掉当前获取的输出Buffer数据,不释放的话MediaCodec缓存满了可能会出现异常。

再最后如果编码过程中出现异常,就会释放掉当前的编码器,按照生命周期其实通过MediaCodec.reset()也可以,当前这里为了方便,就直接释放了,等下次再start()重新创建即可。

至此编码就已经全部完成了,接下来让我们再加如一些其他的函数,使得VideoCodec更加完善。

kotlin 复制代码
    override fun stop() {
        if(state != ICodec.State.START) return
        state = ICodec.State.STOP
        encodeThread?.interrupt()
        enccoder?.stop()
        encodeThread = null
    }
    
    override fun close() {
        stop()
        state = ICodec.State.CLOSE
        enccoder?.release()
        inputSurface?.release()
    }
    
    override fun getState(): ICodec.State {
        return state
    }

    fun getInputSurface():Surface?{
        return inputSurface
    }

stop()和close()分别是停止解码器和释放解码器,stop()之后通过start()还能继续进行编码但是调用close()之后就不能再调用start(),否则就会报错,需要重新创建VideoEncodec进行编码。getState()没什么好说的,就只是返回当前编码器的状态,getInputSurface()将编码器的输入Surface(理解为队列)返回。

现在我们编码器就已经编写完成了,让我们看看如何跟我们的CameraWrapper进行组合编码Camera画面。让我们继续编写一个新的类CameraEncoder,通过这个类让我们把VideoEncodec和CameraWrapper组合起来,用以实现Camera画面编码为H.264。

kotlin 复制代码
class CameraEncoder :VideoEncoder.EncoderCallback{
    private var cameraWrapper: CameraWrapper
    private var videoEncoder:VideoEncoder

    private var callback:VideoEncoder.EncoderCallback? = null

    constructor(context:Context){
        cameraWrapper = CameraWrapper(context)
        videoEncoder = VideoEncoder()
        videoEncoder.setEncoderCallback(this)
    }

    fun start(surfaceView: SurfaceView){
        videoEncoder.start()
        cameraWrapper.setEncoderSurface(videoEncoder.getInputSurface()!!)
        cameraWrapper.startPreview(surfaceView)
    }

    fun stop(){
        videoEncoder.stop()
        cameraWrapper.stopPreview()
        cameraWrapper.setEncoderSurface(null)
    }

    fun close(){
        cameraWrapper.release()
        videoEncoder.close()
    }

    fun setEncoderCallback(callback:VideoEncoder.EncoderCallback){
        this.callback = callback
    }

    override fun onCallback(data: ByteArray,flags:Int) {
        callback?.onCallback(data,flags)
    }
}

因为代码量不多,而且没有什么难度,所以就直接全部贴出来了,在构造函数中同时创建了VideoCodec和CameraWrapper对象,对外的编码数据并不是通过VideoEncoder的回调直返回的,而是通过CameraEncoder的回调间接返回。

启动时先后启动了videoEncoder编码和cameraWrapper预览,特殊一点就是将videoEncoder中的输入Surface也就是我们上面说的可以理解为输入队列的哪个Surface传递到cameraWrapper这样在cameraWrapper预览时会自动关联起来。

停止时也是一样两个对象依次停止,不过在停止之后设置了cameraWrapper在启动时传入的Surface为空,其实也可以不用置空,但个人感觉这样可能会更好一点。

close()就不多说,与上面描述的start和stop逻辑一致。

至此我们Camera画面采集并编码为H.264就已经完成了。将编码后的数据保存到文件,然后通过VLC即可观看。

总结

Camera2与MediaCodec的结合在Android平台上提供了一种强大的视频处理解决方案。Camera2 API负责高效地从摄像头采集原始视频帧,而MediaCodec API则负责将这些帧实时编码为H.264格式,这是目前最广泛支持的视频编码标准之一。这种组合不仅利用了硬件加速来提高编码性能,减少CPU负担,还确保了视频的高质量输出和良好的兼容性。通过精确控制编码参数,可以根据应用需求调整视频的比特率、帧率和分辨率,实现定制化的视频录制和处理。总的来说,Camera2与MediaCodec的协同工作为开发者提供了一个灵活、高效的工具,用于创建和处理高质量的视频内容。

代码依然比较粗糙,但作为启蒙(用词貌似不当),应该是够了,后续随着博主的继续学习将会继续完善,感谢大家观看。

相关推荐
清风徐来辽27 分钟前
Kotlin学习:1.7.语言基础之空安全
开发语言·kotlin
红米饭配南瓜汤1 小时前
Android显示系统(04)- OpenGL ES - Shader绘制三角形
android·音视频·媒体
码农老张Zy1 小时前
【PHP小课堂】学习PHP中的变量处理相关操作
android·开发语言·学习·php
尹中文1 小时前
Android ConstraintLayout 约束布局的使用手册
android
苗壮.1 小时前
Android 俩个主题的不同之处 “Theme.AppCompat vs android:Theme.Material.Light.NoActionBar”
android·gitee·appcompat
努力进修2 小时前
【Java-数据结构篇】Java 中栈和队列:构建程序逻辑的关键数据结构基石
android·java·数据结构
闲暇部落4 小时前
OpenGL ES详解——文字渲染
android·freetype·文字渲染·位图字体
画个太阳作晴天9 小时前
Android10 设备死机的问题分析和解决
android·framework·anr
SHUIPING_YANG11 小时前
Typora设置自动上传图片到图床
android
Ai 编码助手11 小时前
php多进程那点事,用 swoole 如何去解决呢
android·php·swoole