布局优化?应该这么玩

3,047 阅读19分钟

布局优化?应该这么玩

布局优化作为Android性能优化的一部分,其重要性不言而喻。那么在开发过程中,应该注意哪些事项,才能有助于我们开发出流畅的安卓应用?当遇到布局卡顿的时候,又该如何通过分析定位问题?本篇文章将会从原理到实践,一步步教你如何玩转布局优化。

Android布局优化
Android布局优化

概念

合理的布局,能够有效地提高性能,加快页面的显示速度,简化逻辑的复杂度。而布局对于Android性能的影响,则主要包含两个方面:测量+绘制。

作用

通过布局的优化,有效的减少页面的卡顿、丢帧等情况,实现应用的流畅。

基础知识

为什么布局复杂的时候就容易卡顿?5.0系统之后增加的硬件加速是什么,为什么开了硬件加速能够提高流畅度?我写了个xml文件,怎么就显示到手机屏幕上了?要想了解这些都是和UI布局优化有关的知识,那么就不得不讲一下布局优化的一些基础知识了,可能会枯燥,但是我相信,当你看完之后,对上面的问题都会有一个明确的答案了。

GPU VS CPU

平时我们说的CPU和GPU是什么?当说到这个问题的时候,我们不得不说一下栅格化

image-20200620222158018
image-20200620222158018

现在手机截个屏,里面有个@ 符号,把照片放大,放大,再放大

栅格化
栅格化

发现没有,我们屏幕上每一个数据(图片、文字、字母、数字等等)的显示,都是由一个个的小格子组成的,就像我们动图中的@一样。这就是栅格化。这种将文字进行栅格化的功能,费时费力。所以Android系统将这部分功能单独交给了GPU来处理。

CPU只负责将xml布局中的按钮等转化为纹理,然后GPU负责将纹理进行栅格化之后进行渲染。

Open GL

GPU是一个单独的硬件,而且市面上各种GPU型号那么多,CPU将数据传输给GPU的时候,肯定不是说想给就给,必须要遵守一定的接口规则。也就是Open GL。

Open GL是一个跨编程语言、跨平台的编程接口规格的专业的图形程序接口。而CPU和GPU之间的纹理数据的传输,就是通过 Open GL接口来实现的。

渲染原理

CPU和GPU的作用已经清楚了,那么,我们的交给GPU的纹理数据又是如何显示在我们的显示屏上的呢?这时候就涉及到了Android的渲染原理了。

图形消费者-显示屏

显示屏的内容,是从硬件帧缓冲区读取的。会从Buffer的起始位置开始,扫描整个Buffer,然后将内容映射到显示屏。

image-20200620230655608
image-20200620230655608

这里包含了两个缓冲区:

前缓冲区:用来显示内容到屏幕帧缓冲区。

后缓冲区:用于后台合成下一帧图形的帧缓冲区。

很明显,通过两个缓冲区的角色互换能够有效的加快图形的显示。

图形生产者

既然显示屏能够从缓冲区拿到数据并显示,那么肯定是有一个模块的功能是用来生产这些数据的。

为了更好地理解这一部分的功能,我们先来看一下常用的微信页面,并将其进行拆分。

image-20200621100615166
image-20200621100615166

Surface

我们的应用是可以多层显示的,每一层其实都是一个Surface,它就像一个画板。View的onDraw方法,会将相应的布局绘制到Surface内,而每层数据都对应一个Surface。你可以理解为Surface是Android窗口的描述。一个应用最多是可以包含31个窗口的。

GraphicBuffer

Surface内部存在多个缓冲区,形成一个缓冲队列BufferQueue,缓冲则是通过GraphicBuffer来表示。j就像Surface是Android窗口的描述一样,GraphicBuffer则是Android中图形Buffer的描述。

SurfaceFlinger-图形合成者

image-20200621110618166
image-20200621110618166

微信最后展示在我们面前的肯定是上图这个样子的,那么如何将Surface的多层数据进行合成处理然后形成对应的屏幕缓存区所需要的数据的呢?这就是SurfaceFlinger的主要功能了。它会将所有上层对应的Surface内的图形进行合成。当然了,这部分是通过对缓冲队列BufferQueue的操作来实现的

image-20200621103342921
image-20200621103342921

我们从一个Buffer的流转过程来体会一下整个绘制过程:

  • Buffer从Surface的BufferQueue转移到上层(图形的生产者,你可以理解为我们的xml文件绘制的Canvas?)。
  • 上层绘制完后将Buffer放入到BufferQueue。
  • SurfaceFlinger拿到Buffer进行合成(合成后的数据放入到显示屏的后缓冲区)。
  • 合成完成后放回BufferQueue。

通过循环,实现了Buffer的被循环利用的过程。从而实现了一次渲染过程。

刷新机制

Android系统每隔16ms发出VSYNC信号,触发对UI进行渲染,如果每次渲染都成功,这样就能够达到流畅的画面所需的60FPS。

如果我们想要实现流畅的画面,那么就必须保证从整个渲染过程必须在16ms内完成。如果页面比较复杂,导致了onLayout、onDraw的时间特别长或者复杂的处理占用了主线程较多的时间,导致16ms无法完成后缓冲区的准备工作,那么当前就没办法实现前后缓存区的交换,从而导致页面中仍然显示之前的数据,给人一种卡顿的感觉。

我们来看一个卡顿的具体流程。

image-20200621112824276
image-20200621112824276

以时间的顺序来看下将会发生的异常:

Step1. Display显示第0帧数据,此时CPU和GPU渲染第1帧画面,而且赶在Display显示下一帧前完成

Step2. 因为渲染及时,Display在第0帧显示完成后,也就是第1个VSync后,正常显示第1帧

Step3. 由于某些原因,比如CPU资源被占用,系统没有及时地开始处理第2帧,直到第2个VSync快来前才开始处理

Step4. 第2个VSync来时,由于第2帧数据还没有准备就绪,显示的还是第1帧。这种情况被Android开发组命名为“Jank”。

Step5. 当第2帧数据准备完成后,它并不会马上被显示,而是要等待下一个VSync。

优化工具

对于UI卡顿的问题,是有很多的诱因的。比如说页面复杂、层级较深、Bitmap过大、一些操作占用了UI线程等等。如果我们能够通过经验就能定位到问题,那当然最好了。但是很多情况下我们无法得知到底是什么原因导致的卡顿,这时候我们就需要通过工具来进行辅助了。

Systrace

介绍

systrace能够有效的跟踪系统的I/O操作、内核工作队列、CPU负载以及Android各个子系统的运行状况,然后生成HTML报告,可以用来分析我们的卡顿和渲染问题。

使用方式
  1. DDMS方式。
    image-20200621131839663
    image-20200621131839663

    DDMS在Android Studio3.0以后的版本已经去掉了,需要通过sdk\tools\monitor.bat来启动。
  2. Python方法

对于DDMS方式来说,需要我们的程序先启动起来。如果我们应用启动的首个页面卡顿,这时候,就很考验手速了。启动,DDMS迅速选择应用,然后捕捉数据。你单身20年,手速快的当我没说。

这时候我们可以使用Pythoy。该方法需要安装python环境。执行指令为

python systrace.py -b 32768 -t 5 -a com.***.** -o browser.html sched gfx view wm am app
生成systrace
生成systrace

当页面显示Starting tracing的时候,就可以操作页面了。这里我们设置的是采集5秒。

  1. 代码方式

systrace没有办法控制Trace的开始和结束。如果我们确定要分析某个部分的代码,我们必须要为这段代码指定具体的Label,以便能够在systrace生成的视图中能够找到我们的trace信息。这时候就通过代码方式,在要监控的代码开始和结果通过添加监控代码来进行处理。

TraceCompat.beginSection("Fragement_onCreateView");
//自己的代码段
TraceCompat.endSection();

Trace的分析结果中就会带上Fragement_onCreateView 这个过程的运行时间段信息(必须要开启 -a 选项!)

img
img

通过以上三种方式是都能够生成具体的HTML数据。通过浏览器打开即可。

结果分析

通过浏览器打开以后,在左侧找到在Frames。可以看到右侧有红、粉、绿三种颜色的圆圈。一般红色表示有问题,需要优化的地方。

image-20200621134220960
image-20200621134220960

当我们点击红色按钮以后,会有alerts信息。通过这些alerts信息就可以定位到有问题的方法。但是如果想知道具体的更相信的信息的话,就需要结合Traceview来定位问题、解决问题了。
实战

image-20200621134958867
image-20200621134958867

此图是我们一个有问题的代码systrace信息,可以看到,这部分提示的alert信息是Expensive measure/layout pass。表明是布局的measure或者layout时间过长了。通过图上则可以看到RV OnLayout时间特别长,其中onCreateView和onBindView都占据了绝大部分时间。这时候我们就可以知道RecyclerView的布局是存在问题的。所以直接去看一下布局和onBindView中的代码就可以。

常见错误

  • Scheduling delay:渲染一帧的工作被推迟了几个毫秒,从而导致了不合格。确保UI线程上的代码不会被其他线程上完成的工作阻塞,并且后台线程(例如,网络或位图加载)在android.os.Process#THREAD_PRIORITY_BACKGROUND中运行或更低,因此它们不太可能中断UI线程。
  • Expensive measure/layout pass:测量/布局花费了很长时间,导致掉帧,要避免在动画过程中触发重新布局。
  • Long View#draw():记录无效的绘图命令花费了很长时间,在View或Drawable自定义视图时,要避免做耗时操作,尤其是Bitmap的分配和绘制。
  • Expensive Bitmap uploads:修改或新创建Bitmap视图要传送给GPU,如果像素总数很大,这个操作会很耗时。因此在每一帧中要尽量减少Bitmap变更的次数。
  • Inefficient View alpha usage:将alpha设置为半透明值

systrace能够直观的表现丢帧的情况以及丢帧原因,但是对于具体的函数(即代码级别)则无能为力,有时候需要TraceView的辅助来定位分析问题。

Layout Inspector

介绍

Layout Inspector是Android Studio自带的视图层次结构分析工具(Android Studio2.2以后),取代了以前经常使用的Hierarchy View工具。它允许在运行时检测应用程序的视图层次结构。

使用方法

应用启动之后,直接在Android Studio中通过Tools->Layout Inspector。就可以启动Layout Inspector。(该版本是Android Studio4.0。老版本是Tools->Android->Layout Inspector)。

image-20200621141929673
image-20200621141929673

打开之后,能够看到手机当前的页面,左侧则是页面的布局层级。右侧则是选中的控件的属性。

img
img

通过这个功能,能够分析布局的层级结构,减少不必要的层级,减少overdraw的问题,从而达到渲染优化的效果。

TraceView

简介

TraceView是一种可视化工具,可以看出代码在运行时的一些具体信息,方法的调用时长,次数,时间比率等等。了解代码运行过程中的效率问题,从而针对性的改善代码。对于systrace中不能够精确定位问题的,TraceView则能够精确地显示出来。

使用方法
  1. DDMS方式。

DDMS在Android Studio3.0以后的版本已经去掉了,需要通过sdk\tools\monitor.bat来启动。

image-20200621152600682
image-20200621152600682

点击启动以后操作页面,然后再次点击,就可以停止信息的采集。

  1. 代码方法

在需要要监控的代码开始和结果通过添加监控代码来进行处理。

Debug.startMethodTracing("Fragement_onCreateView");
//自己的代码段
Debug.stopMethodTracing()

当运行这段代码的时候,会生成一个trace文件。文件在sdcard->Android->data->包名->files中。然后将文件导出,可以通过DDMS来查看。

这两种方式第一种相对来说简单,但是测试范围比较宽泛。第二种则更加精确,适用于当你能够大体知道具体的问题点的时候。

结果分析

当通过DDMS打开对应的trace以后,显示的页面如下:

img
img

这里面主要关心的下半部分中的关键指标。其中平均占用CPU的时间以及调用、递归次数则是经常使用的两个指标。

左边的name表示执行的方法。

img
img

点开的话,将方法分为了两个部分。

定位问题

哪些方法的执行需要花费很长时间

点击 TraceView 中的 Cpu Time/Call,按照占用 CPU 时间从高到低排序

哪些方法调用次数非常频繁

点击 TraceView 中的 Calls + Recur Calls/Total ,按照调用次数从高到底排序

排序后,然后逐个排查是否有项目代码或者依赖库代码,有的话点击查看详情,查看是这个方法还是调用的子方法的问题,进一步定位问题。

实战分析

下面这个例子,是在一个项目中,发现某个方法比较耗时之后,通过增加了Debug代码来进行监控处理的。

TraceView查找耗时方法
TraceView查找耗时方法

我们先按照Cpu Time/Call进行了排序,然后从左边查找我们所编写的代码文件,发现BaseRectckerViewAdapter方法比较耗时,然后通过查找其中比较耗时的Childen,一层层的查找,最后发现是getItemView方法比较耗时,而这个方法主要就是通过inflate方法加载布局文件。从而定位到这个布局文件是存在问题的,从而导致解析慢。然后通过Layout Inspector查看层级,进行布局的优化,从而解决问题。

提前监控

既然经常出现布局比较耗时的问题,那么有办法去获取布局耗时的方案么?答案当然是肯定了的了,而且方法还不止一种呢~~

方法前后增加计时

这种方法,相信你应该用过。通过在setContentView()方法前后增加代码,然后计算其耗时,从而监控布局是否出现问题。先不说你是不是个勤劳的cver,这种方法,写起来累是肯定的。少的时候还好,但是一旦页面特别多,估计写着写着就cu死了~

AOP切面编程

面向切面编程,这种在后台开发中,属于已经用烂了的技术,但是在Android端,其实相对来说要少很多。既然是切面编程,那么必然是需要一个切点,能够织入我们自己的代码。这里我们的切入点就是setContentView方法了。通过@Around来进行切面处理

@Around("execution(* android.app.Activity.setContentView(..))")
public void getSetContentViewTime(ProceedingJoinPoint joinPoint) {
    Signature signature = joinPoint.getSignature();
    String name = signature.toShortString();
    long time = System.currentTimeMillis();
    try {
        joinPoint.proceed();
    } catch (Throwable throwable) {
        throwable.printStackTrace();
    }
    LogHelper.i(name + " cost " + (System.currentTimeMillis() - time));
}

重装应用,然后打开几个页面,就可以看到对应的页面布局加载耗时日志信息了

Kailaisi-LOG: │ [PerformanceAop.java | 36 | getSetContentViewTime] AppCompatActivity.setContentView(..) cost 174
Kailaisi-LOG: │ [PerformanceAop.java | 36 | getSetContentViewTime] AppCompatActivity.setContentView(..) cost 13
Kailaisi-LOG: │ [PerformanceAop.java | 36 | getSetContentViewTime] AppCompatActivity.setContentView(..) cost 44
Kailaisi-LOG: │ [PerformanceAop.java | 36 | getSetContentViewTime] AppCompatActivity.setContentView(..) cost 61
Kailaisi-LOG: │ [PerformanceAop.java | 36 | getSetContentViewTime] AppCompatActivity.setContentView(..) cost 22

ARTHook方案

ARTHook能够hook对应的方法,然后在方法调用之前或者之后进行自己逻辑的处理,这里我们使用的是epic开源库。具体的用法和实现原理,大家可以去阅读一下。

在Application中onCreate方法中hook所有构造方法,然后hook到setContentView方法。

        DexposedBridge.hookAllConstructors(AppCompatActivity.class, new XC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                super.afterHookedMethod(param);
                Activity object = (Activity) param.thisObject;
                Class<? extends Activity> aClass = object.getClass();
                DexposedBridge.findAndHookMethod(AppCompatActivity.class, "setContentView"int.class, new LayoutTimeMethodHook());
            }
        });

这里的LayoutTimeMethodHook则是实现对应的计时功能。

/**
 * 描述:用于Hook setContentView的时间
 *
 * 作者:kailaisi
 * <br></br>创建时间:2020/6/16:22:43
 */

class LayoutTimeMethodHook : XC_MethodHook() {
    var start: Long = 0

