最近发现之前写的翻页器组件用起来好像有些卡顿,就去想着做一下功能优化,这一篇博客便是PTQBookPageView从v1.0.1版别到v1.1.0版别优化的考虑和分析,以及优化的进程记载。

组件相关链接:

  • 组件介绍页
  • 规划思路
  • Github
  • 源码分享
    • 算法部分
    • 制作和动画部分(包括文字歪曲、点击动画、阴影等)

1 点评目标&优化效果

要做功能优化,首先得知道功能怎样度量、怎样表示。由于功能是一个很笼统的词,咱们有必要把它量化、可视化。那么,由于是UI组件优化,我首先选用了GPU呈现形式分析这一东西。

在手机上的开发者形式里能够敞开GPU呈现(烘托)形式分析这一东西,有的体系也把它叫hwui什么什么的,自己找一下,敞开后,屏幕上会展现一个直方图,直观来看便是有许多竖条,每一个竖条代表一个烘托帧,这些竖条的高度代表烘托的每个阶段烘托前一帧所用的相对时刻。屏幕底端会有根绿线,代表的便是16.6ms,而一帧的烘托时刻超出这个绿线,就有可能产生所谓的掉帧。再说简略点,每个竖条越低越好

这些信息大致能够告知咱们:

  • 1、当前烘托一帧的时刻是否在合理的规模内。
  • 2、烘托的每个阶段的耗时,然后了解应该优化哪些方面来进步应用的烘托功能。

关于这个东西就简略介绍到这儿,更详细的内容能够自己看官方文档(官方文档相同也给出了对应色彩竖条的排查问题的思路)

那么,咱们回到翻页组件。在之前发布的v1.0.1版别中,制作流程是先核算所有点,构建Path,然后老老实实的一个图层一个图层制作(例如,先制作最底层的下一页内容,再制作翻起来的这一页内容,再制作阴影,再制作页面光泽等等...),现在咱们翻开东西,看一下,这一看几乎吓了一跳,如左图所示。最底下的绿线是16.6ms,可是,一翻页之后,满屏的竖条呈现了,这高度也太吓人了,远超合理的规模,每一帧都花费了许多时刻,相当于严峻掉帧了。这下,不得不开端优化了。

那么,通过这一周的考虑和优化,最终的成果便是右图了,尽管还有好几个能够优化的大点,可是我懒得写了,就先优化到这儿吧,定为v1.1.0版别。

Android复杂UI的性能优化实践 - PTQBookPageView 性能优化记录
Android复杂UI的性能优化实践 - PTQBookPageView 性能优化记录

接下来讲一下优化的思路和进程供咱们参阅,假如有不足之处也欢迎评论和批评指正。

2 考虑

那么现在知道了功能问题很糟糕,想要优化,首先得搞清楚能优化哪些方面,然后是怎样去优化,下面是一些考虑的视点。

注:本节仅仅是考虑的进程,详细的完成细节计划之类的在后边评论,乃至,可能有一些本节列出的优化视点经验证后会是不行完成或完成成本巨大的,最终抛弃了。

2.1 Compose

这个UI组件首先是一个Jetpack Compose编写的组件,那么Compose的部分是否存在能优化的大项?

1. 是否存在过度重组

关于Compose,首先能想到的一点便是重组次数是否合理,即,是否存在过度重组、不必要的重组的状况。其实在之前的第一版代码完成时,我就考虑了这一点,运用官方的Layout Inspector(默许在Android Studio的右下角),能够去检查每个@Composable重组的状况,确认他们是否合理,所幸,并没有产生过度重组的状况。这个优化点直接越过。

Android复杂UI的性能优化实践 - PTQBookPageView 性能优化记录

关于LayoutInspector的详细运用,能够自己检查文档。

关于怎么防止过度重组,或许能够从我之前写的这一篇博客里取得一些协助。

2. 下降重组频率、进步重组速度

现在的现实是:由于手指每次哪怕移动一丁点,都会触发手势监听(从log看我的小破测试机大概是10-12ms会触发一次onDrag回调),从而触发重组,然后从头进行屏幕上所有点的核算(而这个核算是很耗时的),核算完后,再依据核算成果调用canvas API进行制作。

