从 View 绘制谈性能优化

在开发过程中,往往会听到 “性能优化” 这个概念,这个概念很大,比如网络性能优化、耗电量优化等等,对我们开发者而言,最容易做的,或者是影响最大的,应该是 View 的性能优化。一般小项目或许用不上 View 性能优化,然而,当业务愈加庞大、界面愈加复杂的时候,没有一个良好的开发习惯和 View 布局优化常识,做出来的界面很容易出现 “卡顿” 现象,从而严重影响用户体验。而对于我们开发者来说,了解一些 View 性能优化的常识,增强开发技巧,可以说是一门必备的功课。

为了更好地理解 View 性能优化的原理,以及造成 “卡顿” 的可能原因,我们从 View 的绘制流程开始讨论。之后,会介绍一些写界面布局常用的一些标签及使用注意事项。

View 绘制流程

我们都知道,View 的绘制分为三个阶段:测量、布局和绘制,这三个阶段各自的作用如下:

  • measure: 为整个 View 树计算实际的大小,即设置实际的高(对应属性:mMeasureHeight)和宽(对应属性:mMeasureWidth),每个 View 的控件的实际宽高都是由父视图和本身视图所决定的。
  • layout:为将整个根据子视图的大小以及布局参数将 View 树放到合适的位置上。
  • draw:利用前两部得到的参数,将视图显示在屏幕上。

当一个 Activity 对象被创建完成之后,会将一个 DecorView 对象添加到 Window 中,同时会创建一个 ViewRootImpl 对象,并将 ViewRootImpl 对象和 DecorView 对象建立联系,然后绘制流程就会从 ViewGroup 的 performTraversals 方法开始执行,如下图所示:

View 绘制流程

整个绘制流程从 ViewRootImpl 的 performTraversals() 方法开始,在该方法内会调用 performMeasure() 方法进行测量子 View(也就是根 View,顶级的 ViewGroup)。然后在 performMeasure 中会调用 measure() 方法来执行具体的测量逻辑,这个时候,代码逻辑就从 ViewRootImp 跳转到了 View 类中了:

1
2
3
4
5
6
7
8
private void performMeasure(int childWidthMeasureSpec, int childHeightMeasureSpec) {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "measure");
try {
mView.measure(childWidthMeasureSpec, childHeightMeasureSpec);
} finally {
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
}

measure() 方法中,有一个 onMeasure() 方法,用于这个方法用来测量子元素的大小,也将测量流程从父元素传递到子元素当中去。紧接着子元素会重复父元素的测量流程,如此反复,就完成了一颗 View 树的遍历。当 measure() 方法完成后,会将结果存储在 LongSparseLongArray 类型的变量 mMeasureCache 中。

performTraversals() 方法中,调用完 performMeasure(),后,会接着调用 performLayout()performDraw() 进行 View 的布局和绘制。这两个流程和测量的流程差不多,就不再叙述。

而这三个阶段分别作了什么呢?源码太长就不贴了,主要的作用如下:

Measure 过程

  • 设置本 View 视图的最终大小。
  • 如果该 View 对象是个 ViewGroup 类型,需要重写该 onMeasure() 方法,对其子视图进行遍历 measure() 过程。
    • measureChildren(),内部使用了一个 for 循环对子视图进行遍历,分别调用了子视图的 measure() 方法。
    • measureChild(),为指定的子视图 measure,会被 measureChildren 调用。
    • measureChildWidthMargins(),为指定的子视图考虑了 margin 和 padding 的 measure。

Layout 过程

  • layout() 方法会设置该 View 视图位于父视图的坐标轴,即 mLeft, mTop, mRight, mBottom.(调用 setFrame() 方法去实现),接下来回调 onLayout() 方法(如果该 View 是 ViewGroup 对象,需要实现该方法,对每个视图进行布局);
  • 如果该 View 是个 ViewGroup 类型,需要遍历每个子视图 childView。调用该子视图的 layout() 方法去设置它的坐标值。

