Android H.264解码实现

本篇既Camera2编码为H.264这一应用的最后一篇,本篇我们将通过MediaCodec进行H.264解码并显示,通过上一篇我们已经对MediaCodec有一定的了解,所以本篇相对来说实现上会简单一些,如果你对MediaCodec的记忆已经有点模糊了或者有需要对MediaCodec基础知识或者编码进行了解的同学,可以通过上一篇Android Camera2采集并编码为H.264进行了解。

接下来我们就直接来看如何进行实现H.264解码。

新的编码参数封装类

不知道大家还记不记得上一篇中的VideoEncParams类,这个类是我们用来统一封装和传递编码参数的,但是实际编码过程中编码参数会非常多,而我们只封装了一部分,如果需要进行扩展就需要对这个类进行修改,这样不太合理,因此本篇我们就换一种提供编码参数的封装方式,对MediaFromat进行代理,创建一个新的类VideoFormat。

可能有同学会问为什么不直接用MediaFromat,上一篇中对MediaFromat的使用也有描述,MediaFromat设置参数是通过键值对的方式进行配置的,个人原因吧,觉得这样虽然很通用,但不是很直观,使用上有点麻烦,因此这里做了一个代理,对不同的键进行函数封装,这样使用起来就会直观很多。

下面让我们来看这个类的实现:

kotlin 复制代码
import android.media.MediaFormat


class VideoFormat(private val format:MediaFormat = MediaFormat.createVideoFormat("video/avc", 1920, 1080)){

    init {
        setFrameRate(30)
        format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1) //低延时解码
    }

    fun setMimeType(mime:String){
        format.setString(MediaFormat.KEY_MIME, mime)
    }

    fun getMimeType():String?{
        return if(format.containsKey(MediaFormat.KEY_MIME)) format.getString(MediaFormat.KEY_MIME)
               else ""
    }

    fun setWidth(width:Int){
        format.setInteger(MediaFormat.KEY_WIDTH, width)
    }

    fun getWidth():Int{
        return if(format.containsKey(MediaFormat.KEY_WIDTH)) format.getInteger(MediaFormat.KEY_WIDTH)
               else 0
    }

    fun setHeight(height:Int){
        format.setInteger(MediaFormat.KEY_HEIGHT, height)
    }

    fun getHeight():Int{
        return if(format.containsKey(MediaFormat.KEY_HEIGHT)) format.getInteger(MediaFormat.KEY_HEIGHT)
               else 0
    }

    fun setFrameRate(rate:Int){
        format.setInteger(MediaFormat.KEY_FRAME_RATE, 30)
    }

    fun getFrameRate():Int{
        return if(format.containsKey(MediaFormat.KEY_FRAME_RATE)) return format.getInteger(MediaFormat.KEY_FRAME_RATE)
               else 0
    }

    fun getMediaFormat():MediaFormat{
        return format
    }
}

这里主要是对解码需要的视频mime、宽度、高度以及帧率进行了封装,如果后续再继续添加额外的参数,在不更新版本的情况下,可以通过传入的MediaFormat进行传参即可,可能跟我们封装的理念有所违背,但是不失为一种灵活的变通办法。

通过队列传递解码数据

不知道大家还记不记得上一篇中编码器原始数据都是Camera通过Surface直接传递给MediaCodec,解码器与编码器不同的地方是解码器需要我们主动传递需要解码的数据给到MediaCodec,我们传递一帧数据给到解码器,不传递它就不解码。但是如果我们像编码数据反馈那样通过接口或者回调的形式直接传递给解码器的话,接收者的执行耗时势必会影响到传递者,因此本篇我们对这块做一个改进,我们给解码器传递数据时通过队列的方式传递,这样尽可能的减少传递数据时所造成的相互影响。

那么这里我们再加一个类Channel:

kotlin 复制代码
class Channel<T>(var name:String = "FrameChannel"+System.currentTimeMillis(), var cacheFrameCount:Int = Integer.MAX_VALUE){
    private var frames = ArrayList<T>()
    private var lock:Object = Object()

    fun add(fd:T):Boolean{
        synchronized(lock){
            with(frames){
                add(fd)
                if(cacheFrameCount<frames.size) {
                    removeAt(0)
                }
            }
            if(frames.size == 1) lock.notifyAll()
            return true
        }
    }

    fun remove(fd:T):Boolean{
        synchronized(lock){
            if(frames.isEmpty()) return false
            return frames.remove(fd)
        }
    }

    fun poll():T{
        synchronized(lock){
            if(frames.isEmpty()){
                Log.w(name,"FrameChannel frames is empty , wait...")
                try{
                    while (frames.isEmpty()) {
                        lock.wait()
                    }
                }catch (e:Exception){
                    Log.w(name,"FrameChannel poll notify")
                }
            }
            return frames.removeAt(0)
        }
    }

    fun clear(){
        Log.w(name,"FrameChannel clear...")
        synchronized(lock){
            frames.clear()
        }
    }

    fun size():Int{
        synchronized(lock) {
            return frames.size
        }
    }

    fun isEmpty():Boolean{
        synchronized(lock) {
            return frames.isEmpty()
        }
    }
}