依据上面的这个现实流程,简略考虑过后,便能发现几个或许能够优化的视点:

  • 耗时核算能否放到子线程?那么耗时核算放到子线程核算完后,怎么把成果给回Compose触发UI更新?假如这个想法能够完成,那么就能够进步重组的速度。
  • onDrag的回调触发是否过于频频了?或许没必要这么频频地触发手势监听,也便是说,并非每次onDrag都去触发耗时核算,而是有一个频率上的下降,保证UI看起来还是连接的即可。这样能够直接下降重组频率。

3、去掉重组中的多余代码

这一点是很trick的一个点,例如,假如你在一个可能会频频重组的@Composable块中输出了Log,乃至多条Log,一方面的确会影响重组的功能,另一方面,假如这些Log中含有State变量,乃至可能会导致不必要的重组产生。

因而,假如非要想在@Composable块中用Log调试,请在完成编码后把这些Log都删掉或许注释掉。

关于Compose部分,能优化的点我暂时就想到这些。

2.2 Bitmap

下一个方向是Bitmap方向,由于整个翻页组件,不论是算法(例如歪曲算法、曲线边际算法)还是Compose侧完成都与Bitmap有关,那么必定得好好盘一盘这个Bitmap相关的部分。

2.2.1 组件中有关Bitmap的部分

这儿先简略提一下组件完成中触及到Bitmap的部分,防止不知道后边的优化部分在说什么。

首先,为什么会用到Bitmap?由于有一个需求是要完成类似纸张翻起来的一个文字歪曲效果,如下图右侧所示。

Android复杂UI的性能优化实践 - PTQBookPageView 性能优化记录

这个歪曲的完成思路用到了Canvas的drawBitmapMesh办法,也便是说,组件要把恣意自定义的@Composable内容进行“截屏”操作,制作成一张Bitmap,然后对这张Bitmap调用drawBitmapMesh进行歪曲(已然是“截屏”操作,这也就解说了为什么组件支持显现恣意的非动态的内容)

在每次页面内容产生改变(例如当前页面内容有改变、或许翻页了)时,就要去重绘前一页、当前页、后一页这三张Bitmap,且这三张Bitmap都是相同巨细的,与组件巨细相同大。

关于把@Composable的内容截屏的完成能够自己去看源码,相关的文件是PTQBookPageView.kt以及PTQBookPageBitmapController.kt。

组件中触及Bitmap的部分就先提到这儿,接下来回到咱们的优化思路中。

1、Bitmap的Config

那么,首先想到的是与项目无关的,Bitmap本身的优化,例如一些常见的思路:

  • 加载图片时,对图片进行下采样,削减加载的耗费,削减内存占用,且进步加载速度(这个思路我没有去管,由于组件完成中,Bitmap都是由截屏操作生成的,便是屏幕巨细,但其实感觉可能能够针对不同尺寸的屏幕去做适配?由于假如屏幕很高清,生成的图片也会很大,但实践上或许不需求那么大。而由于我没有测试设备(我的设备是一个720*1600的低端机),所以这一块暂时没去管。)
  • 已然是翻页组件,那页的纸张必定是不透明的,已然如此,在截屏生成Bitmap时,咱们就不需求有透明度的Bitmap了,也便是Bitmap其实能够采用RGB565格式,而不用ARGB8888,这样,内存占用直接减小了一半,后续对Bitmap的操作速度也会快些。
  • 关于下采样,类似地,在drawBitmapMesh时,会需求设置mesh的格点数,相同,削减这个格点数也会导致功能进步。

2、Bitmap的复用

已然组件后续制作需求触及到的Bitmap的数量是固定的,就只有3张(前一页、当前页、后一页),而且,实践的绝大部分场景下,翻页组件的巨细都是固定的,不会轻易改变,那么就能够想到这3张Bitmap其实能够复用,也便是制作新的Bitmap时把新的像素直接覆盖在本来Bitmap分配的内存上,这样就不用每次翻页或许refresh页面时都先recycle再从头create,只需组件巨细不产生改变,就能够防止多余的内存收回和再分配。

此外,已然提到了复用,相同也能联想到一些其他的可复用的大目标,例如制作时用到的Canvas和Path等,由于它们的创建收回也是在native的,这样能够削减创建和收回带来的耗费。

2.3 制作

作为一个UI组件,另一个考虑方向便是UI组件的一些常见优化点。

1、布局是否嵌套过深

假如组件的布局嵌套太深,必定影响功能,但所幸这个组件并没有这个状况(PTQBookPageViewInner中也只有一个Box和Canvas),所以这个优化点直接越过。