Draw 过程

  • 绘制背景
  • 如果要视图显示渐变框,这里会做一些准备工作
  • 绘制视图本身,即调用 onDraw() 方法。在 view 中,onDraw() 是个空方法,也就是说具体的视图都啊哟覆盖该方法来实现自己的显示(比如 TextView 在这里实现了绘制文字的过程)。而对于 ViewGroup 则不需要实现该方法,因为作为容器是没有内容的,其包含了多个子 View,而子 View 已经实现了自己的绘制方法,因此只需要告诉子 View 绘制自己就行了,也就是下面的 dispatchDraw() 方法。
  • 绘制视图,即 dispatchDraw() 方法。在 View 中这是个空方法,具体的视图不需要实现该方法,它是专门为容器类准备的,也就是容器必须实现该方法。
  • 如果需要,开始绘制渐变框。
  • 绘制滚动条。

因此,如果我们去掉不必要的背景,去掉渐变框,去掉滚动条,在一定程度上是能加快绘制速度的。

优化

帧率(frame per second,即 FPS),指的是每秒刷新的次数。一般电影的帧率为 24FPS、25FPS 和 30FPS。而游戏的帧率一般要保持 60FPS 才能叫做流畅,当游戏的 FPS 低于 30 时,我们就会感受到明显地卡顿。Android 系统每隔 16ms 触发一次 UI 刷新操作,这就要求我们的应用都能在 16ms 内绘制完成。如果有一次的界面绘制用了 22ms,那么,用户在 32ms 内看见的都是同一个界面。情况严重的就会让用户感受到应用运行”卡顿“。

因此,优化的目的,主要就是减少绘制时间,尽量保证每个界面都能在 16ms 内完成绘制。而优化的方案,从上面的分析,我们可以分两个方面:

如何优化

从内优化

  1. 减少 View 层级。这样会加快 View 的循环遍历过程。
  2. 去除不必要的背景。由于 在 draw 的步骤中,会单独绘制背景。因此去除不必要的背景会加快 View 的绘制。
  3. 尽可能少的使用 margin、padding。在测量和布局的过程中,对有 margin 和 padding 的 View 会进行单独的处理步骤,这样会花费时间。我们可以在父 View 中设置 margin 和 padding,从而避免在子 View 中每个单独设置和配置。
  4. 去除不必要的 scrollbar。这样能减少 draw 的流程。
  5. 慎用渐变。能减少 draw 的流程。

从外优化

  1. 布局嵌套过于复杂。这会直接 View 的层级变多。
  2. View 的过渡绘制。
  3. View 的频繁重新渲染。
  4. UI 线程中进行耗时操作。在 Android 4.0 之后,不允许在 UI 线程做网络操作。
  5. 冗余资源及错误逻辑导致加载和执行缓慢。简单的说,就是代码写的烂。
  6. 频繁触发 GC,导致渲染受阻。当系统在短时间内有大量对象销毁,会造成内存抖动,频繁触发 GC 线程,而 GC 线程的优先级高于 UI 线程,因而会造成渲染受阻。

外部因素最为致命!日常开发中更多的应该关心布局的嵌套层级和冗余资源。

比如,当需要将一个 TextView 和一张图片放在一起展示时,我们可以考虑使用 TextView 的 drawableLeft(drawableRight、drawableTop、drawableBottom) 属性来设置图片,而不是使用一个 LinearLayout 来将 TextView 和 ImageView 封装在一起,这样就能减少 View 的绘制层级。

又比如,子元素和父元素都是相同的背景时,就不必在每个子元素中都添加背景属性,等等。

线性布局和相对布局

线性布局和相对布局是我们平时使用最多的布局方式。在一般开发场景中,两者的渲染效率没有明显差别,但是如果真要较真的话,他们之间还是有细微差别的。