这是一个基于ArrayList实现的阻塞队列,当队列为空的时候poll获取数据时会进入阻塞状态,所以poll数据尽量放到异步线程中,如果添加数据时队列为空会尝试唤醒poll中的阻塞。

准备工作做的差不多了,那么接下来让我们开始解码部分的代码实现。

解码实现

与编码器相同,我们需要先设计解码器的接口,这个接口命令为IVideoDecoder并继承ICodec接口。

kotlin 复制代码
interface IVideoDecoder: ICodec {

    fun setDisplay(surface: Surface)

    fun getFrameChannel():Channel<FrameData>

}

解码器新增了两个接口setDisplay接口用于设置显示窗口,也就是参数Surface,这里参数没有固定为SurfaceView或TextureView主要是站在使用者的角度,没有将View限制死,根据使用的场景可以自行选择。

getFrameChannel接口主要是获取队列,进行解码数据传递,没难度,就不解释了。

接下来让我们创建一个VideoDecoder类用来实现我们的视频解码。

kotlin 复制代码
class VideoDecoder(private var id:Int = 0, private var decFormat: VideoFormat): IVideoDecoder,Runnable {

    private val channel = Channel<FrameData>("Chanel_VideoDecoder$id")

    private var surface:Surface? = null

    private var state = IDLE

    private var mediaCodec: MediaCodec? = null

    private lateinit var mBackgroundThread:Thread

    private var frameDelay = 33

VideoDecoder的构造函数需要传入两个参数,一个是解码器的ID,考虑到实际使用过程中可能会存在多个解码器同时使用的场景,因此用这个ID可以区分对应的解码器。第二个参数就是我们前面创建的VideoFormat类。

成员变量基本上与编码器差不多,无非就是多了一个channel、surface以及frameDelay,前面两个就不多说了,这里的frameDelay我简单说下,这个是每一帧的解码时间,计算公式是1000/帧率,主要是为了控制匀速的解码,与实际帧率做一个对应。

让我们继续完善这个类:

kotlin 复制代码
    override fun setDisplay(surface: Surface) {
        if(!surface.isValid){
            LogPrint.warn("### VideoDecoder $id surface is not valid ###")
            return
        }
        this.surface = surface
    }

    override fun getFrameChannel(): Channel<FrameData> {
        return channel
    }

这里首先实现了解码器额外添加的两个接口,两个实现都很简单,setDisplay中对传入的surface做了一个简单的校验,如果如果解码器不执行的话,可以观察日志看下是否有这里的打印。

    override fun start() {
        when(state){
            STOP,
            IDLE  -> {
                state = START
                initCodec()
                startBackgroundThread()
            }
            START -> LogPrint.warn("### VideoDecoder $id already start ###")
            CLOSE -> LogPrint.warn("### VideoDecoder $id already close ###")
        }
    }

这里我们实现了解码器的start接口,只有在IDLE和STOP状态的时候才能触发解码,其他状态时直接给出警告。启动解码这里执行了两个函数initCodec和startBackgroundThread。通过名字应该都不难看出两个函数的作用,我们先来看下initCodec是如何进行解码器初始化的。

kotlin 复制代码
    private fun initCodec(){
        if(mediaCodec!=null) return
        var format = decFormat.getMediaFormat()
        var codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
        var codecName = codecList.findDecoderForFormat(format)
        frameDelay = 1000/decFormat.getFrameRate()
        mediaCodec = MediaCodec.createByCodecName(codecName).apply {
            if (surface!=null){
                configure(format,surface,null,0)
            }
        }
    }

通过MediaCodecList可以获取设备支持的编解码器列表,MediaCodecList.REGULAR_CODECS指的是获取常规的编解码器列表,通过MediaCodecList.ALL_CODECS可以获取该设备支持的所有编解码器列表。

然后通过编解码器列表传入我们自建的MediaFormat获取与之匹配的解码器名称,之后就是与编码器类似的MediaCodec的创建流程了,这里就不再过多赘述。

kotlin 复制代码
    private fun startBackgroundThread() {
        mBackgroundThread = Thread(this)
        mBackgroundThread.name = "VideoDecoder-$id"
        mBackgroundThread.start()
    }

startBackgroundThread函数就只是启动了一个线程,我们继续看下run函数。

kotlin 复制代码
    override fun run() {
        android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO)
        if(mediaCodec==null) {
            println("###  mediaCodec == null  stopping  ###");
            return
        }
        try{
            mediaCodec?.start()
            while(state == START){
                decode()
            }
        }catch (e:Exception){
            Log.e("VideoDecoder","VideoDecoder$id Exception:",e)
            mediaCodec?.release()
            mediaCodec = null
        }
    }

这段代码应该大家看着也不难吧,如果有看过上一篇熟悉编码,再来看这里应该还是很好理解吧,都是同一个套路,我简单说下这个实现流程,首先设置了线程的优先级,然后做了一个校验如果mediaCodec为null就不继续执行了,主要是防止MediaCodec初始化失败。之后在线程运行过程中启动了MediaCodec解码,循环解码逻辑在decode函数,如果解码出现异常,就将当前的mediaCodec释放掉,根据MediaCodec生命周期其实调用reset函数也可以,不过这里我们为了方便就直接释放了,如果需要再次进行解码重新执行start会再次新建MediaCodec。