Android复杂UI的性能优化实践 - PTQBookPageView 性能优化记录

2、是否存在过度制作

过度制作便是指,由于代码编写不当,导致同一个像素点被反复更新。咱们只能看见最上面的图层,因而能够去考虑是否存在很多的被掩盖的区域,已然这些区域是不行见的,那它们本身就不应该被制作。

这儿相同有一个东西,体系自带的,叫调试GPU过度制作,在开发者选项中翻开这个东西,它就会在屏幕上显现咱们过度制作的区域。

Android复杂UI的性能优化实践 - PTQBookPageView 性能优化记录

现在让咱们看看优化前的区域(下左图),一片红,那么基本上能够确认了,这也是一个可优化的大点。

在实践开发时,有一些过度制作是无法防止的,因而咱们要做的便是尽可能地削减过度制作,在考虑优化计划过后,做到了下右图的效果,削减了一些过度制作,进步了功能。

Android复杂UI的性能优化实践 - PTQBookPageView 性能优化记录
Android复杂UI的性能优化实践 - PTQBookPageView 性能优化记录

3、耗时的制作

一些Canvas和Path的API可能会相对来说比较耗时,咱们应该尽可能削减此类API的调用,此外,这篇文章中也给出了一些关于制作的优化思路,例如Path太大等等。

目前我能想到的可优化的点和思路便是这些,下面开端完成。

3 完成

一些细节就不提了,例如什么Path的复用之类的,本节就讲一些首要的部分。

3.1 BitmapController优化

这个部分首要的改动是Bitmap的复用,以及Bitmap的create流程(这个是代码上的优化,不触及功能)

在PTQBookPageBitmapController内,运用一个巨细为3的数组作为Bitmap的复用池。

private val bitmapBuffer = arrayOfNulls<Bitmap?>(3)

在AbstractComposeView重写的dispatchDraw中调用controller的renderAndSave,而renderAndSave会供给一个Canvas,这个Canvas现已把Bitmap准备好了,假如能够制作,则由super.dispatchDraw制作。

override fun dispatchDraw(canvas: Canvas?) {
    controller.renderThenSave(width, height) {
        super.dispatchDraw(it)
    }
}

看看renderThenSave的完成。

fun renderThenSave(width: Int, height: Int, render: (drawable: Canvas) -> Unit) {
    //假如不再需求bitmap,则不再制作了
    if (needBitmapPages.isEmpty() || width <= 0 || height <= 0) {
        return
    }
    //当前需求制作第几页的
    val first = needBitmapPages.first()
    //这儿判别是否需求从头创建Bitmap而不是从复用池去取
    var needNew = false
    if (bitmapBuffer[first.second] == null) {
        needNew = true
    } else {
        //新的巨细产生改变(由于config不变,所以bitmap的巨细能够认为只受width, height影响,而不再去核算allocationByteCount)
        bitmapBuffer[first.second]!!.let {
            if (width != it.width || height != it.height) {
                it.recycle()
                needNew = true
            }
        }
    }
    //假如需求新创建,则创建一个RGB565格式的Bitmap
    if (needNew) {
        bitmapBuffer[first.second] = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
    }
    canvas.let {
        //Canvas设置Bitmap以在dispatchDraw时把内容制作到Bitmap上
        it.setBitmap(bitmapBuffer[first.second]!!)
        //一切准备就绪后,才会回调render,让dispatchDraw给Bitmap填充内容
        render(it)
        //记住清掉引证
        it.setBitmap(null)
    }
    //假如还需求制作下一张,则继续,不然流程停止
    needBitmapPages.removeFirst()
    if (needBitmapPages.isEmpty()) return
    exeRecompositionBlock?.let { it() }
}

Bitmap复用的逻辑就提到这儿,接下来咱们来说说过度制作的优化。

3.2 制作优化

在2.3节的第2点中咱们提到,过度制作是一个可优化大项,一起第3点中提到,Path太大也会影响Path API的调用耗时。因而这一小节首要对这两个状况进行优化。

关于过度制作,我检查了制作的代码,关于有堆叠的制作区域则尽可能地削减重复区域的制作,这一部分的详细代码就不贴了,代码都在PTQBookPageViewInner的Canvas这个@Composable中。

关于过度制作这部分,我本来想直接用一张Bitmap来“组成”歪曲图和底图的,这样就能够削减制作次数,但通过测验后失利了。代码中注释掉的有关synthesizedBitmap的部分便是这个失利的测验。

