背景

之前写了一篇关于如何自定义APngDrawable的文章,当时通过提交任务到线程池来解码apng 文件。其中帧播放的逻辑控制也过于复杂,需要不断的计算帧延时刷新。并且APngDrawable在播放apng文件的过程中,解码线程会经常的发生挂起。为了充分的利用线程,避免挂起线程,并且简化帧播放逻辑。所以我们考虑使用协程来解决这些问题。

协程

协程可以挂起执行,这里的挂起执行与线程的挂起不同。它没有阻塞线程,而是记录当前执行的位置。当异步执行结束后从记录的执行位置继续执行,挂起前后的执行线程有可能不同。利用协程的非阻塞特性可以有效优化apng文件的解码过程。

协程在解码过程中的使用

启动播放apng的过程就是启动协程任务的过程。协程的协程体中进行循环播放控制,帧解码控制,帧渲染控制。下面看下具体的代码:

playJob = launch(Dispatchers.IO) {

            /**
             * for decode the apng file.
             */
            var aPngDecoder: APngDecoder? = null
            frameBuffer = FrameBuffer(columns, rows)
            try {
                // send start event.
                sendEvent(PlayEvent.START)
                //Loop playback.
                repeat(plays) { playCounts ->
                    log { "play start play count : $playCounts" }
                    if (playCounts > 0) {
                        //send repeat event.
                        sendEvent(PlayEvent.REPEAT)
                    }
                    //init apng decoder and frame buffer.
                    if (aPngDecoder == null) {
                        aPngDecoder = APngDecoder(streamCreator.invoke())
                        frameBuffer!!.reset()
                    }
                    aPngDecoder?.let { decoder ->
                        log { "decode start decoder ${decoder.hashCode()} skipFrameCount $skipFrameCount" }
                        //seek to the last played frame.
                        repeat(skipFrameCount) {
                            decoder.advance(frameBuffer!!.bgFrameData)
                        }
                        //decode the left frames
                        repeat(frames - skipFrameCount) {
                            var time = System.currentTimeMillis()
                            decoder.advance(frameBuffer!!.bgFrameData)
                            time = System.currentTimeMillis() - time
                            //compute the delay time. We need to minus the decode time.
                            val delay = frameBuffer!!.fgFrameData.delay - time
                            skipFrameCount = frameBuffer!!.bgFrameData.index + 1
                            logFrame { "decode frame index ${frameBuffer!!.bgFrameData.index} skipFrameCount $skipFrameCount time $time delay $delay" }
                            delay(delay)
                            //swap the frame between fg frame and bg frame.
                            frameBuffer?.swap()
                            //send frame event.
                            sendEvent(PlayEvent.FRAME)
                        }
                        //close the apng decoder.
                        decoder.close()
                        skipFrameCount = 0
                        aPngDecoder = null
                        log { "decode end release decoder ${decoder.hashCode()}" }
                    }
                    log { "play end play count : $playCounts" }
                }
                //play end, reset the start state for next time to restart again.
                isStarted = false
                sendEvent(PlayEvent.END)
            } catch (e: Exception) {
                log { "launch  Exception ${e.message}" }
                //send cancel event.
                sendEvent(PlayEvent.CANCELED)
            } finally {
                log { "release decoder and frameBuffer in finally" }
                aPngDecoder?.close()
                lastFrameData?.release()
                lastFrameData = frameBuffer?.cloneFgBuffer()
                frameBuffer?.release()
            }
        }

这里应用到了协程的repeat方法控制循环播放和循环解码frame,同时配合协程的delay方法控制帧的渲染时间。通过协程改造后的逻辑简单清晰,更加容易理解。
渲染的delay时间需要考虑到解码frame的时间,这里的delay时间是将解码时间排除掉后的时间。通过下面的图可以方便理解:

图片反映的是一帧的解码和渲染过程,由于draw frame的速度很快,所以它的执行时间忽略不计。所以draw frame的开始点也是下一帧解码的开始点。每一帧都是按照这样的逻辑反复执行。
由于解码的协程执行在IO Dispatcher中,而渲染帧是在UI 线程,所以这里需要考虑多线程协同的问题。也就是说draw frame执行在main ui线程。描画时使用的帧数据和解码的帧数据需要保证不是同一个数据。为了解决这个问题,我们定义了一个FrameBuffer用于控制解码与渲染,让他们可以协调工作。

FrameBuffer的使用

下面是FrameBuffer的完整代码,代码还是比较简单的。它通过定义前台frame和后台frame来达到解码与渲染的协同工作。前台frame只用于渲染图像,后台frame只用于解码使用。这样他们两个就各自工作而相互不影响。当后台frame解码完成并且delay时间已经到时,程序会通过调用swap方法切换前后台frame。

internal class FrameBuffer(w: Int, h: Int) {
    var prFrameData: FrameData = FrameData(Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888))
    var fgFrameData: FrameData = FrameData(Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888))
    var bgFrameData: FrameData = FrameData(Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888))

    fun swap() {
        val temp = prFrameData
        prFrameData = fgFrameData
        fgFrameData = bgFrameData
        bgFrameData = temp
    }

    fun reset() {
        fgFrameData.reset()
        prFrameData.reset()
        bgFrameData.reset()
    }

    fun release() {
        fgFrameData.release()
        prFrameData.release()
        bgFrameData.release()
    }

    fun cloneFgBuffer() = FrameData(Bitmap.createBitmap(fgFrameData.bitmap))
}

如何共享APng播放

有的时候我们需要在同一个画面下播放多个同一个APng 文件,如果为每个播放都创建一个解码用的APngHolder,那么内存使用就会增加。我们可以通过共享APngHolder的方式来解决这个问题。在库中我们定义了一个APngHolderPool用于管理共享的APngHolder。下面是这个类的代码:

class APngHolderPool(private val lifecycle: Lifecycle) : LifecycleObserver {
    private val holders = mutableMapOf<String, APngHolder>()

    init {
        lifecycle.addObserver(this)
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_START)
    fun onStart() {
        holders.forEach {
            it.value.resume(true)
        }
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
    fun onStop() {
        holders.forEach {
            it.value.pause(true)
        }
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
    fun onDestroy() {
        holders.clear()
        lifecycle.removeObserver(this)
    }

    internal fun require(scope: CoroutineScope, file: String, streamCreator: () -> InputStream) =
        holders[file] ?: APngHolder(file, true, scope, streamCreator)
            .apply {
                holders[file] = this
                if (lifecycle.currentState >= Lifecycle.State.STARTED) {
                    resume(true)
                }
            }
}

通过代码我们也能发现通过APngHolderPool管理的APngHolder的播放停止等动作只与lifecycle绑定,共享的APngHolder不会因为APngDrawable的隐藏和销毁而停止播放并释放。所以大家在使用共享的APngHolder的时候要考虑是否真正需要它。下面的代码展示了如何使用APngHolderPool。

val sharedAPngHolderPool = APngHolderPool()
    fun onClickView(view: View) {
        when (view.id) {
            R.id.image1 -> {
                imageView.playAPngAsset(this, "google.png", sharedHolders = sharedAPngHolderPool)
            }
            R.id.image2 -> {
                imageView.playAPngAsset(this, "blued.png")
            }
            R.id.imageView -> (imageView.drawable as? APngDrawable)?.let {
                if (it.isRunning) {
                    it.stop()
                } else {
                    it.start()
                }
            }
        }
    }

总结

经过协程改造过的解码过程和渲染过程更加简洁清晰了,也达到了最初的改造目的。并且通过kotlin的扩展支持,使得播放APng的调用也更加简单。下面我分享了整个的代码,其中也包括了改造前的代码。大家可以对照下,相信协程实现的优点显而易见。

Git

大家可以通过下面的git地址下载到完整的代码。
https://github.com/mjlong123123/PlayAPng/releases/tag/1.0.1


mjlong123
4 声望3 粉丝