深入探索Android布局优化
Android的绘制优化其实可以分为两个部分,即布局(UI)优化和卡顿优化,而布局优化的核心问题就是要解决因布局渲染性能不佳而导致应用卡顿的问题,所以它可以认为是卡顿优化的一个子集。对于Android开发来说,写布局可以说是一个比较简单的工作,但是如果想将写的每一个布局的渲染性能提升到比较好的程度,要付出的努力是要远远超过写布局所付出的。由于布局优化这一主题包含的内容太多,因此,笔者将它分为了上、中、下三篇,本篇,即为深入探索Android布局优化的上篇。本篇包含的主要内容如下所示:
- 1、绘制原理
- 2、屏幕适配
- 3、优化工具
说到Android的布局绘制,那么我们就不得不先从布局的绘制原理开始说起。
一、绘制原理
Android的绘制实现主要是借助CPU与GPU结合刷新机制共同完成的。
1、CPU与GPU
- CPU负责计算显示内容,包括Measure、Layout、Record、Execute等操作。在UI绘制上的缺陷在于容易显示重复的视图组件,这样不仅带来重复的计算操作,而且会占用额外的GPU资源。
- GPU负责栅格化(用于将UI元素绘制到屏幕上,即将UI组件拆分到不同的像素上显示)。
这里举两个栗子来讲解一些CPU和GPU的作用:
- 1、文字的显示首先经过CPU换算成纹理,然后再传给GPU进行渲染。
- 2、而图片的显示首先是经过CPU的计算,然后加载到内存当中,最后再传给GPU进行渲染。
那么,软件绘制和硬件绘制有什么区别呢?我们先看看下图:
这里软件绘制使用的是Skia库(一款在低端设备如手机上呈现高质量的 2D 图形的 跨平台图形框架)进行绘制的,而硬件绘制本质上是使用的OpenGl ES接口去利用GPU进行绘制的。OpenGL是一种跨平台的图形API,它为2D/3D图形处理硬件指定了标准的软件接口。而OpenGL ES是用于嵌入式设备的,它是OpenGL规范的一种形式,也可称为其子集。
并且,由于OpenGl ES系统版本的限制,有很多 绘制API 都有相应的 Android API level 的限制,此外,在Android 7.0 把 OpenGL ES 升级到最新的 3.2 版本的时候,还添加了对Vulkan(一套适用于高性能 3D 图形的低开销、跨平台 API)的支持。Vulan作为下一代图形API以及OpenGL的继承者,它的优势在于大幅优化了CPU上图形驱动相关的性能。
2、Android 图形系统的整体架构
Android官方的架构图如下:
为了比较好的描述它们之间的作用,我们可以把应用程序图形渲染过程当作一次绘画过程,那么绘画过程中 Android 的各个图形组件的作用分别如下:
- 画笔:Skia 或者 OpenGL。我们可以用 Skia去绘制 2D 图形,也可以用 OpenGL 去绘制 2D/3D 图形。
- 画纸:Surface。所有的元素都在 Surface 这张画纸上进行绘制和渲染。在 Android 中,Window 是 View 的容器,每个窗口都会关联一个 Surface。而 WindowManager 则负责管理这些窗口,并且把它们的数据传递给 SurfaceFlinger。
- 画板:Graphic Buffer。Graphic Buffer 缓冲用于应用程序图形的绘制,在 Android 4.1 之前使用的是双缓冲机制,而在 Android 4.1 之后使用的是三缓冲机制。
- 显示:SurfaceFlinger。它将 WindowManager 提供的所有 Surface,通过硬件合成器 Hardware Composer 合成并输出到显示屏。
在了解完Android图形系统的整体架构之后,我们还需要了解下Android系统的显示原理,关于这块内容可以参考我之前写的Android性能优化之绘制优化的Android系统显示原理一节。
3、RenderThread
在Android系统的显示过程中,虽然我们利用了GPU的图形高性能计算的能力,但是从计算Display到通过GPU绘制到Frame Buffer都在UI线程中完成,此时如果能让GPU在不同的线程中进行绘制渲染图形,那么绘制将会更加地流畅。
于是,在Android 5.0之后,引入了RenderNode和RenderThread的概念,它们的作用如下:
- RenderNode:进一步封装了Display和某些View的属性。
- RenderThread:渲染线程,负责执行所有的OpenGl命令,其中的RenderNode保存有渲染帧的所有信息,能在主线程有耗时操作的前提下保证动画流畅。
CPU将数据同步给GPU之后,通常不会阻塞等待RenderThread去利用GPU去渲染完视图,而是通知结束之后就返回。加入ReaderThread之后的整个显示调用流程图如下图所示:
在Android 6.0之后,其在adb shell dumpsys gxinfo命令中添加了更加详细的信息,在优化工具一节中我将详细分析下它的使用。
在Android 7.0之后,对HWUI进行了重构,它是用于2D硬件绘图并负责硬件加速的主要模块,其使用了OpenGl ES来进行GPU硬件绘图。此外,Android 7.0还支持了Vulkan,并且,Vulkan 1.1在Android 被引入。
硬件加速存在哪些问题?
我们都知道,硬件加速的原理就是将CPU不擅长的图形计算转换成GPU专用指令。
- 1、其中的OpenGl API调用和Graphic Buffer缓冲区至少会占用几MB以上的内存,内存消耗较大。
- 2、有些OpenGl的绘制API还没有支持,特别是比较低的Android系统版本,并且由于Android每一个版本都会对渲染模块进行一些重构,导致了在硬件加速绘制过程中会出现一些不可预知的Bug。如在Android 5.0~7.0机型上出现的libhwui.so崩溃问题,需要使用inline Hook、GOT Hook等native调试手段去进行分析定位,可能的原因是ReaderThread与UI线程的sync同步过程出现了差错,而这种情况一般都是有多个相同的视图绘制而导致的,比如View的复用、多个动画同时播放。
4、刷新机制
16ms发出VSync信号触发UI渲染,大多数的Android设备屏幕刷新频率为60HZ,如果16ms内不能完成渲染过程,则会产生掉帧现象。
二、屏幕适配
我们都知道,Android手机屏幕的差异化导致了严重的碎片化问题,并且屏幕材质也是用户比较关注的一个重要因素。
首先,我们来了解下主流Android屏幕材质,目前主要有两类:
- LCD(Liquid Crystal Display):液晶显示器。
- OLED(Organic Light-Emitting Diode ):有机发光二极管。
早在20世纪60年代,随着半导体集成电路的发展,美国人成功研发出了第一块液晶显示屏LCD,而现在大部分最新的高端机使用的都是OLED材质,这是因为相比于LCD屏幕,OLED屏幕在色彩、可弯曲程度、厚度和耗电等方面都有一定的优势。正因为如此,现在主流的全面屏、曲面屏与未来的柔性折叠屏,使用的几乎都是 OLED 材质。当前,好的材质,它的成本也必然会比较昂贵。
1、OLED 屏幕和 LCD 屏幕的区别
如果要明白OLED 屏幕和LCD屏幕的区别,需要了解它们的运行原理,下面,我将分别进行讲解。
屏幕的成像原理
屏幕由无数个点组成,并且,每个点由红绿蓝三个子像素组成,每个像素点通过调节红绿蓝子像素的颜色配比来显示不同的颜色,最终所有的像素点就会形成具体的画面。
LCD背光源与OLED自发光
下面,我们来看下LCD和OLED的总体结构图,如下所示:
LCD的发光原理主要在于背光层Back-light,它通常都会由大量的LED背光灯组成以用于显示白光,之后,为了显示出彩色,在其上面加了一层有颜色的薄膜,白色的背光穿透了有颜色的薄膜后就可以显示出彩色了。但是,为了实现调整红绿蓝光的比例,需要在背光层和颜色薄膜之间加入一个控制阀门,即液晶层liquid crystal,它可以通过改变电压的大小来控制开合的程度,开合大则光多,开合小则光少。
对于OLED来说,它不需要LCD屏幕的背光层和用于控制出光量的液晶层,它就像一个有着无数个小的彩色灯泡组成的屏幕,只需要给它通电就能发光。
LCD的致命缺陷
它的液晶层不能完全关合,如果LCD显示黑色,会有部分光穿过颜色层,所以LCD的黑色实际上是白色和黑色混合而成的灰色。而OLED不一样,OLED显示黑色的时候可以直接关闭区域的像素点。
此外,由于背光层的存在,所以LCD显示器的背光非常容易从屏幕与边框之间的缝隙泄漏出去,即会产生显示器漏光现象。
OLED屏幕的优势
- 1、由于没有有背光层和液晶层的存在,所以它的**厚度更薄,其弯曲程度可以达到180%**。
- 2、对比度(白色比黑色的比值)更高,使其画面颜色越浓;相较于LCD来说,OLED是油画,色彩纯而细腻,而LCD是水彩笔画,色彩朦胧且淡。
- 3、OLED每个像素点都是独立的,所以OLED可以单独点亮某些像素点,即能实现单独点亮。而LCD只能控制整个背光层的开关。并且,由于OLED单独点亮的功能,使其耗电程度大大降低。
- 4、OLED的屏幕响应时间很快,不会造成画面残留以致造成视觉上的拖影现象。而LCD则会有严重的拖影现象。
OLED屏幕的劣势
- 1、由于OLED是有机材料,导致其寿命是不如LCD的
有机材料的。并且,由于OLED单独点亮的功能,会使每个像素点工作的时间不一样,这样,在屏幕老化时就会导致色彩显示不均匀,即产生烧屏现象。
- 2、由于OLED就不能采取控制电压的方式去调整亮度,所以目前只能通过不断的开关开关开关去进行调光。
- 3、OLED的屏幕像素点排列方式不如LCD的紧凑,所以在分辨率相同的情况下,OLED的屏幕是不如LCD清楚的。即OLED的像素密度较低。
2、屏幕适配方案
我们都知道,Android 的 系统碎片化、机型以及屏幕尺寸碎片化、屏幕分辨率碎片化非常地严重。所以,一个好的屏幕适配方案是很重要的。接下来,我将介绍目前主流的屏幕适配方案。
1、最原始的Android适配方案:dp + 自适应布局或weight比例布局
首先,我们来回顾一下px、dp、dpi、ppi、density等概念:
- px:像素点,px = density * dp。
- ppi:像素密度,每英寸所包含的像素数目,屏幕物理参数,不可调整,dpi没有人为调整时 = ppi。
- dpi:像素密度,在系统软件上指定的单位尺寸的像素数量,可人为调整,dpi没有人为调整时 = ppi。
- dp:density-independent pixels,即密度无关像素,基于屏幕物理分辨率的一个抽象的单位,以dp为尺寸单位的控件,在不同分辨率和尺寸的手机上代表了不同的真实像素,比如在分辨率较低的手机中,可能1dp = 1px,而在分辨率较高的手机中,可能1dp=2px,这样的话,一个64*64dp的控件,在不同的手机中就能表现出差不多的大小了,px = dp * (dpi / 160)。
- denstiy:密度,屏幕上每平方英寸所包含的像素点个数,density = dpi / 160。
通常情况下,我们只需要使用dp + 自适应布局(如鸿神的AutoLayout、ConstraintLayout等等)或weight比例布局即可基本解决碎片化问题,当然,这种方式也存在一些问题,比如dpi和ppi的差异所导致在同一分辨率手机上控件大小的不同。
2、宽高限定符适配方案
它就是穷举市面上所有的Android手机的宽高像素值,通过设立一个基准的分辨率,其他分辨率都根据这个基准分辨率来计算,在不同的尺寸文件夹内部,根据该尺寸编写对应的dimens文件,如下图所示:
比如以480x320为基准分辨率:
- 宽度为320,将任何分辨率的宽度整分为320份,取值为x1-x320。
- 高度为480,将任何分辨率的高度整分为480份,取值为y1-y480。
那么对于800*480的分辨率的dimens文件来说:
- x1=(480/320)*1=1.5px
- x2=(480/320)*2=3px
此时,如果UI设计界面使用的就是基准分辨率,那么我们就可以按照设计稿上的尺寸填写相对应的dimens去引用,而当APP运行在不同分辨率的手机中时,系统会根据这些dimens去引用该分辨率对应的文件夹下面去寻找对应的值。但是这个方案由一个缺点,就是无法做到向下兼容去使用更小的dimens,比如说800x480的手机就一定要找到800x480的限定符,否则就只能用统一默认的dimens文件了。
3、UI适配框架AndroidAutoLayout的适配方案
因宽高限定符方案的启发,鸿神出品了一款能使用UI适配更加开发高效和适配精准的项目。
基本使用步骤如下:
第一步:在你的项目的AndroidManifest中注明你的设计稿的尺寸:
1 | <meta-data android:name="design_width" android:value="768"> |
第二步:让你的Activity继承自AutoLayoutActivity。如果你不希望继承AutoLayoutActivity,可以在编写布局文件时,直接使用AutoLinearLayout、Auto***等适配布局即可。
接下来,直接在布局文件里面使用具体的像素值就可以了,因为在APP运行时,AndroidAutoLayout会帮助我们根据不同手机的具体尺寸按比例伸缩。
AndroidAutoLayout在宽高限定符适配的基础上,解决了其dimens不能向下兼容的问题,但是它在运行时会在onMeasure里面对dimens去做变换,所以对于自定义控件或者某些特定的控件需要进行单独适配;并且,整个UI的适配过程都是由框架完成的,以后想替换成别的UI适配方案成本会比较高,而且,不幸的是,项目已经停止维护了。
4、smallestWidth适配方案(sw限定符适配)
smallestWidth即最小宽度,系统会根据当前设备屏幕的 最小宽度 来匹配 values-swdp。
我们都知道,移动设备都是允许屏幕可以旋转的,当屏幕旋转时,屏幕的高宽就会互换,加上 最小 这两个字,是因为这个方案是不区分屏幕方向的,它只会把屏幕的高度和宽度中值最小的一方认为是 最小宽度。
并且它跟宽高限定符适配原理上是一样,都是系统通过特定的规则来选择对应的文件。它与AndroidAutoLayout一样,同样解决了其dimens不能向下兼容的问题,如果该屏幕的最小宽度是360dp,但是项目中没有values-sw360dp文件夹的话,它就可能找到values-sw320dp这个文件夹,其尺寸规则命名如下图所示:
假如加入我们的设计稿的像素宽度是375,那么其对应的values-sw360dp和values-sw400dp宽度如下所示:
smallestWidth的适配机制由系统保证,我们只需要针对这套规则生成对应的资源文件即可,即使对应的smallestWidth值没有找到完全对应的资源文件,它也能向下兼容,寻找最接近的资源文件。虽然多个dimens文件可能导致apk变大,但是其增加大小范围也只是在300kb-800kb这个区间,这还是可以接受的。这套方案唯一的变数就是选择需要适配哪些最小宽度限定符的文件,如果您生成的 values-swdp 与设备实际的 最小宽度 差别不大,那误差也就在能接受的范围内,如果差别很大,那效果就会很差。最后,总结一下这套方案的优缺点:
优点:
- 1、稳定且无性能损耗。
- 2、可通过选择需要哪些最小宽度限定符文件去控制适配范围。
- 3、在自动生成values-sw的插件基础下,学习成本较低。
插件地址为自动生成values-sw的项目代码。生成需要的values-swdp文件夹的步骤如下:
- 1、clone该项目到本地,以Android项目打开。
- 2、DimenTypes文件中写入你希望适配的sw尺寸,默认的这些尺寸能够覆盖几乎所有手机适配需求。
- 3、DimenGenerator文件中填写设计稿的尺寸(DESIGN_WIDTH是设计稿宽度,DESIGN_HEIGHT是设计稿高度)。
- 4、执行lib module中的DimenGenerator.main()方法,当前地址下会生成相应的适配文件,把相应的文件连带文件夹拷贝到正在开发的项目中。
缺点:
- 1、侵入性高,后续切换其他屏幕适配方案需修改大量 dimens 引用。
- 2、覆盖更多不同屏幕的机型需要生成更多的资源文件,使APK体积变大。
- 3、不能自动支持横竖屏切换时的适配,如要支持需使用 values-wdp 或 屏幕方向限定符 再生成一套资源文件,又使APK体积变大。
如果想让屏幕宽度随着屏幕的旋转而做出改变该怎么办呢?
此时根据 values-wdp (去掉 sw 中的 s) 去生成一套资源文件即可。
如果想区分屏幕的方向来做适配该怎么办呢?
去根据 屏幕方向限定符 生成一套资源文件,后缀加上 -land 或 -port 即可,如:values-sw360dp-land (最小宽度 360 dp 横向),values-sw400dp-port (最小宽度 720 dp 纵向)。
注意:
如果UI设计上明显更适合使用wrap_content,match_parent,layout_weight等,我们就要毫不犹豫的使用,毕竟,上述都是仅仅针对不得不使用固定宽高的情况,我相信基础的UI适配知识大部分开发者还是具备的。
5、今日头条适配方案
它的原理是根据屏幕的宽度或高度动态调整每个设备的 density (每 dp 占当前设备屏幕多少像素),通过修改density值的方式,强行把所有不同尺寸分辨率的手机的宽度dp值改成一个统一的值,这样就可以解决所有的适配问题。其对应的重要公式如下:
1 | 当前设备屏幕总宽度(单位为像素)/ 设计图总宽度(单位为 dp) = density |
今日头条适配方案默认项目中只能以高或宽中的一个作为基准来进行适配,并不像 AndroidAutoLayout 一样,高以高为基准,宽以宽为基准,来同时进行适配,为什么?
因为,现在中国大部分市面上的 Android 设备的屏幕高宽比都不一致,特别是现在的全面屏、刘海屏、弹性折叠屏,使这个问题更加严重,不同厂商推出的手机的屏幕高宽比都可能不一致。所以,我们只能以高或宽其中的一个作为基准进行适配,以此避免布局在高宽比不一致的屏幕上出现变形。
它有以下优势:
- 1、使用成本低,操作简单,使用该方案后在页面布局时不需要额外的代码和操作。
- 2、侵入性低,和项目完全解耦,在项目布局时不会依赖哪怕一行该方案的代码,而且使用的还是 Android 官方的 API,意味着当你遇到什么问题无法解决,想切换为其他屏幕适配方案时,基本不需要更改之前的代码,整个切换过程几乎在瞬间完成,试错成本接近于 0。
- 3、可适配三方库的控件和系统的控件(不止是是 Activity 和 Fragment,Dialog、Toast 等所有系统控件都可以适配),由于修改的 density 在整个项目中是全局的,所以只要一次修改,项目中的所有地方都会受益。
- 4、不会有任何性能的损耗。
- 5、不涉及私有API。
它的缺点如下所示:
- 1、适配范围不可控,只能一刀切的将整个项目进行适配,这种将所有控件都强行使用我们项目自身的设计图尺寸进行适配的方案会有问题:当某个系统控件或三方库控件的设计图尺寸和和我们项目自身的设计图尺寸差距越大时,该系统控件或三方库控件的适配效果就越差。比较好的解决方案就是按 Activity 为单位,取消当前 Activity 的适配效果,改用其他的适配方案。
- 2、对旧项目的UI适配兼容性不够。
注意:
千万不要在此方案上使用smallestWidth适配方案中直接填写设计图上标注的 px 值的做法,这样会使项目强耦合于这个方案,后续切换其它方案都不得不将所有的 layout 文件都改一遍。
这里推荐一下JessYanCoding的AndroidAutoSize项目,用法如下:
1、首先在项目的build.gradle中添加该库的依赖:
1 | implementation 'me.jessyan:autosize:1.1.2' |
2、接着 AndroidManifest 中填写全局设计图尺寸 (单位 dp),如果使用副单位,则可以直接填写像素尺寸,不需要再将像素转化为 dp:
1 | <manifest> |
为什么只需在AndroidManifest.xml 中填写一下 meta-data 标签就可实现自动运行?
在 App 启动时,系统会在 App 的主进程中自动实例化声明的 ContentProvider,并调用它的 onCreate 方法,执行时机比 Application#onCreate 还靠前,可以做一些初始化的工作,这个时候我们就可以利用它的 onCreate 方法在其中启动框架。如果项目使用了多进程,调用Application#onCreate 中调用下 ContentProvider#query 就能够使用 ContentProvider 在当前进程中进行实例化。
6、小结
上述介绍的所有方案并没有哪一个是十分完美的,但我们能清晰的认识到不同方案的优缺点,并将它们的优点相结合,这样才能应付更加复杂的开发需求,创造出最卓越的产品。比如SmallestWidth 限定符适配方案 主打的是稳定性,在运行过程中极少会出现安全隐患,适配范围也可控,不会产生其他未知的影响,而 今日头条适配方案 主打的是降低开发成本、提高开发效率,使用上更灵活,也能满足更多的扩展需求。所以,具体情况具体分析,到底选择哪一个屏幕适配方案还是需要去根据我们项目自身的需求去选择。
三、优化工具
1、Systrace
早在深入探索Android启动速度优化一文中我们就了解过Systrace的使用、原理及它作为启动速度分析的用法。而它其实主要是用来分析绘制性能方面的问题。下面我就详细介绍下Systrace作为绘制优化工具有哪些必须关注的点。
1)、关注Frames
首先,先在左边栏选中我们当前的应用进程,在应用进程一栏下面有一栏Frames,我们可以看到有绿、黄、红三种不同的小圆圈,如下图所示:
图中每一个小圆圈代表着当前帧的状态,大致的对应关系如下:
- 正常:绿色。
- 丢帧:黄色。
- 严重丢帧:红色。
并且,选中其中某一帧,我们还可以在视图最下方的详情框看到该帧对应的相关的Alerts报警信息,以帮助我们去排查问题;此外,如果是大于等于Android 5.0的设备(即API Level21),创建帧的工作工作分为UI线程和render线程。而在Android 5.0之前的版本中,创建帧的所有工作都是在UI线程上完成的。接下来,我们看看该帧对应的详情图,如下所示:
对应到此帧,我们发现这里可能有两个绘制问题:Bitmap过大、布局嵌套层级过多导致的measure和layout次数过多,这就需要我们去在项目中找到该帧对应的Bitmap进行相应的优化,针对布局嵌套层级过多的问题去选择更高效的布局方式,这块后面我们会详细介绍。
2)、关注Alerts栏
此外,Systrace的显示界面还在在右边侧栏提供了一栏Alert框去显示出它所检测出所有可能有绘制性能问题的地方及对应的数量,如下图所示:
在这里,我们可以将Alert框看做是一个是待修复的Bug列表,通常一个区域的改进可以消除应用程序中的所有类中该类型的警报,所以,不要为这里的警报数量所担忧。
2、Layout Inspector
Layout Inspector是AndroidStudio自带的工具,它的主要作用就是用来查看视图层级结构的。
具体的操作路径为:
1 | 点击Tools工具栏 ->第三栏的Layout Inspector -> 选中当前的进程 |
下面为操作之后打开的Awesome-WanAndroid首页图,如下所示:
其中,最左侧的View Tree就是用来查看视图的层级结构的,非常方便,这是它最主要的功能,中间的是一个屏幕截图,最右边的是一个属性表格,比如我在截图中选中某一个TextView(Kotlin/入门及知识点一栏),在属性表格的text中就可以显示相关的信息,如下图所示:
3、Choreographer
Choreographer是用来获取FPS的,并且可以用于线上使用,具备实时性,但是仅能在Api 16之后使用,具体的调用代码如下:
1 | Choreographer.getInstance().postFrameCallback(); |
使用Choreographer获取FPS的完整代码如下所示:
1 | private long mStartFrameTime = 0; |
通过以上方式我们就可以实现实时获取应用的界面的FPS了。但是我们需要排除掉页面没有操作的情况,即只在界面存在绘制的时候才做统计。我们可以通过 addOnDrawListener 去监听界面是否存在绘制行为,代码如下所示:
1 | getWindow().getDecorView().getViewTreeObserver().addOnDrawListener |
当出现丢帧的时候,我们可以获取应用当前的页面信息、View 信息和操作路径上报至 APM后台,以降低二次排查的难度。此外,我们将连续丢帧超过 700 毫秒定义为冻帧,也就是连续丢帧 42 帧以上。这时用户会感受到比较明显的卡顿现象,因此,我们可以统计更有价值的冻帧率。冻帧率就是计算发生冻帧时间在所有时间的占比。通过解决应用中发生冻帧的地方我们就可以大大提升应用的流畅度。
4、Tracer for OpenGL ES 与 GAPID(Graphics API Debugger)
Tracer for OpenGL ES 是 Android 4.1 新增加的工具,它可逐帧、逐函数的记录 App 使用 OpenGL ES 的绘制过程,并且,它可以记录每个 OpenGL 函数调用的消耗时间。当使用Systrace还找不到渲染问题时,就可以去尝试使用它。
而GAPID是 Android Studio 3.1 推出的工具,可以认为是Tracer for OpenGL ES的进化版,它不仅实现了跨平台,而且支持Vulkan与回放。由于它们主要是用于OpenGL相关开发的使用,这里我就不多介绍了。
5、自动化测量 UI 渲染性能的方式
在自动化测试中,我们通常希望通过执行性能测试的自动化脚本来进行线下的自动化检测,那么,有哪些命令可以用于测量UI渲染的性能呢?
我们都知道,dumpsys是一款输出有关系统服务状态信息的Android工具,利用它我们可以获取当前设备的UI渲染性能信息,目前常用的有如下两种命令:
1)、gfxinfo
gfxinfo的主要作用是输出各阶段发生的动画与帧相关的信息,命令格式如下:
1 | adb shell dumpsys gfxinfo <PackageName> |
这里我以Awesome-WanAndroid项目为例,输出其对应的gfxinfo信息如下所示:
1 | quchao@quchaodeMacBook-Pro ~ % adb shell dumpsys gfxinfo json.chao.com.wanandroid |
下面,我将对其中的关键信息进行分析。
帧的聚合分析数据
开始的一栏是统计的当前界面所有帧的聚合分析数据,主要作用是综合查看App的渲染性能以及帧的稳定性。
- Graphics info for pid 1722 [json.chao.com.wanandroid] -> 说明了当前提供的是Awesome-WanAndroid应用界面的帧信息,对应的进程id为1722。
- Total frames rendered 5210 -> 本次dump的数据搜集了5210帧的信息。
- Janky frames: 193 (3.70%) -> 5210帧中有193帧发生了Jank,即单帧耗时时间超过了16ms,卡顿的概率为3.70%。
- 50th percentile: 5ms -> 所有帧耗时排序后,其中前50%最大的耗时帧的耗时为5ms。
- 90th percentile: 9ms -> 同上,依次类推。
- 95th percentile: 13ms -> 同上,依次类推。
- 99th percentile: 34ms -> 同上,依次类推。
- Number Missed Vsync: 31 -> 垂直同步失败的帧数为31。
- Number High input latency: 0 -> 处理input耗时的帧数为0。
- Number Slow UI thread: 153 -> 因UI线程的工作而导致耗时的帧数为153。
- Number Slow bitmap uploads: 6 -> 因bitmap加载导致耗时的帧数为6。
- Number Slow issue draw commands: 51 -> 因绘制问题导致耗时的帧数为51。
- HISTOGRAM: 5ms=4254 6ms=131 7ms=144 8ms=87… -> 直方图数据列表,说明了耗时0
5ms的帧数为4254,耗时56ms的帧数为131,后续的数据依次类推即可。
后续的log数据表明了不同组件的缓存占用信息,帧的建立路径信息以及总览信息等等,参考意义不大。
可以看到,上述的数据只能让我们总体感受到绘制性能的好坏,并不能去定位具体帧的问题,那么,还有更好的方式去获取具体帧的信息吗?
添加framestats去获取最后120帧的详细信息
该命令的格式如下:
1 | adb shell dumpsys gfxinfo <PackageName> framestats |
这里还是以Awesome-WanAndroid项目为例,输出项目标签页的帧详细信息:
1 | quchao@quchaodeMacBook-Pro ~ % adb shell dumpsys gfxinfo json.chao.com.wanandroid framestats |
这里我们只需关注其中的PROFILEDATA一栏,因为它表明了最近120帧每个帧的状态信息。
因为其中的数据是以csv格式显示的,我们将PROFILEDATA中的数据全部拷贝过来,然后放入一个txt文件中,接着,把.txt后缀改为.csv,使用WPS表格工具打开,如下图所示:
从上图中,我们看到输出的第一行是对应的输出数据列的格式,下面我将详细进行分析。
Flags:
- Flags为0则可计算得出该帧耗时:FrameCompleted - IntendedVsync。
- Flags为非0则表示绘制时间超过16ms,为异常帧。
IntendedVsync:
- 帧的预期Vsync时刻,如果预期的Vsync时刻与现实的Vsync时刻不一致,则表明UI线程中有耗时工作导致其无法响应Vsync信号。
Vsync:
- 花费在Vsync监听器和帧绘制的时间,比如Choreographer frame回调、动画、View.getDrawingTime等待。
- 理解Vsync:Vsync避免了在屏幕刷新时,把数据从后台缓冲区复制到帧缓冲区所消耗的时间。
OldestInputEvent:
- 输入队列中最旧输入事件的时间戳,如果没有输入事件,则此列数据都为Long.MAX_VALUE。
- 通常用于framework层开发。
NewestInputEvent:
- 输入队列中最新输入时间的时间戳,如果没有输入事件,则此列数据都为0。
- 计算App大致的延迟添加时间:FrameCompleted - NewestInputEvent。
- 通常用于framework层开发。
HandleInputStart:
- 将输入事件分发给App对应的时间戳时刻。
- 用于测量App处理输入事件的时间:AnimationStart - HandleInputStart。当值大于2ms时,说明程序花费了很长的时间来处理输入事件,比如View.onTouchEvent等事件。注意在Activity切换或产生点击事件时此值一般都比较大,此时是可以接受的。
AnimationStart:
- 运行Choreographer(舞蹈编排者)注册动画的时间戳。
- 用来评估所有运行的所有动画器(ObjectAnimator、ViewPropertyAnimator、常用转换器)需要多长时间:AnimationStart - PerformTraversalsStart。当值大于2ms时,请查看此时是否执行的是自定义动画且动画是否有耗时操作。
PerformTraversalsStart:
- 执行布局递归遍历开始的时间戳。
- 用于获取measure、layout的时间:DrawStart - PerformTraversalsStart。(注意滚动或动画期间此值应接近于0)。
DrawStart:
- draw阶段开始的时间戳,它记录了任何无效视图的DisplayList的起点。
- 用于获取视图数中所有无效视图调用View.draw方法所需的时间:SyncStart - DrawStart。
- 在此过程中,硬件加速模块中的DisplayList发挥了重要作用,Android系统仍然使用invalidate()调用draw()方法请求屏幕更新和渲染视图,但是对实际图形的处理方式有所不同。Android系统并没有立即执行绘图命令,而是将它们记录在DisplayList中,该列表包含视图层次结构绘图所需的所有信息。相对于软件渲染的另一个优化是,Android系统仅需要记录和更新DispalyList,以显示被invalidate() 标记为dirty的视图。只需重新发布先前记录的Displaylist,即可重新绘制尚未失效的视图。此时的硬件绘制模型主要包括三个过程:刷新视图层级、记录和更新DisplayList、绘制DisplayList。相对于软件绘制模型的刷新视图层级、然后直接去绘制视图层级的两个步骤,虽然多了一个步骤,但是节省了很多不必要的绘制开销。
SyncQueued:
- sync请求发送到RenderThread线程的时间戳。
- 获取sync就绪所花费的时间:SyncStart - SyncQueued。如果值大于0.1ms,则说明RenderThread正在忙于处理不同的帧。
SyncStart:
- 绘图的sync阶段开始的时间戳。
- IssueDrawCommandsStart - SyncStart > 0.4ms左右则表明有许多新的位图需要上传至GPU。
IssueDrawCommandsStart:
- 硬件渲染器开始GPU发出绘图命令的时间戳。
- 用于观察App此时绘制时消耗了多少GPU:FrameCompleted - IssueDrawCommandsStart。
SwapBuffers:
- eglSwapBuffers被调用时的时间戳。
- 通常用于Framework层开发。
FrameCompleted:
- 当前帧完成绘制的时间戳。
- 获取当前帧绘制的总时间:FrameCompleted - IntendedVsync。
综上,我们可以利用这些数据计算获取我们在自动化测试中想关注的因素,比如帧耗时、该帧调用View.draw方法所消耗的时间。framestats和帧耗时信息等一般2s收集一次,即一次120帧。为了精确控制收集数据的时间窗口,如将数据限制为特定的动画,可以重置计数器,重新聚合统计的信息,对应命令如下:
1 | adb shell dumpsys gfxinfo <PackageName> reset |
2)、SurfaceFlinger
我们都知道,在Android 4.1以后,系统使用了三级缓冲机制,即此时有三个Graphic Buffer,那么如何查看每个Graphic Buffer占用的内存呢?
答案是使用SurfaceFlinger,命令如下所示:
1 | adb shell dumpsys SurfaceFlinger |
输出的结果非常多,因为包含很多系统应用和界面的相关信息,这里我们仅过滤出Awesome-WanAndroid应用对应的信息:
1 | + Layer 0x7f5a92f000 (json.chao.com.wanandroid/json.chao.com.wanandroid.ui.main.activity.MainActivity#0) |
在Slots中,显示的是缓冲区相关的信息,可以看到,此时App使用的是00号缓冲区,即第一个缓冲区。
接着,在SurfaceFlinger命令输出log的最下方有一栏Allocated buffers,这这里可以使用当前缓冲区对应的对象地址去查询其占用的内存大小。具体对应到我们这里的是0x7f5b1ca580,匹配到的结果如下所示:
1 | 0x7f5b052a00: 9690.00 KiB | 1080 (1088) x 2280 | 1 | 1 | 0x10000900 | json.chao.com.wanandroid/json.chao.com.wanandroid.ui.main.activity.MainActivity#0 |
可以看到,这里每一个Graphic Buffer都占用了9MB多的内存,通常分辨率越大,单个Graphic Buffer占用的内存就越多,如1080 x 1920的手机屏幕,一般占用8160kb的内存大小。此外,如果应用使用了其它的Surface,如SurfaceView或TextureView(两者一般用在opengl进行图像处理或视频处理的过程中),这个值会更大。如果当App退到后台,系统就会将这部分内存回收。
了解了常用布局优化常用的工具与命令之后,我们就应该开始着手进行优化了,但在开始之前,我们还得对Android的布局加载原理有比较深入的了解。
四、总结(上)
在本篇文章中,我们主要对Android的布局绘制以及加载原理、优化工具、全局监控布局和控件的加载耗时进行了全面的讲解,这为大家学习《深入探索Android布局优化(下)》打下了良好的基础。下面,总结一下本篇文章涉及的三大主题:
- 1、绘制原理:CPU\GPU、Android图形系统的整体架构、绘制线程、刷新机制。
- 2、屏幕适配:OLED 屏幕和 LCD 屏幕的区别、屏幕适配方案。
- 3、优化工具:使用Systrace来进行布局优化、利用Layout Inspector来查看视图层级结构、采用Choreographer来获取FPS以及自动化测量 UI 渲染性能的方式(gfxinfo、SurfaceFlinger等dumpsys命令)。
四、布局加载原理
1、为什么要了解Android布局加载原理?
知其然知其所以然,不仅要明白在平时开发过程中是怎样对布局API进行调用,还要知道它内部的实现原理是什么。明白具体的实现原理与流程之后,我们可能会发现更多可优化的点。
2、布局加载源码分析
我们都知道,Android的布局都是通过setContentView()这个方法进行设置的,那么它的内部肯定实现了布局的加载,接下来,我们就详细分析下它内部的实现原理与流程。
以Awesome-WanAndroid项目为例,我们在通用Activity基类的onCreate方法中进行了布局的设置:
1 | setContentView(getLayoutId()); |
点进去,发现是调用了AppCompatActivity的setContentView方法:
1 |
|
这里的setContentView其实是AppCompatDelegate这个代理类的抽象方法:
1 | /** |
在这个抽象方法的左边,会有一个绿色的小圆圈,点击它就可以查看到对应的实现类与方法,这里的实现类是AppCompatDelegateImplV9,实现方法如下所示:
1 |
|
setContentView方法中主要是获取到了content父布局,移除其内部所有视图之后并最终调用了LayoutInflater对象的inflate去加载对应的布局。接下来,我们关注inflate内部的实现:
1 | public View inflate(int resource, ViewGroup root) { |
这里只是调用了inflate另一个的重载方法:
1 | public View inflate(int resource, ViewGroup root, boolean attachToRoot) { |
在注释1处,通过Resources的getLayout方法获取到了一个XmlResourceParser对象,继续跟踪下getLayout方法:
1 | public XmlResourceParser getLayout(int id) throws NotFoundException { |
这里继续调用了loadXmlResourceParser方法,注意第二个参数传入的为layout,说明此时加载的是一个Xml资源布局解析器。我们继续跟踪loadXmlResourceParse方法:
1 |
|
在注释1处,如果值类型为字符串的话,则调用了ResourcesImpl实例的loadXmlResourceParser方法。我们首先看看这个方法的注释:
1 | /** |
注释的意思说明了这个方法是用于加载指定文件的Xml解析器,这里我们之间查看关键的mAssets.openXmlBlockAsset方法,这里的mAssets对象是AssetManager类型的,看看AssetManager实例的openXmlBlockAsset方法做了什么处理:
1 | /** |
可以看到,最终是调用了注释1处的openXmlAssetNative方法,这是定义在AssetManager中的一个Native方法:
1 | private native final long openXmlAssetNative(int cookie, String fileName); |
与此同时,我们可以猜到读取Xml文件肯定是通过IO流的方式进行的,而openXmlBlockAsset方法后抛出的IOException异常也验证了我们的想法。因为涉及到IO流的读取,所以这里是Android布局加载流程一个耗时点 ,也有可能是我们后续优化的一个方向。
分析完Resources实例的getLayout方法的实现之后,我们继续跟踪inflate方法的注释2处:
1 | public View inflate(int resource, ViewGroup root, boolean attachToRoot) { |
infalte的实现代码如下:
1 | public View inflate(XmlPullParser parser, boolean attachToRoot) { ViewGroup root, |
可以看到,infalte内部是通过XmlPull解析的方式对布局的每一个节点进行创建对应的视图的。首先,在注释1处会判断节点是否是merge标签,如果是,则对merge标签进行校验,如果merge节点不是当前布局的父节点,则抛出异常。然后,在注释2处,通过createViewFromTag方法去根据每一个标签创建对应的View视图。我们继续跟踪下createViewFromTag方法的实现:
1 | private View createViewFromTag(View parent, String name, Context context, AttributeSet attrs) { |
在createViewFromTag方法中,首先会判断mFactory2是否存在,存在就会使用mFactory2的onCreateView方法区创建视图,否则就会调用mFactory的onCreateView方法,接下来,如果此时的tag是一个Fragment,则会调用mPrivateFactory的onCreateView方法,否则的话,最终都会调用LayoutInflater实例的createView方法:
1 | public final View createView(String name, String prefix, AttributeSet attrs) |
LayoutInflater的createView方法中,首先,在注释1处,使用类加载器创建了对应的Class实例,然后在注释2处根据Class实例获取到了对应的构造器实例,并最终在注释3处通过构造器实例constructor的newInstance方法创建了对应的View对象。可以看到,在视图节点的创建过程中采用到了反射,我们都知道反射是比较耗性能的,过多的反射可能会导致布局加载过程变慢,这个点可能是后续优化的一个方向。
最后,我们来总结下Android中的布局加载流程:
- 1、在setContentView方法中,会通过LayoutInflater的inflate方法去加载对应的布局。
- 2、inflate方法中首先会调用Resources的getLayout方法去通过IO的方式去加载对应的Xml布局解析器到内存中。
- 3、接着,会通过createViewFromTag根据每一个tag创建具体的View对象。
- 4、它内部主要是按优先顺序为Factory2和Factory的onCreatView、createView方法进行View的创建,而createView方法内部采用了构造器反射的方式实现。
从以上分析可知,在Android的布局加载流程中,性能瓶颈主要存在两个地方:
- 1、布局文件解析中的IO过程。
- 2、创建View对象时的反射过程。
3、LayoutInflater.Factory分析
在前面分析的View的创建过程中,我们明白系统会优先使用Factory2和Factory去创建对应的View,那么它们究竟是干什么的呢?
其实LayoutInflater.Factory是layoutInflater中创建View的一个Hook,Hook即挂钩,我们可以利用它在创建View的过程中加入一些日志或进行其它更高级的定制化处理:比如可以全局替换自定义的TextView等等。
接下来,我们查看下Factory2的实现:
1 | public interface Factory2 extends Factory { |
可以看到,Factory2是直接继承于Factory,继续跟踪下Factory的源码:
1 | public interface Factory { |
onCreateView方法中的第一个参数就是指的tag名字,比如TextView等等,我们还注意到Factory2比Factory的onCreateView方法多一个parent的参数,这是当前创建的View的父View。看来,Factory2比Factory功能要更强大一些。
最后,我们总结下Factory与Factory2的区别:
- 1、Factory2继承与Factory。
- 2、Factory2比Factory的onCreateView方法多一个parent的参数,即当前创建View的父View。
五、获取界面布局耗时
1、常规方式
如果要获取每个界面的加载耗时,我们就必需在setContentView方法前后进行手动埋点。但是它有如下缺点:
- 1、不够优雅。
- 2、代码有侵入性。
2、AOP
关于AOP的使用,我在《深入探索Android启动速度优化》一文的AOP(Aspect Oriented Programming)打点部分已经详细讲解过了,这里就不再赘述,还不了解的同学可以点击上面的链接先去学习下AOP的使用。
我们要使用AOP去获取界面布局的耗时,那么我们的切入点就是setContentView方法,声明一个@Aspect注解的PerformanceAop类,然后,我们就可以在里面实现对setContentView进行切面的方法,如下所示:
1 |
|
为了获取方法的耗时,我们必须使用@Around注解,这样第一个参数ProceedingJoinPoint就可以提供proceed方法去执行我们的setContentView方法,在此方法的前后就可以获取setContentView方法的耗时。后面的execution表明了在setContentView方法执行内部去调用我们写好的getSetContentViewTime方法,后面括号内的*是通配符,表示匹配任何Activity的setContentView方法,并且方法参数的个数和类型不做限定。
完成AOP获取界面布局耗时的方法之后,重装应用,打开几个Activity界面,就可以看到如下的界面布局加载耗时日志:
1 | 2020-01-01 12:20:17.605 12297-12297/json.chao.com.wanandroid I/WanAndroid-PEGASILOG: │ [PerformanceAop.java | 36 | getSetContentViewTime] AppCompatActivity.setContentView(..) cost 174 |
可以看到,Awesome-WanAndroid项目里面各个界面的加载耗时一般都在几十毫秒作用,加载慢的界面可能会达到100多ms,当然,不同手机的配置不一样,但是,这足够让我们发现哪些界面布局的加载比较慢。
3、LayoutInflaterCompat.setFactory2
上面我们使用了AOP的方式监控了Activity的布局加载耗时,那么,如果我们需要监控每一个控件的加载耗时,该怎么实现呢?
答案是使用LayoutInflater.Factory2,我们在基类Activity的onCreate方法中直接使用LayoutInflaterCompat.setFactory2方法对Factory2的onCreateView方法进行重写,代码如下所示:
1 |
|
这样我们就实现了利用LayoutInflaterCompat.Factory2全局监控Activity界面每一个控件加载耗时的处理,后续我们可以将这些数据上传到我们自己的APM服务端,作为监控数据可以分析出哪些控件加载比较耗时。当然,这里我们也可以做全局的自定义控件替换处理,比如在上述代码中,我们可以将TextView全局替换为自定义的TextView。
然后,我们注意到这里我们使用getDelegate().createView方法来创建对应的View实例,跟踪进去发现这里的createView是一个抽象方法:
1 | public abstract View createView( View parent, String name, Context context, |
它对应的实现方法为AppCompatDelegateImplV9对象的createView方法,代码如下所示:
1 |
|
这里最终又调用了AppCompatViewInflater对象的createView方法:
1 | public final View createView(View parent, final String name, Context context, |
在AppCompatViewInflater对象的createView方法中系统根据不同的tag名字创建出了对应的AppCompat兼容控件。看到这里,我们明白了Android系统是使用了LayoutInflater的Factor2/Factory结合了AppCompat兼容类来进行高级版本控件的兼容适配的。
接下来,我们注意到注释1处,setFactory2方法需在super.onCreate方法前调用,否则无效,这是为什么呢?
这里可以先大胆猜测一下,可能是因为在super.onCreate()方法中就需要将Factory2实例存储到内存中以便后续使用。下面,我们就跟踪一下super.onCreate()的源码,看看是否如我们所假设的一样。AppCompatActivity的onCreate方法如下所示:
1 |
|
第一行的delegate实例的installViewFactory()方法就吸引了我们的注意,因为它包含了一个敏感的关键字“Factory“,这里我们继续跟踪进installViewFactory()方法:
1 | public abstract void installViewFactory(); |
这里一个是抽象方法,点击左边绿色圆圈,可以看到这里具体的实现类为AppCompatDelegateImplV9,其实现的installViewFactory()方法如下所示:
1 |
|
可以看到,如果我们在super.onCreate()方法前没有设置LayoutInflater的Factory2实例的话,这里就会设置一个默认的Factory2。最后,我们再来看下默认Factory2的onCreateView方法的实现:
1 | /** |
在注释1处,我们首先会尝试让Activity的Facotry实例去加载对应的View实例,如果Factory不能够处理它,在注释2处,就会调用createView方法去创建对应的View,AppCompatDelegateImplV9类的createView方法的实现上面我们已经分析过了,此处就不再赘述了。
总结(中)
在本篇文章中,我们主要对Android的全局监控布局和控件的加载耗时进行了全面的讲解,这为大家学习《深入探索Android布局优化(下)》打下了良好的基础。下面,总结一下本篇文章涉及的两大大主题:
- 4、布局加载原理:布局加载源码分析、LayoutInflater.Factory分析。
- 5、获取界面布局耗时:使用AOP的方式去获取界面加载的耗时、利用LayoutInflaterCompat.setFactory2去监控每一个控件加载的耗时。
下面,笔者将与大家一起进入进行布局优化的实操环节。
六、布局优化常规方案
布局优化的方法有很多,大部分主流的方案笔者已经在Android性能优化之绘制优化里讲解过了。下面,我将介绍一些其它的优化方案。
1、布局Inflate优化方案演进
1、代码动态创建View
使用Java代码动态添加控件的简单示例如下:
1 | Button button=new Button(this); |
2、替换MessageQueue来实现异步创建View
在使用子线程创建视图控件的时候,我们可以把子线程Looper的MessageQueue替换成主线程的MessageQueue,在创建完需要的视图控件后记得将子线程Looper中的MessageQueue恢复为原来的。在Awesome-WanAndroid项目下的UiUtils的Ui优化工具类中,提供了相应的实现,代码如下所示:
1 | /** |
3、AsynclayoutInflater异步创建View
在第三小节中,我们对Android的布局加载原理进行了深入地分析,从中我们得出了布局加载过程中的两个耗时点:
- 1、布局文件读取慢:IO过程。
- 2、创建View慢:使用反射,比直接new的方式要慢3倍。布局嵌套层级越多,控件个数越多,反射的次数就会越频繁。
很明显,我们无法从根本上去解决这两个问题,但是Google提供了一个从侧面解决的方案:使用AsyncLayoutInflater去异步加载对应的布局,它的特点如下:
- 1、工作线程加载布局。
- 2、回调主线程。
- 3、节省主线程时间。
接下来,我将详细地介绍AsynclayoutInflater的使用。
首先,在项目的build.gradle中进行配置:
1 | implementation 'com.android.support:asynclayoutinflater:28.0.0' |
然后,在Activity中的onCreate方法中将setContentView注释:
1 | super.onCreate(savedInstanceState); |
接着,在super.onCreate方法前继续布局的异步加载:
1 | // 使用AsyncLayoutInflater进行布局的加载 |
接下来,我们来分析下AsyncLayoutInflater的实现原理与工作流程。
由于我们是使用new的方式创建的AsyncLayoutInflater,所以我们先来看看它的构造函数:
1 | public AsyncLayoutInflater( { Context context) |
在注释1处,创建了一个BasicInflater,它内部的onCreateView并没有使用Factory做AppCompat控件兼容的处理:
1 | protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException { |
由前面的分析可知,在createView方法中仅仅是做了反射创建出对应View的处理。
接着,在注释2处,创建了一个全局的Handler对象,主要是用于将异步线程创建好的View实例及其相关信息回调到主线程。
最后,在注释3处,获取了一个用于异步加载View的线程实例。
接着,我们继续跟踪AsyncLayoutInflater实例的inflate方法:
1 |
|
在注释1处,这里使用InflateRequest对象将我们传进来的三个参数进行了包装,并最终将这个InflateRequest对象加入了mInflateThread线程中的一个ArrayBlockingQueue中:
1 | public void enqueue(AsyncLayoutInflater.InflateRequest request) { |
并且,在InflateThread这个静态内部类的静态代码块中调用了其自身实例的start方法以启动线程:
1 | static { |
在run方法中,使用了死循环的方式去不断地调用runInner方法,在runInner方法中,首先在注释1处从ArrayBlockingQueue队列中获取一个InflateRequest对象,然后在注释2处将异步加载好的view对象赋值给了InflateRequest对象,最后,在注释3处,将请求作为消息发送给了Handler的handleMessage:
1 | private Callback mHandlerCallback = new Callback() { |
在handleMessage方法中,当异步加载得到的view为null时,此时在注释1处还做了一个fallback处理,直接在主线程进行view的加载,以此兼容某些异常情况,最后,就调用了回调接口的onInflateFinished方法将view的相关信息返回给Activity对象。
小结
由以上分析可知,AsyncLayoutInflater是通过侧面缓解的方式去缓解布局加载过程中的卡顿,但是它依然存在一些问题:
- 1、不能设置LayoutInflater.Factory,需要通过自定义AsyncLayoutInflater的方式解决,由于它是一个final,所以需要将代码直接拷处进行修改。
- 2、因为是异步加载,所以需要注意在布局加载过程中不能有依赖于主线程的操作。
由于AsyncLayoutInflater仅仅只能通过侧面缓解的方式去缓解布局加载的卡顿,因此,我们下面将介绍一种从根本上解决问题的方案。对于AsynclayoutInflater的改进措施,可以查看祁同伟同学封装之后的代码,具体的改进分析可以查看Android AsyncLayoutInflater 限制及改进,这里附上改进之后的代码:
1 | /** |
4、使用X2C进行布局加载优化
由上分析可知,在布局加载的过程中有两个主要的耗时点,即IO操作和反射,而AsyncLayoutInflater仅仅是缓解,那么有什么方案能从根本上去解决这个问题呢?
使用Java代码写布局?
如果使用Java代码写布局,无疑从Xml文件进行IO操作的过程和反射获取View实例的过程都将被抹去。虽然这样从本质上解决了问题,但是也引入了一些新问题,如不便于开发,可维护性差等等。
那么,还有没有别的更好的方式呢?
答案就是X2C。
X2C
X2C框架保留了XML的优点,并解决了其IO操作和反射的性能问题。开发人员只需要正常写XML代码即可,在编译期,X2C会利用APT工具将XML代码翻译为Java代码。
接下来,我们来进行X2C的使用。
首先,在app的build.gradle文件添加如下依赖:
1 | annotationProcessor 'com.zhangyue.we:x2c-apt:1.1.2' |
然后,在对应的MainActivity类上方添加如下注解,让MainActivity知道我们使用的布局是activity_main:
1 |
|
接着,将onCreate方法中setContentView的原始方式改为X2C的设置方式:
1 | X2C.setContentView(MainActivity.this, R.layout.activity_main); |
最后,我们再Rebuild项目,会在build下的generated->source->apt->debug->com.zhangyue.we.x2c下自动生成X2C127_activity_main这个类:
1 | public class X2C127_activity_main implements IViewCreator { |
在这个类中又继续调用了layout目录下的X2C127_Activity_Main实例的createView方法,如下所示:
1 | public class X2C127_Activity_Main implements IViewCreator { |
从上可知,里面采用了new的方式创建了相应的控件,并设置了对应的信息。
接下来,我们回到X2C.setContentView(MainActivity.this, R.layout.activity_main)这个方法,看看它内部究竟做了什么处理:
1 | /** |
在注释1处,通过getView方法获取到了对应的view,我们继续跟踪进去:
1 | public static View getView(Context context, int layoutId) { |
可以看到,这里采用了一个sSparseArray集合去存储布局对应的视图创建对象creator,如果是首次创建creator的话,会在注释1处使用反射的方式去加载处对应的creator对象,然后将它放入sSparseArray集中,最后在注释2处调用了creator的createView方法去使用new的方式去创建对应的控件。
但是,X2C框架还存在一些问题:
- 1、部分Java属性不支持。
- 2、失去了系统的兼容(AppCompat)
对于第2个问题,我们需要修改X2C框架的源码,当发现是TextView等控件时,需要直接使用new的方式去创建一个AppCompatTextView等兼容类型的控件。于此同时,它还有如下两个小的点不支持,但是这个问题不大:
- merge标签 ,在编译期间无法确定xml的parent,所以无法支持。
- 系统style,在编译期间只能查到应用的style列表,无法查询系统style,所以只支持应用内style。
2、使用ConstraintLayout降低布局嵌套层级
首先,对于Android视图绘制的原理,我们必须要有一定的了解,关于这块,大家可以参考下Android View的绘制流程 这篇文章。
对于视图绘制的性能瓶颈,大概有以下三点:
- 1、测量、布局、绘制每个阶段的耗时。
- 2、自顶而下的遍历,当嵌套层级过多时,遍历耗时会比较明显。
- 3、无效的嵌套布局或不合理使用RelativeLayout可能会导致触发多次绘制。
那么,如何减少布局的层级及复杂度呢?
基本上只要遵循以下两点即可:
- 1、减少View树层级。
- 2、宽而浅,避免窄而深。
为了提升布局的绘制速度,Google推出了ConstraintLayout,它的特点如下:
- 1、实现几乎完全扁平化的布局。
- 2、构建复杂布局性能更高。
- 3、具有RelativeLayout和LinearLayout的特性。
接下来,我们来简单使用一下ConstraintLayout来优化一下我们的布局。
首先,下面是我们的原始布局:
1 |
|
可以看到,它具有三层嵌套结构,然后我们来使用ConstraintLayout来优化一下这个布局:
1 |
|
经过ConstraintLayout之后,布局的嵌套层级变为了2级,如果布局比较复杂,比如有5,6,7层嵌套层级,使用Contraintlayout之后降低的层级会更加明显。对于其app下的一系列属性,其实都非常简单,这里就不多做介绍了。
除此之外,还有以下方式可以减少布局层级和复杂度:
- 1、不嵌套使用RelativeLayout。
- 2、不在嵌套LinearLayout中使用weight。
- 3、使用merge标签,它能够减少一个层级,但只能用于根View。
3、过渡绘制优化
在视图的绘制优化中,还有一个比较重要的优化点,就是避免过渡绘制,这个笔者已经在Android性能优化之绘制优化一文的第四小节详细分析过了。最后这里补充一下自定义View中使用clipRect的一个实例。
首先,我们自定义了一个DroidCardsView,他可以存放多个叠加的卡片,onDraw方法的实现如下:
1 | protected void onDraw(Canvas canvas) { |
从以上代码可知,这里是直接进行绘制的,此时显示的布局过渡绘制背景如下所示:
可以看到,图片的背景都叠加起来了,这个时候,我们需要在绘制的时候使用clipRect让系统去识别可绘制的区域,因此我们在自定义的DroidCardsView的onDraw方法去使用clipRect:
1 | protected void onDraw(Canvas canvas) { |
在注释1处,首先需要在clipRect方法和绘制前后成对使用canvas的save方法与restore方法用来对画布进行操作。接着,在注释2处,使用clipRect指定绘制区域,这里的mCardSpacing是指的相邻卡片最左边的间距,需要在动态创建DroidCardsView的时候传入。最后,在注释3处调用实际绘制卡片的方法。
使用clipRect优化过后的布局过渡绘制背景如下所示:
注意:
我们还可以通过canvas.quickReject方法来判断是否没和某个矩形相交,以跳过非矩形区域的绘制操作。
当然,对视图的绘制优化还有其它的一些优化操作,比如:
1、使用ViewStub、Merge,ViewStub是一种高效占位符,用于延迟初始化。
2、onDraw中避免创建大对象,进行耗时操作。
3、TextView的优化,比如利用它的drawableLeft属性。此外,也可以使用Android 9.0之后的 PrecomputedText,它将文件的measure与layout过程进行了异步化。但是需要注意,如果要显示的文本比较少,反而会造成不必要的Scheduling delay,建议文本字符大于200时才使用,并记得使用其兼容类PrecomputedTextCompat,它在9.0以上使用PrecomputedText进行优化,在5.0~9.0使用StaticLayout进行优化。具体调用代码如下所示:
Future future = PrecomputedTextCompat.getTextFuture( “text”, textView.getTextMetricsParamsCompat(), null); textView.setTextFuture(future);
到这里,笔者就将常规的布局优化讲解完了,是不是顿时感觉实力大增呢?
如果你此时内心已经YY到这种程度,那我只能说:
对于Android的布局优化还有更深入的优化方式吗?
没错,下面,笔者就来和大家一起来探索布局优化的进阶方案。
七、布局优化的进阶方案
1、使用异步布局框架Litho
Litho是Facebook开源的一款在Android上高效建立UI的声明式框架,它具有以下特点:
- 声明式:它使用了声明式的API来定义UI组件。
- 异步布局:它可以提前布局UI,而不会阻塞UI线程。
- 视图扁平化:它使用了Facebook开源的另一款布局引擎Yoga进行布局,以自动减少UI包含的ViewGroup数量。
- 细粒度的回收:可以回收文本或图形等任何组件,并可以在用户界面的任何位置重复使用。
- 内部不仅支持使用View来渲染视图,还可以使用更轻量的Drawable来渲染视图。Litho实现了大量使用Drawable来渲染的基础组件,可以进一步使布局扁平化。
简单使用Litho
接下来,我们在项目里面来使用Litho。
1、首先,我们需要配置Litho的相关依赖,如下所示:
1 | // 项目下 |
2、然后,在Application下的onCreate方法中初始化SoLoader:
1 |
|
从之前的介绍可知,我们知道Litho使用了Yoga进行布局,而Yoga包含有native依赖,在Soloader.init方法中对这些native依赖进行了加载。
3、最后,在Activity的onCreate方法中添加如下代码即可显示单个的文本视图:
1 | // 1、将Activity的Context对象保存到ComponentContext中,并同时初始化 |
显示效果如下所示:
在上面的示例中,我们仅仅是将Text这个子组件设置给了LithoView,后续为了实现更复杂的布局,我们需要使用带多个子组件的根组件去替换它。
使用自定义Component
由上可知,在Litho中的视图单元叫做Component,即组件,它的设计理念来源于React组件化的思想。每个组件持有描述一个视图单元所必须的属性与状态,用于视图布局的计算工作。视图最终的绘制工作是由组件指定的绘制单元(View或Drawable)来完成的。接下来,我们使用Litho提供的自定义Component的功能,它能够让我们实现更复杂的Component,这里我们来实现一个类似ListView的列表。
首先,我们先来实现一个ListItem Component,它就如ListView的itemView一样。在下面的实战中,我们将会学习到所有的基础知识,这将会支撑你后续能实现更多更复杂的Component。
然后,在Litho中,我们需要先写一个Spec类去声明Component所对应的布局,在这里需要使用@LayoutSpec规范注解(除此之外,Litho还提供了另一种类型的组件规范:Mount Spec)。代码如下所示:
1 |
|
然后,框架会使用APT技术去帮助生成对应的ListItem Component 类。最后,我们在Activity的onCreate中将上述第一个例子中的第二步改为如下:
1 | // 2、构建ListItem组件 |
运行项目,显示界面如下所示:
那上述过程是如何进行构建的呢?
它看起来就像有一个LithoSpec的类名,并且在项目构建之后生成了一个与LithoSpec有着同样包名的Litho类,如下所示:
类似于Litho这种类中的所有方法参数都会由Litho进行自动填充。此外,基于这些规格,将会有一些额外的方法由注解处理器自动生成,例如上述示例中Column或Row中的Text的TextSizeSp、backgroundColor等方法。(Row和Column分别对应着Flexox中的行和列,它们都实现了Litho中另一种特殊的组件Layout)
补充:MountSpec规范
MountSpec是用来生成可挂载类型组件的一种规范,它的作用是用来生成渲染具体的View或者Drawable的组件。同LayoutSpec类似,它必须使用@MountSpec注解来标注,并实现一个标注了@onCreateMountContent的方法。但是MountSpec的实现要比Layout更加地复杂,因为它拥有自己的生命周期,如下所示:
- @OnPrepare:准备阶段,用于进行一些初始化操作。
- @OnMeasure:负责布局的计算工作。
- @OnBoundsDefined:在布局计算完成之后、挂载视图之前做一些操作。
- @OnCreateMountContent:如果没有可以复用的视图单元,则调用它去创建需要挂载的视图。
- @OnMount:挂载视图,用于完成布局相关的设置。
- @OnBind:绑定视图,用于完成数据和视图的绑定。
- @OnUnBind:解绑视图,与@OnBind相对,主要用于重置视图的数据属性,避免出现数据复用的问题。
- @OnUnmount:卸载视图,与@OnMount相对,主要用于重置视图的布局相关的属性,避免出现布局复用的问题。
MountSpec的生命周期流转图如下所示:
在使用Litho完成了两个实例的开发之后,相信我们已经对Litho的布局方式已经有了一个感性的认知。那么,Litho到底是如何进行布局优化的呢?在布局优化中它所做的核心工作有哪些?
Litho在布局优化中所做的核心工作包括以下三点:
- 1、异步布局化。
- 2、布局自动扁平化。
- 3、更细粒度地优化RecyclerView中组件的缓存与回收的方法。
1、异步布局化
在前文中,我们知道Android的布局加载过程通常会先后涉及到measure、layout、draw过程,并且它们都是在主线程执行的,如果方法执行过程中耗时太多,则主界面必然会产生卡顿现象。
还记得我们在前面介绍的PrecomputedText,它内部将measure与layout的过程放在了异步线程进行初始化,而Litho与PrecomputedText类似,也是将measre与layout的过程进行了异步化,核心原理就是利用CPU的闲置时间提前在异步线程中完成measure和layout的过程,仅在UI线程中完成绘制工作。
那么Android为什么不自己实现异步布局呢?
主要有以下两原因:
- 1、因为View的属性是可变的,只要属性发生变化就可能导致布局变化,所以需要重新计算布局,那么提前异步去计算布局的意义就不大了。而Litho组件的属性是不可变的,因此它的布局计算结果也是不变的。
- 2、提前异步布局需要去提前创建好接下来用到的若干条目的视图,但是Android原生的View作为视图单元,不仅包含一个视图的所有属性,而且还负责视图的绘制工作。如果要在绘制前提前去计算布局,就需要预先去持有大量未展示的View实例,这将会大大增加App进程的内存占用。对于Litho的组件来说,它只是视图属性的一个集合,仅仅负责计算布局,绘制工作由指定的绘制单元来完成。因此在Litho中,提前创建好下面要用到的多个条目的组件,是不会有性能问题的。两者的绘制原理简图如下所示:
2、布局自动扁平化
经过之前的学习,我们了解到Litho采用了一套自有的布局引擎Yoga,它会在布局的过程中去检测出不必要的布局嵌套层级,并自动去减少多余的层级以实现布局的扁平化,这可以显著减少渲染时的递归调用,加快渲染速度。例如,在实现一个图片带多个文字的布局中,我们通常会至少有两个布局层级,当然,你也可以使用TextView的drawableStart方法 + 代码动态布局使用Spannable/Html.fromHtml(用来实现多种不同规格的文字) + lineSpaceExtra/lineSpacingMultiplier(用来调整多行文本的显示间距)来将布局层级降为一层,但是这种实现方式比较繁琐,而通过使用Litho,我们可以把降低布局嵌套层级的任务全部丢给布局引擎Yoga去处理。由前面可知,Litho是使用Flexbox来创建布局的,并最终生成带有层级结构的组件树。通过使用Yoga来进行布局计算,可以使用Flexbox的相对布局变成了只有一层嵌套的绝对布局。相比于ConstraintLayout,对于实现复杂布局的时候可读性会更好一些,因为ConstraintLayout此时会有过多的约束条件,这会导致可读性变差。此外,Litho自身还提供了许多挂载Drawable的基本视图组件,相比Viwe组件使用它们可以显著减少内存占用(通常会减少80%的内存占用)。Litho实现布局自动扁平化的原理图如下所示:
3、更细粒度地优化RecyclerView中组件的缓存与回收的方法
使用了RecyclerView与ListView这么久,我们明白它是以viewType为粒度来对一个组件集合统一进行缓存与回收的,并且,当viewType的类型越多,其对组件集合的缓存与回收的效果就会越差。相对于RecyclerView与ListView缓存与回收的粗粒度而言,Litho实现了更细粒度的回收机制,它是以Text、image、video等单个Component为粒度来作为其基准的,具体实现原理为在item回收前,会把LithoView中挂载的各个绘制单元进行解绑拆分出来,由Litho自己的缓存池去分类回收,然后在展示前由LithoView按照组件树的样式挂载组装各个绘制单元,这样就达到了细粒度复用的目的。毫无疑问,这不仅提高了其缓存的命中率与内存的使用率,也降低了提高了其滚动刷新的频率。更细粒度复用优化内存的原图如下所示:
由上图可以看出,滑出屏幕的itemType1会被拆分成一个个的视图单元。其中LithoView容器由Recycler缓存池回收,而其他视图单元则由Litho的缓存池分类回收,例如分类为Img缓存池、Text缓存池等等。
现在,我们对Litho已经比较了解了,它似乎很完美,但是任何事物都有其弊端,在学习一个新的事物时,我们不仅仅只去使用与了解它的优势,更应该对它的缺陷与弊端了如指掌。Litho在布局的过程中,使用了类似React的单向数据流设计,并且由于Litho是使用代码进行动态布局,这大大增加了布局的复杂度,而且,代码布局是无法实时预览的,这也增加了开发调试时的难度。
综上,对于某些性能性能要求高的场景,可以先使用Litho布局的方式去替换,特别是需要利用好Litho中的RecyclerViewCollectionComponent与sections去充分提升RecylerView的性能。
现在,我们来使用RecyclerViewCollectionComponent与sections去创建一个可滚动的列表单元。
接下来,我们需要使用SectionsAPI,它可以将列表分为多个Section,然后编写GroupSectionSpec注解类来声明每个Section需要呈现的内容及其使用的数据。下面,我们创建一个ListSectoinSpec:
1 | // 1、可以理解为一个组合Sectoin规格 |
然后,我们将MainActivity onCreate方法中的步骤2替换为如下代码:
1 | // 2、使用RecyclerCollectionComponent去绘制list |
最终的显示效果如下所示:
如果我们需要显示不同UI的ListItem该怎么办呢?
这个时候我们需要去自定义Component的属性,即props,它是一种不可变属性(此外还有一种可变属性称为State,但是其变化是由组件内部进行控制的,例如输入框、Checkbox等都是由组件内部去感知用户的行为,并由此更新组件的State属性),你设置的这些属性将会改变Component的行为或表现。Props是Component Spec中方法的参数,并且使用@Prop注解。
下面,我们使用props将ListItemSpec的onCreateLayout修改为可自定义组件属性的方法,如下所示:
1 |
|
奇妙之处就发生在我们所定义的@Prop注解与注解处理器之间,注解处理器以一种智能的方式对组件构建过程中所关联的属性生成了对应的方法。
接下来,我们再修改ListSectionSpec类,如下所示:
1 |
|
最终的显示效果如下所示:
除此之外,我们还可以有更多的方式去定义@Prop,如下所示:
1 | int shadowRadius, |
上面定义了一个可选的Prop,传入的shadowRadius是支持dimen规格的,如px、dp、sp等等。
小结
使用Litho,在布局性能上有很大的提升,但是开发成本太高,因为需要自己去实现很多的组件,并且其组件需要在编译时才能生成,不能够进行实时预览,但是可以把Litho封装成Flexbox布局的底层渲染引擎,以此实现上层的动态化,具体实现原理可参见Litho在美团动态化方案MTFlexbox中的实践。
2、使用Flutter实现高性能的UI布局
Flutter可以说是2019最火爆的框架之一了,它是 Google 开源的 UI 工具包,帮助开发者通过一套代码库高效构建多平台精美应用,支持移动、Web、桌面和嵌入式平台。对于Android来说,FLutter能够创作媲美原生的高性能应用,应用使用 Dart语言进行 开发。Flutter的架构类似于Android的层级架构,每一层都建立在前一层之上,其架构图如下所示:
在Framework层中,Flutter通过在 widgets 层组合基础 widgets 来构建 Material 层,而 widgets 层本身则是通过对来自 Rendering 层的低层次对象组合而来。而在Engine层,Flutter集成了Skia引擎用于进行栅格化,并且使用了Dart虚拟机。
那么Flutter的图形性能为何能够媲美原生应用呢?
接下来,我们以Flutter、原生Android、其它跨平台框架如RN来做比较,它们的图形绘制调用层级图如下所示:
可以看到,Flutter框架的代码完全取代了Java层的框架代码,所以只要当Flutter框架中Dart代码的效率可以媲美原生框架的Java代码的时候,那么总体的Flutter App的性能就能够媲美原生的APP。而反观其它流行的跨平台框架如RN,它首先需要调用自身的Js代码,然后再去调用Java层的代码,这里比原生和Flutter的App显然多出来一个步骤,所以它的性能肯定是不及原生的APP的。此外,Flutter App不同于原生、RN,它内部是直接包含了Skia渲染引擎的,只要Flutter SDK进行升级,Skia就能够升级,这样Skia的性能改进就能够同步到Flutter框架之中。而对于Android原生和RN来说,只能等到Android系统升级才能同步Skia的性能改进。
而Flutter又是如何实现高性能UI布局的呢?
接下来,我们来大致了解一下Flutter的UI绘制原理,它主要是通过VSYNC信号来使UI线程和GPU线程有条不紊的周期性的去渲染界面,其绘制原理图如下所示:
绘制步骤大致如下:
- 1、首先 UI Runner 会执行 root isolate(可简单理解为Dart VM的线程),它会告诉引擎层有帧要渲染,当需要渲染则会调用到Engine的ScheduleFrame()来注册VSYNC信号回调,一旦触发回调doFrame(),并当它执行完成后,便会移除回调方法,也就是说一次注册一次回调。
- 2、当需要再次绘制则需要重新调用到ScheduleFrame()方法,该方法的唯一重要参数regenerate_layer_tree决定在帧绘制过程是否需要重新生成layer tree,还是直接复用上一次的layer tree。
- 3、接着,执行的是UI线程绘制过程中最核心的WidgetsBinding的drawFrame()方法,然后会创建layer tree视图树。
- 4、然后 Layer Tree 会交给 GPU Task Runner 进行合成和栅格化。
- 5、最后,GPU Task Runner会利用Skia库结合GL或Vulkan将layer tree提供的信息转化为平台可执行的GPU指令。
此外,Flutter 也采用了类似 Litho 的props属性不可变、Reat单向数据流的方案,用于将视图与数据分离。对于Flutter这一大前端领域的核心技术,笔者也是充满兴趣,后续会有计划对此进行深入研究,敬请期待。
3、使用RenderThread 与 RenderScript
在Android 5.0之后,Android引进了RenderThread,它能够实现动画的异步渲染。但是目前支持RenderThread完全渲染的动画,只有两种,即ViewPropertyAnimator和CircularReveal(揭露动画)。对于CircularReveal使用比较简单且功能较为单一,就不多做过多的描述了。下面我简单说下ViewPropertyAnimator中如何去利用RenderThread。
1、在ViewPropertyAnimator类系中,有一个ViewPropertyAnimatorRT ,它的主要作用就把动画交给RenderThread去处理。因此,我们需要先去创建对应view的ViewPropertyAnimatorRT,代码如下所示:
1 | /** |
2、接下来,我们需要将ViewPropertyAnimatorRT设置给ViewPropertyAnimator的mRTBackend字段,这样ViewPropertyAnimator才能利用它去将动画交给RenderThread处理,如下所示:
1 | private static void setViewPropertyAnimatorRT(ViewPropertyAnimator animator, Object rt) { |
3、最后,在开启动画之前将ViewPropertyAnimatorRT实例设置进去即可,如下所示:
1 | ViewPropertyAnimator animator = v.animate().scaleY(2).setDuration(2000); |
当前,如果是做音视频或图像处理的工作,经常需要对图片进行高斯模糊、放大、锐化等操作,但是这里涉及大量的图片变换操作,例如缩放、裁剪、二值化以及降噪等。而图片的变换又涉及大量的计算任务,这个时候我们可以通过RenderScript去充分利用手机的GPU计算能力,以实现高效的图片处理。
而RenderScript的工作流程需要经历如下三个步骤:
- 1、RenderScript运行时API:提供进行运算的API。
- 2、反射层:相当于NDK中的JNI胶水代码,它是一些由Android编译工具自动生成的类,对我们写的RenderScript代码进行包装,以使得安卓层能够和RenderScript进行交互。
- 3、安卓框架:通过调用反射层来访问RenderScript运行时。
由于RenderScript主要是用于音视频、图像处理等细分领域,这里笔者就不继续深入扩展了,对于NDK、音视频领域的知识,笔者在今年会有一系列学习计划,目前大纲已经定制好了,如果有兴趣的朋友,可以了解下:Awesome-Android-NDK。
八、布局优化的常见问题
1、你在做布局优化的过程中用到了哪些工具?
我在做布局优化的过程中,用到了很多的工具,但是每一个工具都有它不同的使用场景,不同的场景应该使用不同的工具。下面我从线上和线下两个角度来进行分析。
比如说,我要统计线上的FPS,我使用的就是Choreographer这个类,它具有以下特性:
- 1、能够获取整体的帧率。
- 2、能够带到线上使用。
- 3、它获取的帧率几乎是实时的,能够满足我们的需求。
同时,在线下,如果要去优化布局加载带来的时间消耗,那就需要检测每一个布局的耗时,对此我使用的是AOP的方式,它没有侵入性,同时也不需要别的开发同学进行接入,就可以方便地获取每一个布局加载的耗时。如果还要更细粒度地去检测每一个控件的加载耗时,那么就需要使用LayoutInflaterCompat.setFactory2这个方法去进行Hook。
此外,我还使用了LayoutInspector和Systrace这两个工具,Systrace可以很方便地看到每帧的具体耗时以及这一帧在布局当中它真正做了什么。而LayoutInspector可以很方便地看到每一个界面的布局层级,帮助我们对层级进行优化。
2、布局为什么会导致卡顿,你又是如何优化的?
分析完布局的加载流程之后,我们发现有如下四点可能会导致布局卡顿:
- 1、首先,系统会将我们的Xml文件通过IO的方式映射的方式加载到我们的内存当中,而IO的过程可能会导致卡顿。
- 2、其次,布局加载的过程是一个反射的过程,而反射的过程也会可能会导致卡顿。
- 3、同时,这个布局的层级如果比较深,那么进行布局遍历的过程就会比较耗时。
- 4、最后,不合理的嵌套RelativeLayout布局也会导致重绘的次数过多。
对此,我们的优化方式有如下几种:
- 1、针对布局加载Xml文件的优化,我们使用了异步Inflate的方式,即AsyncLayoutInflater。它的核心原理是在子线程中对我们的Layout进行加载,而加载完成之后会将View通过Handler发送到主线程来使用。所以不会阻塞我们的主线程,加载的时间全部是在异步线程中进行消耗的。而这仅仅是一个从侧面缓解的思路。
- 2、后面,我们发现了一个从根源解决上述痛点的方式,即使用X2C框架。它的一个核心原理就是在开发过程我们还是使用的XML进行编写布局,但是在编译的时候它会使用APT的方式将XML布局转换为Java的方式进行布局,通过这样的方式去写布局,它有以下优点:1、它省去了使用IO的方式去加载XML布局的耗时过程。2、它是采用Java代码直接new的方式去创建控件对象,所以它也没有反射带来的性能损耗。这样就从根本上解决了布局加载过程中带来的问题。
- 3、然后,我们可以使用ConstraintLayout去减少我们界面布局的嵌套层级,如果原始布局层级越深,它能减少的层级就越多。而使用它也能避免嵌套RelativeLayout布局导致的重绘次数过多。
- 4、最后,我们可以使用AspectJ框架(即AOP)和LayoutInflaterCompat.setFactory2的方式分别去建立线下全局的布局加载速度和控件加载速度的监控体系。
3、做完布局优化有哪些成果产出?
- 1、首先,我们建立了一个体系化的监控手段,这里的体系还指的是线上加线下的一个综合方案,针对线下,我们使用AOP或者ARTHook,可以很方便地获取到每一个布局的加载耗时以及每一个控件的加载耗时。针对线上,我们通过Choreographer.getInstance().postFrameCallback的方式收集到了FPS,这样我们可以知道用户在哪些界面出现了丢帧的情况。
- 2、然后,对于布局监控方面,我们设立了FPS、布局加载时间、布局层级等一系列指标。
- 3、最后,在每一个版本上线之前,我们都会对我们的核心路径进行一次Review,确保我们的FPS、布局加载时间、布局层级等达到一个合理的状态。
九、总结
对于Android的布局优化,笔者以一种自顶向下,层层递进的方式和大家一起深入地去探索了Android中如何将布局优化做到极致,其中主要涉及以下八大主题:
- 1、绘制原理:CPU\GPU、Android图形系统的整体架构、绘制线程、刷新机制。
- 2、屏幕适配:OLED 屏幕和 LCD 屏幕的区别、屏幕适配方案。
- 3、优化工具:使用Systrace来进行布局优化、利用Layout Inspector来查看视图层级结构、采用Choreographer来获取FPS以及自动化测量 UI 渲染性能的方式(gfxinfo、SurfaceFlinger等dumpsys命令)。
- 4、布局加载原理:布局加载源码分析、LayoutInflater.Factory分析。
- 5、获取界面布局耗时:使用AOP的方式去获取界面加载的耗时、利用LayoutInflaterCompat.setFactory2去监控每一个控件加载的耗时。
- 6、布局优化常规方案:使用AOP的方式去获取界面加载的耗时、利用LayoutInflaterCompat.setFactory2去监控每一个控件加载的耗时。
- 7、布局优化的进阶方案:使用异步布局框架Litho、使用Flutter实现高性能的UI布局、使用RenderThread实现动画的异步渲染与 利用RenderScript实现高效的图片处理。
- 8、布局优化的常见问题。
可以看到,布局优化看似是Android性能优化中最简单的专项优化项,但是笔者却花费了整整三、四万字的篇幅才能比较完整地将其核心知识传授给大家。因此,不要小看每一个专项优化点,深入进去,必定满载而归。