而关于Path太大的问题,咱们考虑一下,依据咱们笼统出来的页面点模型,当页面挨近笔直状况时,这时Path的端点会向上延伸到很远很远处,我用log看过了,乃至有的点的y坐标到了20-30万,这就很夸张了,所以Path太大的首要影响因素便是笔直的时分,因而咱们需求对部分便利核算(有的地方是曲线不太好核算,得规划算法但我懒得想了)的制作图层进行专门的笔直处理,以buildPath函数中的shadow3的Path的构建为例,我针对越界的线与组件边框规模求了交点,以防止过大的Path点呈现,代码如下。

//shadow3
pathResult.shadowPaths[2].apply {
    moveTo(W)
    lineTo(S1)
    //若挨近笔直,则直接画成矩形,不然画梯形
    if (((T1.y - O.y) / (C.y - O.y)).absoluteValue > shadow3VerticalThreshold) {
        lineTo(S1.copy(y = (C.y - O.y).absoluteValue - S1.y))
        lineTo(W.copy(y = (C.y - O.y).absoluteValue - W.y))
    } else {
        /**
         * @since v1.1.0 越界制作优化:假如Z在BC内,则直接画线,不然求交点
         */
        //给一组log数据供参阅
        //buildPath: C.y:0 O.y:1600 upsideDown:true W: Point(x=376.90134, y=0.0) S1: Point(x=523.4354, y=0.0) T1: Point(x=720.0, y=36051.1) Z: Point(x=720.0, y=62926.297)
        //buildPath: C:1600.0 O.y:0 upsideDown:false W: Point(x=380.06815, y=1600.0) S1: Point(x=526.1546, y=1600.0) T1: Point(x=720.0, y=-46201.938) Z: Point(x=720.0, y=-82226.625)
        val S1T1_OBx = Line.withKAndOnePoint(lST.k, S1).x(O.y) //S1T1交OB的x坐标
        val WZ_OBx = Line.withKAndOnePoint(lST.k, W).x(O.y)
        lineTo(if (S1T1_OBx > C.x) T1 else Point(S1T1_OBx, O.y))
        lineTo(if (S1T1_OBx > C.x) Z else Point(WZ_OBx, O.y))
    }
    close()
}

3.3 未完成的优化

这一部分记载一下未完成或许失利了的优化,可是思路我觉得可能还是会有点用的。

1、native层进行图片组成

假如说有两张图片想左右拼接,或许四张图片想左右拼接,而又比较吃功能的话,能够考虑ndk开发,直接在native层操纵图片的像素,但我这儿失利了,由于我需求先用canvas API对图片处理,再去操纵像素则更没必要了。

这儿提一嘴,假如要手动把RGB565的图片转为ARGB8888,每个像素的转换办法。

RGB565是一个像素16位,从高到低分别是R5位,G6位,B5位,而ARGB8888则是32位,每个字节8位,但这儿有个坑,ARGB8888从高到低分别是ABGR。

代码如下:

static uint32_t rgb565PixelToArgb8888(uint16_t pixel) {
    uint8_t r = ((pixel >> 11) & 0x1F) * 0xff / 0x1f;
    uint8_t g = ((pixel >> 5) & 0x3F) * 0xff / 0x3f;
    uint8_t b = (pixel & 0x1F) * 0xff / 0x1f;
    return 0xff << 24 | (b & 0xff) << 16 | (g & 0xff) << 8 | (r & 0xff);
}

2、Compose中开子线程核算,一起下降手势的回调频率

这个便是2.1节第2点提到的优化思路,我没去做,由于太懒了。完成的思路大概是在手势触发后,起一个其他线程的协程去进行杂乱核算,然后有成果了就直接用flow(collectAsState)发送给@Composable中的state变量,导致UI更新。而约束频率能够测验用flow的debounce办法。

这一部分我没有去完成,因而上面仅是个设想,可能实践操作还会有其他的问题,不过也算给个思路供参阅吧。

4 结语

目前的组件优化到了一个能用的程度了,文中也说了,其实还能进一步优化,比方耗时核算放到新线程,或许改用C++重写,应该还能优化一些,可是懒得去完成了。

这一趟优化下来也的确令我学到了不少东西,现已收成满满了,不过学习的脚步不能停下,还有许多细节是需求学习的,一步步来吧。

关于杂乱UI的优化,希望文中的一些思路能帮到咱们,就写到这儿好了。