前言
本篇博文主要讲述的是基于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接口,开始我们的编码实现。
class VideoEncoder(private var params:VideoEncParams = VideoEncParams()):IVideoEncoder {private var state = ICodec.State.IDLEprivate var enccoder:MediaCodec? = nullprivate var inputSurface:Surface? = nullprivate var encodeThread:Thread? = nullprivate var callback:EncoderCallback? = nullfun 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
)
参数貌似有点多,但实际编码过程中的传参根据需要可能会更多,只是我这里罗列的这些参数已经满足我们当前示例的需要了,这些参数的意义等下在配置到编码器的时候我们再详细解释,继续往下。
override fun start() {if(state == ICodec.State.START) returnstate = ICodec.State.STARTif(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的实现逻辑就是编码的关键逻辑了。
private var encodeTask:Runnable = Runnable {//编码输出信息的对象,赋值由MediaCodec实现var bufferInfo = MediaCodec.BufferInfo()//当前可用的ByteBuffer索引var outputBufferIndex:Intvar encBuf:ByteArraytry {//死循环获取,也可以对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 = nullinputSurface?.release()inputSurface = nullLog.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更加完善。
override fun stop() {if(state != ICodec.State.START) returnstate = ICodec.State.STOPencodeThread?.interrupt()enccoder?.stop()encodeThread = null}override fun close() {stop()state = ICodec.State.CLOSEenccoder?.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。
class CameraEncoder :VideoEncoder.EncoderCallback{private var cameraWrapper: CameraWrapperprivate var videoEncoder:VideoEncoderprivate var callback:VideoEncoder.EncoderCallback? = nullconstructor(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的协同工作为开发者提供了一个灵活、高效的工具,用于创建和处理高质量的视频内容。
代码依然比较粗糙,但作为启蒙(用词貌似不当),应该是够了,后续随着博主的继续学习将会继续完善,感谢大家观看。