接下来看下decode实现。

kotlin 复制代码
    private fun decode(){
        var beginTime = System.currentTimeMillis()
        if(channel.isEmpty()){
            return
        }
        var inputIndex = mediaCodec!!.dequeueInputBuffer(-1)
        var frameData = channel.poll()
        var data = frameData.data
        if (inputIndex >= 0) {
            var sampSize: Int = data?.size!!
            var byteBuffer = mediaCodec!!.getInputBuffer(inputIndex)
            byteBuffer?.put(data)
            mediaCodec?.queueInputBuffer(inputIndex, 0, sampSize, 0, frameData.flag)//**
        }

        var bufferInfo = MediaCodec.BufferInfo()
        var outIndex = mediaCodec!!.dequeueOutputBuffer(bufferInfo, 100000)
        if (outIndex >= 0) {
            mediaCodec?.releaseOutputBuffer(outIndex, true)
        }

        var now = System.currentTimeMillis()
        var cTime = now - beginTime
        if (cTime < frameDelay) {
            val sleepTime = frameDelay - cTime
            Thread.sleep(sleepTime)
        }
    }

第一行记录的解码开始时间,主要是后续计算整个解码过程中的消耗时间。

mediaCodec!!.dequeueInputBuffer(-1) 获取MediaCodec中当前可用的输入Buffer的索引。

有空闲Buffer的话inputIndex必定不小于0,接着通过channel拿到需要解码的一帧数据,将获取到的这一帧数据保存到mediaCodec拿到的输入缓存区中,再通过mediaCodec?.queueInputBuffer告知mediaCodec可以进行这一帧数据处理了,这里特别说下,该函数最后一个参数就是我们编码传递过来的flag,可以理解为帧类型。

接着通过 mediaCodec!!.dequeueOutputBuffer(bufferInfo, 100000)拿到解码之后输出的缓存数据,这个函数会阻塞运行,阻塞时间就是第二个传入的参数,单位是微秒,传入-1的话会无限等待,这里的数据我们不需要额外处理因此直接释放即可。

最后我们计算了解码这一坨所消耗的时间,如果小于每一帧的时间的话,需要等待差值的时间再进行解码,保证帧率与解码信息中设置的帧率一致。

至此解码器核心部分的代码已经全部添加完成,那么让我们直接实现剩下的两个接口。

kotlin 复制代码
    override fun stop() {
        if(!isRunning()) return
        state = STOP
        stopBackgroundThread()
        mediaCodec?.stop()
        channel.clear()
    }

    override fun close() {
        stop()
        state = CLOSE
        mediaCodec?.release()
    }

停止时这里额外将帧缓存队列清空了,主要是为了防止有脏帧影响到后续解码。其他的应该也没什么好说的,都挺简单的,与编码器一样,close之后就不能再使用视频解码器了。

使用VideoDecoder

下来让我们看下如何使用:

java 复制代码
VideoDecoder videoDecoder = new VideoDecoder(1,new VideoFormat());
videoDecoder.setDisplay(decoderView.getHolder().getSurface());
videoDecoder.start();

videoDecoder.getFrameChannel().add(new FrameData(data,frameFlags,false));

简单使用的话就这四行代码,因为VideoFormat预置数据与之前编码的一致所以我就没有进行额外配置。

第二行设置显示Surface,一定要在调用start前设置,不然启动解码器时会出错,因为我们在启动解码器的时候进行了Surface校验,如果为空就不会对解码器进行配置了,按照MediaCodec生命周期,在编解码之前必须要先对其进行配置,所以就会出现异常。

给解码器传递数据,这里写的简单,大概就是这个样子,应该可以表达清楚传递数据的方式,至于数据哪里来的,可能跟每个使用场景有关。

至此我们解码器的编码就已经全部完成,代码依然很粗糙,但是相信如果我们继续的话,一定会将其打磨的更加优秀,那么最后感谢大家观看。

相关推荐
花追雨4 小时前
Android -- 双屏异显之方法一
android·双屏异显
小趴菜82274 小时前
安卓 自定义矢量图片控件 - 支持属性修改矢量图路径颜色
android
氤氲息4 小时前
Android v4和v7冲突
android
KdanMin4 小时前
高通Android 12 Launcher应用名称太长显示完整
android
chenjk44 小时前
Android不可擦除分区写文件恢复出厂设置,无法读写问题
android
袁震4 小时前
Android-Glide缓存机制
android·缓存·移动开发·glide
工程师老罗4 小时前
Android笔试面试题AI答之SQLite(2)
android·jvm·sqlite
User_undefined6 小时前
uniapp Native.js 调用安卓arr原生service
android·javascript·uni-app
安小牛6 小时前
android anr 处理
android
刘争Stanley8 小时前
如何高效调试复杂布局?Layout Inspector 的 Toggle Deep Inspect 完全解析
android·kotlin·android 15·黑屏闪屏白屏