查看原文
其他

“终于懂了“系列:Android性能优化—FPS提升实战

鸿洋 2023-09-13

The following article is from 胡飞洋 Author 胡飞洋

前言

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的原理,包括四级缓存、onCreateViewHolderonBindViewHodler的调用时机等

这里列举的是本篇强相关的技术点,性能优化本身是对涉及技术点的综合运用,需要具备扎实的基础知识。

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有大幅提升的方案是 解决所有帧的公共问题——重度绘制,也是本篇重点介绍的内容。

2Android渲染流程

在需要的预备知识中,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图中的描述:

Systrace

2.2 GPU呈现模型分析

渲染流程的耗时可以通过工具——GPU呈现模型分析 来分析,这非常有助于耗时点寻找和分析。

GPU呈现模型分析.png

绿色的横线是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的 onLayoutonMeasure 都是来自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```

onLayoutonMeasure表现一致。

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(00, view.getWidth(), view.getHeight(), radius);
5    }
6});
7this.setClipToOutline(true);
修改后,每帧的红色条占比大幅降低,实际fps也大幅提升。
5总结


5.1 疑问点

为啥有的优化操作不达预期,对FPS绝对值提升很有限?

  1. 假如一次滑动有100帧,有80帧每帧耗时40ms,有10帧耗时16.6ms以内,10帧耗时80ms,那么我们只把80ms的10个帧优化到40ms,那么对FPS均值的提升是有限的(尤其是快速滑动时)。例如只优化了onCreateViewHolder或者onBindViewHodler,那么只对出现新item的帧有影响,这只占很小的比例。

  2. 如果把40ms的80个帧优化到16.6ms以内,80ms的10个帧优化到40ms,那么对FPS均值的提升就是显著的。例如上面实战中,所有帧都有的绘制耗时,这影响到所有帧。

5.2 优化分析思路

先在慢滑状态下,查看GPU呈现模式工具,优先看多数帧的共同耗时点,再看非共性问题(例如进入新item时的帧耗时)。具体耗时点分析,可通过SysTrace分析。

  1. 慢滑,是因为避免两帧之间的干扰,若当前帧耗时较多,那么很可能会导致下一帧的VSync延迟。

  2. 优先看多数帧的耗时点,是因为要优先解决帧耗时的共性问题 进而大幅提升FPS。

  3. 使用Systrace打点来分析具体耗时的代码。

  4. UIThread和RenderThread都需要分析。

本篇重点介绍了渲染流程和对应的GPU呈现模式分析图,以及对应色条的理解。然后对滑动阶段各耗时场景进行的详细的分析,最后进行了优化实战。性能优化需要真实的实践,只有真正做过并取得了显著的收益才会有更深的理解。大家可以针对自己的项目看看有无流畅度的问题,尝试去分析和优化,看看是否能有显著的提升。

好了,本篇就到这里,欢迎留言讨论~



最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

Compose 1.5 发布,性能大幅提升?
Android 内存优化的小知识
Android实现特效或滤镜预览的几种方式


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存