RelativeLayout 在测量子 View 排列方式是基于彼此的依赖关系,这种依赖关系导致了子 View 的显示顺序不一定和布局中的 View 的顺序相同,在确定所有子 View 的时候,会先对所有的 View 进行排序,同时,由于 RelativeLayout 允许 “A在横向上依赖于 B,B 在纵向上依赖于 A“,因此会测量两次,导致测量效率较低。而 LinearLayout 由于有 orientation 属性,则测量就很简单了。

LinearLayout 在设置 weight 属性的时候,也会导致二次测量:首先会遍历测量没有 weight 属性的 View,然后再遍历测量包含 weight 属性的 View。

布局比较

布局比较

选择布局容器的基本准则:

  • 尽可能的使用 RelativeLayout 以减少 View 层级,使 View 树趋于扁平化。
  • 不影响层级深度的情况下,使用 LinearLayout 和 FrameLayout 而不是 RelativeLayout

布局标签

说到布局标签,我想大概很多人都用过一些。为了说明 Android 系统对于这些标签的处理,我们先看一下 XML 布局是如何解析绘制到屏幕的。

在 Activity 的 onCreate() 方法中,我们一般会调用 setContentView() 方法,这个方法负责将 XML 文件解析绘制到屏幕上,这个方法很简单:

1
2
3
4
public void setContentView(@LayoutRes int layoutResID) {
getWindow().setContentView(layoutResID);
initWindowDecorActionBar();
}

这个方法第一行是调用 Window 类的 setContentView(),第二行是初始化 ActionBar。Window 类是一个抽象类,它是所有视图相关类的顶层类,其唯一一个实现类是 PhoneWindow,在 PhoneWindow 类的 setContentView() 方法中,会先移除掉所有的 view 视图,然后再调用 LayoutInflater.inflate() 方法绘制,在 LayoutInflater 的 inflate() 方法中,会创建一个 XmlResourceParser 解析器,然后再进行解析。我们来看看 inflate() 方法里面干了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
View result = root;
......
final String name = parser.getName();
// 这里的 TAG_MERGE 其实就是 merge 标签
if (TAG_MERGE.equals(name)) {
if (root == null || !attachToRoot) {
throw new InflateException("<merge /> can be used only with a valid " + "ViewGroup root and attachToRoot=true");
}
rInflate(parser, root, inflaterContext, attrs, false);
} else {
// 这里的 createViewFromTag() 方法创建的是根布局
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
......
ViewGroup.LayoutParams params = null;
if (root != null) {
// 根据 xml 属性生成布局参数
params = root.generateLayoutParams(attrs);
// 生成子元素的布局
rInflateChildren(parser, temp, attrs, true);
if (root != null && attachToRoot) {
root.addView(temp, params);
}
......
}
......
}
return result;
}

