前言
Android性能优化不是一个能完全讲解清楚的题目。Android中的性能优化涉及的内容实在太过广泛,需要掌握的技术实在太多,且具体的项目所使用的优化方案也大不相同。想全面讲解性能优化,是万万不能的,实际上目前我学习到的还差得很远。
本专题内容包括对过往工作、技术学习的总结,以及对优化方向的思考与梳理。内容涵盖的点可能不够全面,其实也没必要做到全面,更多的是思考和实践。
系列预计分为五篇:
-
《“终于懂了“系列:Android性能优化(一)流畅度优化—FPS提升实战》
-
《“终于懂了“系列:Android性能优化(二)包体积优化—减包实战》
-
《“终于懂了“系列:Android性能优化(三)内存优化》
-
《“终于懂了“系列:Android性能优化(四)动态策略》
-
《“终于懂了“系列:Android性能优化(五)降本增效与AB实验》
再加上之前的《启动优化》,基本上相对重要的Android性能优化的方向都会涉及到。
本篇就首先来介绍我认为在性能优化中地位仅次于包体积优化、启动速度优化的流畅度优化。
1
流畅度
1.1 流畅度认知
流畅度在本篇中是指 可滑动列表在滑动时的流畅度,流畅度越高则体验越好。流畅度优化,就是让列表滑动地更流畅,以期望带来像留存率、停留时长等业务指标的收益。
它和所谓的 布局优化、卡顿优化、绘制优化 还是有区别的:流畅度优化有确定的衡量指标——fps,fps越大则滑动时的体验越流畅。也就是说,流畅度优化是 有指标衡量的、且指标能反映用户直接体验好坏的 优化方向。
fps,每秒帧数,即帧率单位。可见文章《Android屏幕刷新机制》
像电商、新闻等典型app的核心页面都是一个可滑动的列表,用户滑动列表以浏览更多的商品或信息,那么滑动时的流畅程度是影响用户是否继续滑动的一个重要因素。手指滑动时 列表不跟手、滑动出现明显卡顿等这些问题 我们是需要极力避免的。
1.2 预备知识
想要解决滑动流畅度问题、提升fps,需要掌握较多的技术点:
-
View工作原理,包括三大流程measure/layout/draw,自定义view等
-
屏幕刷新机制,包括VSync、Choreographer、fps的计算
-
渲染流程,包括UIThread与RenderThread、CPU与GPU 分别经过哪些步骤
-
滑动列表RecycleView的原理,包括四级缓存、onCreateViewHolder与onBindViewHodler的调用时机等
这里列举的是本篇强相关的技术点,性能优化本身是对涉及技术点的综合运用,需要具备扎实的基础知识。
1.3 优化工具
在流畅度优化中所使用的工具最重要的有2个:
-
Systrace,性能数据采样和分析工具,通过生成的Systrace文件可以帮助分析问题,是Android性能优化中必须掌握的工具。在文章《启动优化》中有介绍过,《Systrace系列》(https://www.androidperformance.com/2019/05/28/Android-Systrace-About)可以帮你全面深入学习Systrace。另外还有更方便的基于Systrac的btrace(https://github.com/bytedance/btrace/blob/master/README.zh-CN.md),高性能且支持自动注入自定义事件
-
GPU呈现模型分析(HWUI呈现模式分析)(https://developer.android.com/studio/profile/dev-options-rendering?hl=zh-cn),以滚动直方图的形式直观地显示渲染界面窗口帧所花费的时间
此外成熟的性能优化方案 除了实施优化外,还应包括 线上监控APM工具和防劣化方案,本篇不会涉及。
1.4 优化方案
在很多介绍 布局优化、卡顿优化、绘制优化的文章中,提到的解决卡顿问题方案有很多:
-
减少view层级、异步加载view、使用x2c(https://github.com/iReaderAndroid/X2C)框架
-
通过Looper设置Printer来监控并解决主线程耗时函数
-
滑动时暂停后台下载任务/IO读写,例如在列表idle时才加载图片/视频
-
对各种IPC结果进行缓存
这些方案在实际项目中也确实能带来不错的收益,但是在项目的流畅度优化中经实验对比却没有获得fps的较大提升。而最后使fps有大幅提升的方案是 解决所有帧的公共问题——重度绘制,也是本篇重点介绍的内容。
2
Android渲染流程
在需要的预备知识中,View工作原理、屏幕刷新机制 我之前有文章做了专门的介绍,网上关于RecycleView原理的文章也是比较多的。关于渲染流程则是一个被提及比较少的知识点,本节会整体介绍渲染流程,以及与GPU呈现模型分析图的关系。
2.1 渲染流程
由《Android屏幕刷新机制》我们知道,屏幕上每一帧的渲染都要从 VSync开始,会先在UI线程处理 Input、Animations、Traversal(measure/layout/draw)事件,在draw中(现在Android默认开启GPU硬件加速)会产生用来描述绘制行为的DisplayList。
然后UI线程把DisplayList同步给渲染线程 RenderThread,RenderThread这里做一些优化的操作,到这里都是在CPU中完成。接着RenderThread把绘制信息提交给 GPU 进行绘制(这里会进行dequeueBuffer),当绘制完毕后通过 queueBuffer把Buffer放回到 BufferQueue里。最后在Vsync-sf时SurfaceFlinger会将Frame Buffer进行合成,然后我们就可以在屏幕上看到这一帧了。
-
第一个阶段,其实主要做的就是构建DrawOp树(里面封装OpenGL渲染命令),同时,预处理分组一些相似命令,以便提高GPU处理效率,这个阶段主要是CPU在工作,不过这个阶段前期运行在UI线程,后期部分运行在RenderThread(渲染线程)。如下图:
构建DrawOp树
-
第二个阶段主要运行在渲染线程,CPU将数据同步给GPU,之后,通知GPU进行渲染,不过这里需要注意的是,CPU一般不会阻塞等待GPU渲染完毕,而是通知结束后就返回,除非GPU非常繁忙,来不及响应CPU的请求,没有给CPU发送通知,CPU才会阻塞等待。CPU返回后,会直接将GraphicBuffer提交给SurfaceFlinger,告诉SurfaceFlinger进行合成,SF合成后提交显示,如此完成图像的渲染显示。
示意图:
渲染流程在Systrace图中的描述:
2.2 GPU呈现模型分析
渲染流程的耗时可以通过工具——GPU呈现模型分析 来分析,这非常有助于耗时点寻找和分析。
绿色的横线是16.6ms基准线;每一个竖条就代表一个帧的绘制流程,颜色块及其长度则是对应某个阶段所用的相对时间,具体如下:
颜色称谓从低向上:青色、深绿色、浅绿色、深蓝色、浅蓝色、红色、黄色
-
VSync延迟:收到VSync信号到执行此次绘制的时间间隔。收到VSync信号后会post一个Message放入队列,当UI线程有耗时操作,那么handleMessage/doFrame就会被延迟。一般前一帧绘制较久,那么本帧就会被延迟。
-
输入和动画:编舞者doFrame中执行InputCallback、AnimationCallback的时间。
-
测量/布局:编舞者doFrame中执行TraversalCallback的的performMeasure/performLayout的时间。
-
绘制:编舞者doFrame中执行TraversalCallback的的performDraw的时间。
-
同步和上传:主线程与渲染线程同步渲染数据、将位图信息上传到 GPU 所花的时间。
-
命令问题(发出命令):CPU-RenderThreader将绘制显示列表的命令发送给GPU所花的时间。之后,GPU才能根据这些OpenGL命令进行渲染。
-
交换缓冲区:之前的GPU命令被发送完毕后,CPU一般会发送最后一个命令给GPU,告诉GPU当前命令发送完毕,可以处理,GPU一般而言需要返回一个确认的指令,不过,这里并不代表GPU渲染完毕,仅仅是通知CPU,GPU有空开始渲染而已,并未渲染完成,但是之后的问题APP端无需关心了,CPU可以继续处理下一帧的任务了。如果GPU比较忙,来不及回复通知,则CPU需要阻塞等待,直到收到通知,才会唤起当前阻塞的Render线程,继续处理下一条消息,这个阶段是在swapBuffers中完成的。
尽管此工具名为“GPU 渲染模式分析”,但所有受监控的进程实际上发生在 CPU 中。通过将命令提交到 GPU 来触发渲染,GPU 也会异步渲染屏幕。在某些情况下,GPU 可能会有太多工作要处理,因此您的 CPU 必须先等待一段时间,然后才能提交新命令。如果发生这种情况,您将看到橙色竖条和红色竖条上出现峰值,且命令提交将被阻止,直到 GPU 命令队列中腾出更多空间。
3
滑动列表常见fps劣化场景
了解了渲染流程,以及对应的GPU呈现模型分析图,那么就来看看滑动列表在滑动时的现象。我们模拟各阶段的耗时,用来测试和深度理解。
首先看看正常无耗时的滑动列表,在滑动时的GPU呈现模型分析图(忽略图中右上角,看底部GPU呈现图即可):
正常的GPU呈现图
下面我们分别来看不同场景下对应的GPU呈现模型分析图有什么特点。
3.1 onBindViewHolder
我们在onBindViewHolder做一个耗时操作:
1class PerfAdapter(layoutId:Int): BaseQuickAdapter<PerfBean, PerfItemViewHolder>(layoutId) {
2 override fun convert(holder: PerfItemViewHolder, item: PerfBean) {
3 try {
4 Thread.sleep(30)
5 } catch (e: InterruptedException) {
6 }
7 holder.binding?.tvPerfName?.text = item.name
8 }
9}
3.1.1 青色
滑动时如果被触发的 onBindViewHolder 的触发来自recycleView的prefetch,那么在接收到VSync信号后这一帧的doframe却被当前UI线程的Message—onBindViewHolder耗时耽误了执行,这个就是VSync延迟了,即这一帧占比最大的就是青色。
3.1.2 深绿色
如果被触发的onBindViewHolder 来自doFrame中的InputCallback,那就是这一帧占比最大的就是深绿色。
3.2 onMeasure/onLayout
我们在在item的根布局的onMeasure做耗时操作:
1override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
2 try {
3 Thread.sleep(30)
4 } catch (e: InterruptedException) {
5 }
6 super.onMeasure(widthMeasureSpec, heightMeasureSpec)
7}
3.2.1 深绿色
正常滑动时,滑进屏幕的一个item,它的onMeasure/onLayout是来自doFrame中的InputCallback、AnimationCallback,那就是深绿色,即第一根柱子。
第二根柱子,因为前一帧占用主线程时间较长,那这一帧的VSync就被延迟了,即青色,即第二根柱子。
onLayout中耗时和onMeasure表现一致。
3.2.2 浅绿色
即onLayout/onMeasure需要来自performTraversal。
3.2.2.1 页面首帧
刚进入页面后,首帧的每个item的 onLayout和onMeasure 都是来自doFrame的performTraversal。
3.2.2.2 onBindViewHodler中延迟刷新的帧
在onBindViewHodler中延迟2秒更新文字,那2s后的item的onMeasure就来自TraversalCallback,即浅绿色。
1```
2class PerfAdapter(layoutId:Int): BaseQuickAdapter<PerfBean, PerfItemViewHolder>(layoutId) {
3 override fun convert(holder: PerfItemViewHolder, item: PerfBean) {
4 holder.binding?.tvPerfName?.text = item.name
5 holder.binding?.root?.postDelayed( {
6 holder.binding?.tvPerfName?.text = "我变了"
7 } ,3000)
8}
9}
10```
onLayout和onMeasure表现一致。
3.3 draw/onDraw/dispatchDraw-深蓝色
Item view的dispatchDraw:
1override fun dispatchDraw(canvas: Canvas?) {
2 try {
3 Thread.sleep(30)
4 } catch (e: InterruptedException) {
5 }
6 super.dispatchDraw(canvas)
7}
正常滑动时,滑进屏幕的一个item,它的draw/onDraw/dispatchDraw来自从traversalCallback: traversalCallback->Recyleview.draw->Item.draw->Item.dispatchDraw,表现为深蓝色。
完整的draw过程:1. 画背景 2.画自己-- onDraw,自己实现 3.画子view-- dispatchDraw 4.画装饰,这里每一个耗时都会表现为深蓝色。
注意:如果是ViewGroup,要设置.setWillNotDraw(false),才会走完整的draw过程,否则只会走dispatchDraw。
3.4 多图-浅蓝色
如下图,列表中有很多图片时 浅蓝色区段 确实增大(在低端机上可能更为明显)。
3.5 绘制命令-红色
3.5.1 高频绘制
draw/onDraw/dispatchDraw中有很多绘制命令,即多次调用canvas.drawXXX方法:
1 /**
2* 众所周知,当我们自定义一个View时会重写他的3个方法,onMeasure(),onLayout(),onDraw()方法,
3* 但是自定义一个ViewGroup的时候要重写onMeasure(),onLayout(),dispatchDraw()这3个方法
4* setWillNotDraw(false),才会走draw(canvas: Canvas?),否则直接走了draw(Canvas canvas, ViewGroup parent, long drawingTime)(通过ViewGroup.drawChild())
5*/
6@RequiresApi(Build.VERSION_CODES.M)
7override fun draw(canvas: Canvas?) {
8 for (i in 1..1000){
9 canvas?.drawCircle(i*5.toFloat(), 80.toFloat(),5f, mPaint)
10 }
11 mPaint.color = context.getColor(R.color.colorAccent)
12 for (i in 1..1000){
13 canvas?.drawCircle(i*5.toFloat(), 100.toFloat(),5f, mPaint)
14 }
15 mPaint.color = context.getColor(R.color.colorPrimary)
16 for (i in 1..1000){
17 canvas?.drawCircle(i*5.toFloat(), 120.toFloat(),5f, mPaint)
18 }
19 super.draw(canvas)
20}
-
可见每 帧的红色部分都变长:每帧需要发出的命令都是3000个。(因为是影响到每一帧,所以需特别注意此情况)
-
当新item出现时会有一帧 深蓝色变长:深绿色是因为,深蓝色是因为7.3
原因是:系统会尽可能地缓存显示列表。因此某些情况下,滚动、转换或动画会要求系统重新发送显示列表(即红色),但不必实际重新构建它(即重新捕获绘制命令)(即draw过程-深蓝色)。因此,您可能会看到“发出命令”条较高,但“绘制命令”条并不高(即红色高但深蓝色不高)。
上图是低端机的情况,可见对于低端机来说,每一帧红色都满了,对fps影响巨大。(实测低端机中50个drawCircle就会造成每帧都超出16.6ms。)
-
这种情况,无论快滑还是慢滑,每帧都是满的。
-
2.1-2.3中的场景,只影响即将出现item的一帧,慢滑时对整体fps影响较小,快滑时影响比较大。
3.5.2 重度绘制
像canvas.saveLayer相关方法,一次调用就很耗费性能,请不要使用!重要!
3.5节与3.3节的不同:
3.3中是单纯draw方法的耗时,只影响即将出现item的那一帧
而本节中的 高频绘制命令、重度绘制命令,会作用于每一 帧 ****(虽然也是写在draw相关方法中)
总结:当你慢滑时,发现每一 帧 都超出16.6且红色占比很大,那么就可以判断是绘制命令的问题,需要去查itemView中的自定义view的draw相关方法。
3.6 交换buffer-黄色 TODO
怎么让黄色变长呢?GPU忙碌?暂未测试出~
4
实战分析
在实际项目首页的列表滑动fps优化时,发现在慢滑时:红色块占据一帧大部分耗时、且是所有帧的共性问题,如3.5节中一样,可见是绘制命令的问题。这就需要排查view绘制相关代码,尤其是自定义view。最后发现,在列表的item view中使用了较多的自定义圆角view:
1//RoundedImageView.java,用于实现圆角图片
2@Override
3protected void onDraw(Canvas canvas) {
4 // 使用图形混合模式来显示指定区域的图片
5 canvas.saveLayer(srcRectF, null, Canvas.ALL_SAVE_FLAG);
6 ...
7 super.onDraw(canvas);
8 paint.reset();
9 path.reset();
10 if (isCircle) {
11 path.addCircle(width / 2.0f, height / 2.0f, radius, Path.Direction.CCW);
12 } else {
13 path.addRoundRect(srcRectF, srcRadii, Path.Direction.CCW);
14 }
15 ...
16 paint.setXfermode(xfermode);
17 if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) {
18 canvas.drawPath(path, paint);
19 } else {
20 srcPath.reset();
21 srcPath.addRect(srcRectF, Path.Direction.CCW);
22 srcPath.op(path, Path.Op.DIFFERENCE);
23 canvas.drawPath(srcPath, paint);
24 }
25 paint.setXfermode(null);
26 ...
27 // 恢复画布
28 canvas.restore();
29 ...
30}
实现绘制圆角的方案为 saveLayer+Xfermode混合模式,而此方案中的saveLayer方法则是重度绘制方法。
替换方案:使用setOutlineProvider系统方法即可:
1this.setOutlineProvider(new ViewOutlineProvider() {
2 @Override
3 public void getOutline(View view, Outline outline) {
4 outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), radius);
5 }
6});
7this.setClipToOutline(true);
修改后,每帧的红色条占比大幅降低,实际fps也大幅提升。
5
总结
5.1 疑问点
为啥有的优化操作不达预期,对FPS绝对值提升很有限?
-
假如一次滑动有100帧,有80帧每帧耗时40ms,有10帧耗时16.6ms以内,10帧耗时80ms,那么我们只把80ms的10个帧优化到40ms,那么对FPS均值的提升是有限的(尤其是快速滑动时)。例如只优化了onCreateViewHolder或者onBindViewHodler,那么只对出现新item的帧有影响,这只占很小的比例。
-
如果把40ms的80个帧优化到16.6ms以内,80ms的10个帧优化到40ms,那么对FPS均值的提升就是显著的。例如上面实战中,所有帧都有的绘制耗时,这影响到所有帧。
5.2 优化分析思路
先在慢滑状态下,查看GPU呈现模式工具,优先看多数帧的共同耗时点,再看非共性问题(例如进入新item时的帧耗时)。具体耗时点分析,可通过SysTrace分析。
-
慢滑,是因为避免两帧之间的干扰,若当前帧耗时较多,那么很可能会导致下一帧的VSync延迟。
-
优先看多数帧的耗时点,是因为要优先解决帧耗时的共性问题 进而大幅提升FPS。
-
使用Systrace打点来分析具体耗时的代码。
-
UIThread和RenderThread都需要分析。
本篇重点介绍了渲染流程和对应的GPU呈现模式分析图,以及对应色条的理解。然后对滑动阶段各耗时场景进行的详细的分析,最后进行了优化实战。性能优化需要真实的实践,只有真正做过并取得了显著的收益才会有更深的理解。大家可以针对自己的项目看看有无流畅度的问题,尝试去分析和优化,看看是否能有显著的提升。