「深入理解Android布局优化 1」-布局的加载流程与绘制原理
前言
本篇文章是《深入理解Android布局优化》系列文章的第一篇。系列的主要目的是希望将Android开发中涉及布局优化的部分做一次系统的归纳、总结和学习。本系列文章包含理论基础、常见工具、项目实践三个部分。
理论基础:「深入理解Android布局优化 1」-布局的加载流程与绘制原理,主要讲解布局的加载流程与绘制原理,从源码上发现布局的性能瓶颈。
常见工具:「深入理解Android布局优化 2」-常见工具的使用,主要讲解Android布局优化时各种常见工具的使用。
项目实践:以一个实际的APP为例,将学习到的理论和工具,实际运用到Android开发中。
本文中实践时使用的项目地址:https://github.com/linux-link/Fan,可以先阅读这篇文章了解这个项目一次组件化与Android Jetpack的实践
本篇属于三个部分中的理论基础部分。
目录
- Android系统的绘图机制
- Activity的组成
- 布局文件的加载流程
- View的绘制流程
- 布局优化的简单建议
- 总结
正文
一、Android系统的绘图机制
Android系统每隔16ms就重新绘制一次Activity,这就要求UI界面必须在16ms内完成屏幕刷新的全部逻辑操作,这样才能达到每秒60fps,然而这个fps是由手机硬件所决定,现在大多数手机屏幕刷新率是60Hz(赫兹是国际单位制中频率的单位,它是每秒中的周期性变动重复次数的计量),也就是说我们有16ms(1000ms/60fps=16.66ms)的时间去完成每帧的绘制逻辑操作,如果超过了就会出现所谓的丢帧。实际开发中复杂的界面往往在16ms内完成全部绘制,但是尽量降级UI的绘制时间,总是可以有效的降低卡顿感。
对于Android系统的硬件绘图机制,并非布局优化的重点,有兴趣的可以翻看文末的参考资料。
二、Activity的组成
一个Activity层级结构图,如下所示
它有点像洋葱圈一层包裹着一层,下面我们就来逐个介绍一下。
-
PhoneWindow
PhoneWindow是Window的子类,Window是顶级窗口外观和行为策略的抽象基类。它提供标准的UI策略,例如背景,标题区域,默认密钥处理等。它的唯一实现就是PhoneWindow
-
DecorView
DecorView是一个ViewGroup类,继承自FrameLayout,是Activity在绘制布局文件时的宿主,也可以把它理解为绘制布局文件时的“画布”。
-
TitleActionBar
Android提供一个默认的ActionBar,我们在写demo时经常会看到这个ActionBar,一般正式开发时,会在Style.xml中把它去掉.
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
-
ContentView
ContentView就是我们在setContentView时传入的xml布局文件绘制出来的ViewGroup,在Activity(kotlin语言)中我们可以通过如下代码获取到各个ContentView
//kotlin window.decorView.findViewById<ViewGroup>(android.R.id.content).getChildAt(0) //java getWindow().getDecorView().findViewById(android.R.id.content).getChildAt(0)
通过这张层级关系图,我们就大致明白了Activity层级结构,理解Activity的页面层级结构非常的重要,它不仅与性能优化息息相关,而且也可以帮助我们理解Android触摸事件的分发机制。
触摸事件的分发机制,经常涉及到自定义的View,自定义View其实也是我们在布局优化时常用的手段之一。
这里重新画了一张“洋葱圈”一样的层级结构图,来帮助你理解触摸事件的向上传递机制。这张图很形象的解释了触摸事件是如何从Activity中开始传递,又是如何回到Activity中的。关于触摸事件的分发具体的分发机制,请参阅其他文章,这里就不再细说了。
洋葱圈结构图三、布局文件的加载流程
在Android开发中setContentView是我们最常用的将xml格式的布局文件绘制到activity中的方法。那么布局文件是如何绘制到Activity当中的呢?通过阅读setcontentView的源代码,可以发现布局文件的加载大致分为,读取xml、创建View对象两个流程。
image1.读取xml布局文件
-
setContentView
setContentView很好理解,就是向Activity的DecorView中装载布局文件。Activity再通过decorView拿到当前设定的布局,交给LayoutInflater解析。
ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content); LayoutInflater.from(mContext).inflate(resId, contentParent);
-
LayoutInflater.inflate()
LayoutInflater,在Android系统通过它将布局XML文件实例化为其对应的View 对象。在inflate中通过loadXmlResourceParser方法来读取xml布局文件,并把读取到的文件流封装到XmlResourceParser中。这样就把xml布局文件就从存储器中放到了内存中,注意loadXmlResourceParser是一个IO操作。
2.根据xml布局文件,创建对应的View或ViewGroup
-
LayoutInflater.createViewFromTag()
在loadXmlResourceParser把文件流装载到XmlResourceParser之后,LayoutInflater会调用createViewFromTag方法,根据标签来创建对应的View对象,例如根据读取到的<TextView>创建TextView对象。
createViewFromTag创建View对象主要是Fractory的onCreateView或是调用createView方法来实现,createView内部具体是通过反射来创建View对象。
简单梳理一遍View的加载流程,你会发现,到这里Android系统就完成了把xml布局文件转换成具体的View对象,在这其中我们可以看到至少两个会影响性能地方,一个是loadXmlResourceParser(),把xml读取到内存中这样的IO操作会影响性能,另一个则是createView(),通过反射创建对象会影响性能。这两个地方将是我们日后进行布局优化的重点。
目前为止Activity还是看不任何东西的,因为创建的View还没有开始绘制。接下来我们就来看看View的绘制流程。
四、View的绘制流程
View绘制流程主要分为三个部分:measure、layout、draw,分别对应测量、布局和绘制,其中measure确定View的测量宽高,layout确定View最终宽高和四个顶点的位置,draw负责将view最终绘制到屏幕上。
ViewGroup的绘制流程与View大体相同,唯一的区别就是,View只需要绘制它自己,而ViewGroup不仅要绘制它自己还要绘制它的子View。下面我们就以ViewGroup为例,简单从源代码的角度来看一下这三个流程:
1.Measure与MeasureSpec
测量过程通过measure()来实现,是View树自顶向下的遍历,每个View在循环过程中将尺寸细节向下传递,当测量过程完成之后,所有的View也就都存储了自己尺寸。
ViewGroup是一个抽象类,它并没有重写View的measure()方法,它在内部会调用measureChildren(),然后再去循环调用View的measure()方法。
measure()方法需要传入两个参数widthMeasureSpec和heightMeasureSpec。
protected void measure(int widthMeasureSpec, int heightMeasureSpec)
表面上看widthMeasureSpec和heightMeasureSpec是int的数字,它们是父类传过来的给当前View的一个建议值(这个建议值是我们在XML中设定的),实际上是由mode+size组成的。将widthMeasureSpec转换为二进制后,它是一个32位的数字,前两位表示模式(mode),后30位表示数值(size)。
mode共有三种模式,分别是
-
UNSPECIFIED(未指定)
不做任何限制,View可以获得任意大小。它一般用于系统的内部测量过程。
-
EXACTLY(完全)
由父View决定子View的确切大小,子View将被限定在给定边界里而忽略它自身的大小。对应match_parent和具体的dp值
-
AT_MOST(至多)
View最多达到指定大小的值,对应wrap_content
上述3中模式在自定义view时非常有用,当模式是EXACTLY时,我们是直接使用父类的建议值,当模式是AT_MOST时,我们则需要自己设定View的大小,因为用户没有规定这个View有多大。
2.layout
Layout的作用是ViewGroup用来确定子View的位置。在ViewGroup中调用layout方法确定位置确定后,它会在onLayout中遍历所有子View的layout方法,子View的layout又会调用onLayout方法,确定自己的位置。
layout的大致流程如下:
首先通过setFrame设定View的四个顶点位置;
然后调用onLayout方法,在这里面调用每个子View的layout
3.draw
draw的过程是最简单的,它的作用就是把View绘制到屏幕上,
public void draw(Canvas canvas) {}
在draw方法中主要完成了一下几个任务:
- 使用drawBackground方法绘制背景
- 在onDraw中绘制自己
- 在dispatch中绘制子View
- 在onDrawScrollBars中绘制装饰
在Android中draw方法会被频繁的调用,例如:按home键app进入后台,当我们在回到APP时,即使APP没有被销毁,当前界面下View组件的draw方法也会被调用。
简单了解了View的绘制流程后,不难看出这里面也存在至少两个性能瓶颈,一个是measure和layout过程中会循环调用子View的方法,其实这就决定了布局文件不能嵌套过深,否则循环的时间复杂度会很高。另一个是View的draw方法会被频繁的调用,对于这类频繁调用的方法,我们不能在其中创建对象或执行耗时操作,否则会产生剧烈的内存抖动和页面卡顿。
五、布局优化的简单建议
通过上面的分析,我们对布局的组成,加载以及绘制有了一定的了解,现在再来看看常见的布局优化建议,相信你一定对这些建议有了进一步的认识。
-
使用ConstraintLayout减少布局嵌套
ConstraintLayout是Google推出一种可以有效减少嵌套问题的布局,它可以让你的布局更加的扁平化,如果你没有使用过ConstraintLayout,强烈推荐使用。
-
使用<include/>和<merge/>标签来减少布局嵌套
<include/>标签可以将一个指定的布局引入到当前的布局中,通过这种方式可以复用项目中已经存在的布局。有时候被引用的布局顶级节点与外部布局存在重复的情况,这时就可以使用<merge/>将多余的顶级节点去掉。关于<merge/>
-
使用ViewStub延迟加载布局
ViewStub继承了View,它的宽高都是0,因此它不参与任何布局与绘制的过程。在开发中有的布局正常情况下并不显示,这时候就可以使用ViewStub,在布局初始化的时候可以避免加载这类并不需要立即显示的布局。
-
不要在onDraw()创建对象或执行耗时操作
具体原因在上面已经说过了,这里就不赘述了。
-
不使用xml布局
使用xml布局文件,Android需要通过IO操作把xml布局文件加载到内存中,然后通过反射创建view对象,如果不使用xml就可以完全避免这些影响的性能操作。使用这类思想创建布局文件框架有的iReader的X2C和FaceBook的Litho,不过这是一类很极端的做法,并不推荐。
-
复杂布局使用自定义View
当App设计图非常复杂,我们需要使用非常多的系统组件组合才能实现相似的功能时,建议使用自定义View,保持界面的扁平化。
六、总结
本篇文章梳理了一下Activity的组成,一个xml的布局是如何加载到界面中,以及是如果绘制出来的,最后总结了一下目前的布局优化建议。但是在实际的开发中往往很难让所有人完全遵守布局优化的建议,下一篇我们来讲讲布局优化时常用的工具「深入理解Android布局优化 2」-常见工具的使用,通过工具来帮助我们发现UI的性能问题。
参考资料
Android进阶——性能优化之布局渲染原理和底层机制详解(四)
《Android开发艺术探索》 任玉刚著