    @Throws(Throwable::class)
    override fun beforeHookedMethod(paramMethodHookParam
{
        start = System.currentTimeMillis()
        val thisObject = param.thisObject as Activity
        Log.d(TAG, thisObject.javaClass.simpleName + "beforeHookedMethod: startTime+" + start)
    }

    @Throws(Throwable::class)
    override fun afterHookedMethod(paramMethodHookParam
{
        super.afterHookedMethod(param)
        val thisObject = param.thisObject as Activity
        val name = param.method.name
        //实现我们自己的逻辑
        Log.d(TAG, thisObject.javaClass.simpleName + "afterHookedMethod: endTime:" + System.currentTimeMillis())
        Log.d(TAG, "${thisObject.javaClass.simpleName} :${name}总共耗时: ${System.currentTimeMillis() - start}")
    }
    companion object {
        private const val TAG = "LayoutTimeMethodHook"
    }
}

当运行以上代码之后,就能够打印出来耗时信息了

2020-06-21 16:34:23.541 2851-2851/com.kailaisii.cll D/LayoutTimeMethodHook: SplashActivity :setContentView总共耗时: 102
2020-06-21 16:34:24.692 2851-2851/com.kailaisii.cll D/LayoutTimeMethodHook: SettingActivity :setContentView总共耗时: 15
2020-06-21 16:34:32.540 2851-2851/com.kailaisii.cll D/LayoutTimeMethodHook: LoginActivity :setContentView总共耗时: 47

可以看到也能够对于每个Activity的setContentView进行耗时的统计。

布局优化方案

最好的方法,当然是防患于未然了。出现问题再去费时费力的查找,肯定不如在开发的时候就注意一定的布局优化方法,一劳永逸。

加载优化

我们之前Android的inflate源码详解中将结果具体的inflate流程。知道了inflater的整个过程,主要包括如下两点:

  • xmlPullParser是一个IO操作,布局越复杂,IO越耗时。
  • createView对于每个View都是通过反射来获取,控件越多,反射次数越多,那么耗时就越长。

那么对于加载的优化,我们就可以从这两方面来入手。

  • AsyncLayoutInflater,将inflater交给子线程来做。
  • X2C:规避掉IO和反射,通过动态加载视图。

绘制优化

层级越深,布局越复杂,那么执行onLayout和onDraw的时候,肯定就会消耗更多的时间,所以通过一定的层级优化,减少GPU的渲染任务,从而达到优化的效果。

布局层级和复杂度优化

mearsure、layout、draw三个过程都是从上到下来遍历的,层级越深遍历就越耗时。RealtiveLayout嵌套则可能会导致多次触发onMeasure等等。

优化思路

  • 减少View树的层级。
  • 布局要宽而浅,避免窄而深。
  • ConstraintLayout是一种扁平化的布局,能用的时候尽量用。
  • 避免嵌套使用RelativeLayout
  • 不再LinearLayout中使用weight
  • 可以使用merge标签,减少根View的层级。
  • ViewStub延迟加载。
避免过度绘制

一个像素最好只被绘制一次,多次绘制只会浪费GPU资源。

优化思路

  • 避免层级叠加
  • 去掉多余的backgroud
其他
  • 避免同一时间动画执行过多。
  • 自定义View避免频繁触发onMeasure、onDraw、onLayout。
  • 去除冗余资源及逻辑,防止消耗主线程的cpu时间片。

总结

这篇文章从绘制的原理出发,然后介绍了一些常用的优化工具以及优化方案。希望通过本篇文章的阅读,能够让你对布局的优化方法以及其原理都能够有一个较深入的理解。

参考

https://mp.weixin.qq.com/s/VdG3bqjYjDvd285OIcffBA

https://blog.csdn.net/u012267215/article/details/86013223

https://www.jianshu.com/p/1a063c9073b1

https://blog.csdn.net/haigand/article/details/90584757

https://www.jianshu.com/p/6bce4e256381

http://bxbxbai.github.io/2014/10/25/use-trace-view/

https://www.jianshu.com/p/1b64024f2d08

https://mp.weixin.qq.com/s/2wxLTb8gaYg5aRw3QdVAdA

https://blog.csdn.net/u012267215/article/details/86013223

https://blog.csdn.net/carson_ho/article/details/79549417)

https://www.baidu.com/link?url=Y5v5WLhIwXiNEGVGY5dYSKF3fojrb806fHnEDnNw-aGBX8Nv2vXxVoRchaD4UcUF&wd=&eqid=832d3037000dd3de000000035eef2549

本文由 开了肯 发布!

同步公众号[开了肯]

image-20200404120045271
image-20200404120045271