为了方便阅读,我将一些代码省略掉,从上面可以看出大致的解析流程:先判断是否有 merge 标签,然后检查其合理性,注意源码已经说明了,merge 标签只能用在 ViewGroup 的根布局中,并且 attachToRoot 必须要设置为 true。然后调用 rInflate() 方法;如果没有 merge 标签,就会调用 rInflateChildren() 方法生成子元素的布局,而这个 rInflateChildren() 方法最终也是辗转到前面的 rInflate() 方法中,我们来看一下这个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
void rInflate(XmlPullParser parser, View parent, Context context,
AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
final int depth = parser.getDepth();
int type;
while (((type = parser.next()) != XmlPullParser.END_TAG ||
parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
if (type != XmlPullParser.START_TAG) {
continue;
}
final String name = parser.getName();
// 判断是否有标签 requestFocus
if (TAG_REQUEST_FOCUS.equals(name)) {
// parseRequestFocus() 方法里面其实就是 parent.requestFocus() 方法!就这么简单!
parseRequestFocus(parser, parent);
}
// 判断是否有标签 tag
else if (TAG_TAG.equals(name)) {
parseViewTag(parser, parent, attrs);
}
// 判断是否有标签 include,该标签不能用在根元素中。
else if (TAG_INCLUDE.equals(name)) {
if (parser.getDepth() == 0) {
throw new InflateException("<include /> cannot be the root element");
}
parseInclude(parser, context, parent, attrs);
}
// 判断是否有标签 merge,该标签只能用在根元素中。由于这里是处理 View 元素的布局,因此直接抛异常
else if (TAG_MERGE.equals(name)) {
throw new InflateException("<merge /> must be the root element");
} else {
// 这里的 createViewFromTag() 方法会根据 View 标签创建 View 对象。
final View view = createViewFromTag(parent, name, context, attrs);
final ViewGroup viewGroup = (ViewGroup) parent;
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
rInflateChildren(parser, view, attrs, true);
viewGroup.addView(view, params);
}
}

if (finishInflate) {
parent.onFinishInflate();
}
}

关于 attachToRoot ,还记得我们在自定义 View 的时候有一句代码:LayoutInflater.from(parent.getContext).inflater(R.layout.xxx, parent, true),这段代码最后一个参数 true,就是讲 attachToRoot 设置为 true。

这里面一共涉及到了四个标签:。下面来分别说一下:

requestFocus

requestFocus 标签就是让标签内的 View 获取焦点,其内部就是使用 view.requestFocus() 方法实现的。

tag

tag 标签是 API 21 里面新增的,用来给 View 对象添加额外的信息。从 Android 1.0 开始,Android 就开始支持给 View 对象调用 setTag(Object) 和 getTag(Object) 来添加和获取标签信息,到了 Android 1.6 ,添加和获取标签信息有了新的方法:setTag(int, Object)。而在 Android 4.0 之后,setTag(int, Object) 的内部实现改为非静态的 SparseArray 来实现。Android 5.0 的时候,提供了一种全新的写法,就是 tag 标签。

举一个例子来说明这个标签怎么用,先编写一个 XML 布局文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">

<Button
android:id="@+id/btn_negative"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="cancel">

<tag
android:id="@+id/btn_state_negative"
android:value="@string/btn_state_negative" />

</Button>

<Button
android:id="@+id/btn_positive"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="ok">

<tag
android:id="@+id/btn_state_positive"
android:value="@string/btn_state_positive" />
</Button>

</LinearLayout>

然后我们就可以通过下面的方式获取标签信息:

1
2
Button btn_negative = (Button) findViewById(R.id.btn_negative);
String tag = (String) btn_negative.getTag(R.id.btn_state_negative);

tag 标签是这四个标签中唯一一个需要指定 id 属性的!

include

我们来看看 处理 include 标签的方法 parseInclude() 里面的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
private void parseInclude(XmlPullParser parser, Context context, View parent,
AttributeSet attrs) throws XmlPullParserException, IOException {
// ATTR_LAYOUT 即 layout 属性,
int layout = attrs.getAttributeResourceValue(null, ATTR_LAYOUT, 0);
......
// 使用了 include 标签而没有为其设置 layout 属性,是会抛异常的!
if (layout == 0) {
final String value = attrs.getAttributeValue(null, ATTR_LAYOUT);
throw new InflateException("You must specify a valid layout "
+ "reference. The layout ID " + value + " is not valid.");
} else {
......
final String childName = childParser.getName();
if (TAG_MERGE.equals(childName)) {
// The <merge> tag doesn't support android:theme, so
// nothing special to do here.
rInflate(childParser, parent, context, childAttrs, false);
} else {
// createViewFromTag() 方法采用反射的方式,根据标签名创建一个 view 对象
// 注意这里的标签并不包括之前提到的 merge 和 include 等等,而是指的 Button、TextView 等 View 相关标签。
final View view = createViewFromTag(parent, childName, context, childAttrs, hasThemeOverride);
ViewGroup.LayoutParams params = null;
try {
params = group.generateLayoutParams(attrs);
} catch (RuntimeException e) {
// Ignore, just fail over to child attrs.
}
if (params == null) {
params = group.generateLayoutParams(childAttrs);
}
view.setLayoutParams(params);
// Inflate all children.
rInflateChildren(childParser, view, childAttrs, true);
// 设置 View 对象的 id 属性
if (id != View.NO_ID) {
view.setId(id);
}
// 设置 View 对象的可见性
switch (visibility) {
case 0:
view.setVisibility(View.VISIBLE);
break;
case 1:
view.setVisibility(View.INVISIBLE);
break;
case 2:
view.setVisibility(View.GONE);
break;
}
group.addView(view);
}
}
......
LayoutInflater.consumeChildElements(parser);
}

parseInclude() 方法里面会判断是否需要处理 merge 标签,然后根据标签名(如 Button、TextView 等)调用 createViewFromTag() 方法创建一个 view 对象,然后生成该对象的布局参数,设置 id 属性,设置可见性等等。

然后注意到 createViewFromTag(),顾名思义,该方法会根据 XML 的标签来创建 View 对象,这个方法里面最终会调用到 createView() 方法,是使用反射来创建 View 对象的具体实现。

有个问题不知道大家注意到没有,这些 id、可见性等等的属性都是 view 对象的,而 include 标签和 merge 标签并并没有这些属性,也就是说,如果你在 include 或 merge 标签中设置了一个 id,然后在代码中通过 findViewById() 方法企图找到这个 include 或 merge 的布局,是会报空指针异常的!

我这里为了方便区分,将 include、merge 等标签称为“布局标签”,它们不能创建为 View 对象,设置 id 属性对它们没有意义。而将 XML 中的 Button、TextView 等标签称为“视图标签”(视图元素、控件等),因为它们能被创建为 View 对象,可以设置 id 等熟悉。

其他比较有趣的标签

除了上面介绍的 merge、include、requestFocus 和 tag 等布局标签外,还有如下常用的 View 标签:

ViewStub

利用 ViewStub 标签可以让布局懒加载。当你界面要显示很多内容,而其中一些不用立即显示出来的时候(比如商品详情、下载进度条等等),可以使用 ViewStub 标签来隐藏这些内容,当需要的时候再让它们加载出来。ViewStub 虽然是 View 标签,但是其本身没有大小,不会绘制任何东西,因此是一个非常轻量的 View 标签。

使用 ViewStub 和 include 标签类似,需要使用 android:layout 属性来确定需要隐藏的布局,同时,由于 ViewStub 是一个 View 标签,因此需要使用一个 id 来操作 ViewStub。比如使用 ViewStub 简单的 XML 如下:

1
2
3
4
5
<ViewStub
android:id="@+id/stab_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout="@layout/text_view" />

在 Java 代码中,对 ViewStub 的操作有两种方式:

  • 设置 View 的可见性

    1
    findViewById(R.id.stab_view)).setVisibility(View.VISIBLE);
  • 调用 ViewStub 的 inflate() 方法

    1
    2
    ViewStub stubView = (ViewStub) findViewById(R.id.stab_view);
    stubView.inflate();

上面两种方法都可以加载由 ViewStub 引用的布局。使用 ViewStub 有两点需要注意:

  1. 当调用了 inflate() 方法后,ViewStub 标签就从视图中移除了,也就是说,inflate() 方法不能对同一个 ViewStub 调用两次。
  2. ViewStub 所引用的布局的根标签不能为 标签。

Space

这是是一个空控件,该 View 没有实现 onDraw() 方法,因此绘制效率比较高。该控件可以用来占用空白(比如代替 padding 和 margin)。

大概差不多了,View 的性能优化还有一些没有介绍,比如 Overdraw 等,这里就给个链接吧:OverDraw

同时,对于 View 性能优化有兴趣的同学,欢迎参加视频课程:有心课堂

如果觉得文章对你有帮助,请我喝杯可乐吧