作者:彭泰强
1 评价指标&优化成果
要做性能优化,首先得知道性能怎么度量、怎么表示。因为性能是一个很抽象的词,我们必须把它量化、可视化。那么,因为是UI组件优化,我首先选用了GPU呈现模式分析这一工具。
在手机上的开发者模式里可以开启GPU呈现(渲染)模式分析这一工具,有的系统也把它叫hwui什么什么的,自己找一下,开启后,屏幕上会展示一个直方图,直观来看就是有很多竖条,每一个竖条代表一个渲染帧,这些竖条的高度代表渲染的每个阶段渲染前一帧所用的相对时间。屏幕底端会有根绿线,代表的就是16.6ms,而一帧的渲染时间超出这个绿线,就有可能发生所谓的掉帧。再说简单点,每个竖条越低越好。
这些信息大致可以告诉我们:
- 1、当前渲染一帧的时间是否在合理的范围内。
- 2、渲染的每个阶段的耗时,从而了解应该优化哪些方面来提高应用的渲染性能。
关于这个工具就简单介绍到这里,更详细的内容可以自己看 官方文档(官方文档同样也给出了对应颜色竖条的排查问题的思路)
。
那么,我们回到翻页组件。在之前发布的v1.0.1版本中,绘制流程是先计算所有点,构建Path,然后老老实实的一个图层一个图层绘制(例如,先绘制最底层的下一页内容,再绘制翻起来的这一页内容,再绘制阴影,再绘制页面光泽等等...)
,现在我们打开工具,看一下,这一看简直吓了一跳,如左图所示。最底下的绿线是16.6ms,然而,一翻页之后,满屏的竖条出现了,这高度也太吓人了,远超合理的范围,每一帧都花费了很多时间,相当于严重掉帧了。这下,不得不开始优化了。
那么,经过这一周的思考和优化,最后的结果就是右图了,虽然还有好几个可以优化的大点,但是我懒得写了,就先优化到这里吧,定为v1.1.0版本。
接下来讲一下优化的思路和过程供大家参考,如果有不足之处也欢迎讨论和批评指正。
2 思考
那么现在知道了性能问题很糟糕,想要优化,首先得搞清楚能优化哪些方面,然后是怎么去优化,下面是一些思考的角度。
注:本节仅仅是思考的过程,具体的实现细节方案之类的在后面讨论,甚至,可能有一些本节列出的优化角度经验证后会是不可实现或实现成本巨大的,最后放弃了。
2.1 Compose
这个UI组件首先是一个Jetpack Compose编写的组件,那么Compose的部分是否存在能优化的大项?
1. 是否存在过度重组
关于Compose,首先能想到的一点就是重组次数是否合理,即,是否存在过度重组、不必要的重组的情况。其实在之前的第一版代码实现时,我就考虑了这一点,使用官方的Layout Inspector(默认在Android Studio的右下角),可以去检查每个@Composable重组的情况,确认他们是否合理,所幸,并没有发生过度重组的情况。这个优化点直接跳过。
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?因为有一个需求是要实现类似纸张翻起来的一个文字扭曲效果,如下图右侧所示。
这个扭曲的实现思路用到了Canvas的drawBitmapMesh方法,也就是说,组件要把任意自定义的@Composable内容进行“截屏”操作,绘制成一张Bitmap,然后对这张Bitmap调用drawBitmapMesh进行扭曲(既然是“截屏”操作,这也就解释了为什么组件支持显示任意的非动态的内容)
。
在每次页面内容发生改变(例如当前页面内容有变化、或者翻页了)
时,就要去重绘前一页、当前页、后一页这三张Bitmap,且这三张Bitmap都是同样大小的,与组件大小一样大。
组件中涉及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)
,所以这个优化点直接跳过。
2、是否存在过度绘制
过度绘制就是指,由于代码编写不当,导致同一个像素点被反复更新。我们只能看见最上面的图层,因此可以去考虑是否存在大量的被掩盖的区域,既然这些区域是不可见的,那它们本身就不应该被绘制。
这里同样有一个工具,系统自带的,叫 调试GPU过度绘制,在开发者选项中打开这个工具,它就会在屏幕上显示我们过度绘制的区域。
现在让我们看看优化前的区域(下左图),一片红,那么基本上可以确定了,这也是一个可优化的大点。
在实际开发时,有一些过度绘制是无法避免的,因此我们要做的就是尽可能地减少过度绘制,在思考优化方案过后,做到了下右图的效果,减少了一些过度绘制,提高了性能。
3、耗时的绘制
一些Canvas和Path的API可能会相对来说比较耗时,我们应该尽可能减少此类API的调用
目前我能想到的可优化的点和思路就是这些,下面开始实现。
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的优化,希望文中的一些思路能帮到大家,就写到这里好了。
Android 学习笔录
Android 性能优化篇:https://qr18.cn/FVlo89
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集:https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap