Android 的 UI 优化学习笔记和总结,包括一些导致卡顿的原因和一些解决方案,欢迎大家一起学习交流!

16ms

Android 系统每隔 16ms 发出 VSYNC 信号触发对UI进行渲染,那么就要求每一帧都要在 16ms 内绘制完成(包括发送给 GPU 和 CPU 绘制到缓冲区的命令,这样就能够达到流畅的画面所需要的60fps。

http://yydcdut.github.io/img/16ms.png

如果你的某个操作花费时间是24ms,系统在得到 VSYNC 信号的时候就无法进行正常渲染,这样就发生了丢帧现象。那么用户在 32ms 内看到的会是同一帧画面。

http://yydcdut.github.io/img/34ms.png

丢帧原因

有很多原因可以导致丢帧,这里列举一些常见的:

  • layout 太过复杂,层次过多
  • UI 上有层叠太多的绘制单元,过度绘制
  • CPU 或者 GPU 负载过重
  • 动画执行的次数过多
  • 频繁 GC,主要是内存抖动
  • UI 线程执行耗时操作
  • 等等

分析

接下来逐个分析导致原因以及解决方案:

布局太过复杂,层次过多

layout 布局是一棵树,树根是 window 的 decorView,套嵌的子 view 越深,树就越复杂,渲染就越费时间。每个 View 都会经过 measure、layout 和 draw 三个流程,都是从树根开始,那么选父布局的时候就要考虑渲染的性能问题:这里分析一下常见的布局控件 LinearLayoutRelativeLayoutFrameLayout

LinearLayout

LinearLayout 在 measure 的时候,在横向或者纵向会去测量子 View 的宽度或高度,且只会测量一次,但是当设置 layout_weight 属性的时候会去测量两次才能获得精确的展示尺寸。

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
public class LinearLayout extends ViewGroup {
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (mOrientation == VERTICAL) {
measureVertical(widthMeasureSpec, heightMeasureSpec);
} else {
measureHorizontal(widthMeasureSpec, heightMeasureSpec);
}
}

void measureVertical(int widthMeasureSpec, int heightMeasureSpec) {
//blablabla......
final int count = getVirtualChildCount();

for (int i = 0; i < count; ++i) {
//blablabla......
if (heightMode == MeasureSpec.EXACTLY && lp.height == 0 && lp.weight > 0) {
//blablabla......
} else {
//blablabla......

// Determine how big this child would like to be. If this or
// previous children have given a weight, then we allow it to
// use all available space (and we will shrink things later
// if needed).
measureChildBeforeLayout(
child, i, widthMeasureSpec, 0, heightMeasureSpec,
totalWeight == 0 ? mTotalLength : 0);
//blablabla......
}
}
//blablabla......
if (skippedMeasure || delta != 0 && totalWeight > 0.0f) {
for (int i = 0; i < count; ++i) {
LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) child.getLayoutParams();

float childExtra = lp.weight;
if (childExtra > 0) {
int share = (int) (childExtra * delta / weightSum);
weightSum -= childExtra;
delta -= share;

final int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
mPaddingLeft + mPaddingRight +
lp.leftMargin + lp.rightMargin, lp.width);

if ((lp.height != 0) || (heightMode != MeasureSpec.EXACTLY)) {
// child was measured once already above...
// base new measurement on stored values
int childHeight = child.getMeasuredHeight() + share;
if (childHeight < 0) {
childHeight = 0;
}

child.measure(childWidthMeasureSpec,
MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY));
} else {
// child was skipped in the loop above.
// Measure for this first time here
child.measure(childWidthMeasureSpec,
MeasureSpec.makeMeasureSpec(share > 0 ? share : 0,
MeasureSpec.EXACTLY));
}
}
}
} else {
alternativeMaxWidth = Math.max(alternativeMaxWidth,
weightedMaxWidth);


// We have no limit, so make all weighted views as tall as the largest child.
// Children will have already been measured once.
if (useLargestChild && heightMode != MeasureSpec.EXACTLY) {
for (int i = 0; i < count; i++) {
final View child = getVirtualChildAt(i);

if (child == null || child.getVisibility() == View.GONE) {
continue;
}

final LinearLayout.LayoutParams lp =
(LinearLayout.LayoutParams) child.getLayoutParams();

float childExtra = lp.weight;
if (childExtra > 0) {
child.measure(
MeasureSpec.makeMeasureSpec(child.getMeasuredWidth(),
MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(largestChildHeight,
MeasureSpec.EXACTLY));
}
}
}
}
//blablabla......
}
}

RelativeLayout

RelativeLayout 在 measure 的时候会在横向和纵向各测量一次。

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public class RelativeLayout extends ViewGroup {
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//blablabla......

View[] views = mSortedHorizontalChildren;
int count = views.length;
for (int i = 0; i < count; i++) {
View child = views[i];
if (child.getVisibility() != GONE) {
LayoutParams params = (LayoutParams) child.getLayoutParams();
int[] rules = params.getRules(layoutDirection);

applyHorizontalSizeRules(params, myWidth, rules);
measureChildHorizontal(child, params, myWidth, myHeight);

if (positionChildHorizontal(child, params, myWidth, isWrapContentWidth)) {
offsetHorizontalAxis = true;
}
}
}
//blablabla......

views = mSortedVerticalChildren;
count = views.length;
for (int i = 0; i < count; i++) {
final View child = views[i];
if (child.getVisibility() != GONE) {
final LayoutParams params = (LayoutParams) child.getLayoutParams();

applyVerticalSizeRules(params, myHeight, child.getBaseline());
measureChild(child, params, myWidth, myHeight);
if (positionChildVertical(child, params, myHeight, isWrapContentHeight)) {
offsetVerticalAxis = true;
}

if (isWrapContentWidth) {
if (isLayoutRtl()) {
if (targetSdkVersion < Build.VERSION_CODES.KITKAT) {
width = Math.max(width, myWidth - params.mLeft);
} else {
width = Math.max(width, myWidth - params.mLeft - params.leftMargin);
}
} else {
if (targetSdkVersion < Build.VERSION_CODES.KITKAT) {
width = Math.max(width, params.mRight);
} else {
width = Math.max(width, params.mRight + params.rightMargin);
}
}
}

if (isWrapContentHeight) {
if (targetSdkVersion < Build.VERSION_CODES.KITKAT) {
height = Math.max(height, params.mBottom);
} else {
height = Math.max(height, params.mBottom + params.bottomMargin);
}
}

if (child != ignore || verticalGravity) {
left = Math.min(left, params.mLeft - params.leftMargin);
top = Math.min(top, params.mTop - params.topMargin);
}

if (child != ignore || horizontalGravity) {
right = Math.max(right, params.mRight + params.rightMargin);
bottom = Math.max(bottom, params.mBottom + params.bottomMargin);
}
}
}
//blablabla......
}
}

简析

如果带有 weight 属性的 LinearLayout 或者 RelativeLayout 被套嵌使用,measure 所费时间可能会呈指数级增长(两个套嵌的叶子 view 会有四次 measure,三个套嵌的叶子 view 会有8次的 measure)。为了缩短这个时间,保持树形结构尽量扁平(深度低),而且尽量要移除所有不需要渲染的 view。

Hierarchy Viewer

Hierarchy Viewer 可以很方便可视化的查看屏幕上套嵌 view 结构,这个工具在 sdk 的 tools 文件里面。

栗子

http://yydcdut.github.io/img/MsgNumberView_before0.png

http://yydcdut.github.io/img/MsgNumberView_before1.png

MsgNumberView 是一个自定义控件,其 measure、layout 和 draw 共花费 3ms 的时间,可以发现布局中多了一层 LinearLayout,而该 LinearLayout 而进行了测量等操作,共花费 1.4ms 时间。当我们去除中间的 LinearLayout 后再分析看:

http://yydcdut.github.io/img/MsgNumberView_after.png

去除之后发现,总体在渲染上下降了很多时间,变为了 0.25ms。

你可能已经注意到了每个 view 里黄色、绿色等圆圈。它们表示该 view 在那一层树形结构里 measure,layout 和 draw 所花费的相对时间。绿色表示最快的前 50%,黄色表示最慢的前 50%,红色表示那一层里面最慢的 view 。

再来看一个栗子:

http://yydcdut.github.io/img/JobPostDetail_CompanyScale_before.png

该 LinearLayout 里面有三个子 View,其中两个也是 LinearLayout ,并且子 LinearLayout 中是两个 TextView,对于最外层的 LinearLayout 来说,渲染共花费了 3.6ms 左右。那么处理一下,减少深度:

http://yydcdut.github.io/img/JobPostDetail_CompanyScale_after.png

发现渲染减到了 1ms 左右。当然这里的修改不仅仅是布局上的修改,在 java 代码上也有一些改动,之前上边的 TextView 是作为 Label 控件,那么现在 Label 和 真正显示数据的 TextView 合并成一个,在 Java 代码中也进行了处理,包括 Label 的字体颜色与显示控件的字体颜色不一样,通过 Html 或者 Spannable 进行修饰等等。

优化

  • 避免复杂的 View 层级
  • 避免 layout 顶层使用 RelativeLayout
  • 布局层次相同的情况下,使用 LinearLayout
  • 复杂布局建议采用 RelativeLayout 而不是多层次的 LinearLayout
  • <include/> 标签复用
  • <merge/> 标签减少嵌套
  • 尽量避免 layout_weight
  • 视图按需加载或者使用 ViewStub

层叠太多,过度绘制

跟 measure 一样, View 的绘制也是从树根开始一层一层往叶子绘制,就难免导致叶子的绘制挡住了其父节点的一些绘制的内容。过渡绘制是一个术语,表示某些组件在屏幕上的一个像素点的绘制次数超过 1 次。过度绘制导致的问题是花了太多的时间去绘制那些堆叠在下面的、用户看不到的东西,浪费了 CPU 周期和渲染时间。

调试 GPU 过度绘制

http://yydcdut.github.io/img/overdraw.png

蓝色,淡绿,淡红,深红代表了4种不同程度的 Overdraw 情况,我们的目标就是尽量减少红色 Overdraw,看到更多的蓝色甚至白色区域。

http://yydcdut.github.io/img/overdraw_options_view.png

栗子

http://yydcdut.github.io/img/overdraw_bad.pnghttp://yydcdut.github.io/img/overdraw_good.png

这里展示的是帖子的详情页 Activity,在做这里的过度绘制的优化的时候,我从 xml 文件和 Java 代码两个层面去进行优化,在 xml 中去除无用的 background 等,点击态的 normal 状态统一用 transparent,在 Java 代码中,当 loading 结束后,修改 loading 的背景由灰色变为白色颜色等。

优化

  • 去除重复或者不必要的 background
  • 点击态中的 normal 尽量设置成 transparent
  • 去除 window 中的 background(这个可以通过处理 decorView 或者设置 Theme 的方式)
  • 若是自定义控件的话,通过 canvas.clipRect() 帮助系统识别那些可见的区域

http://yydcdut.github.io/img/android_perf_course_clip_1.png

上面的示例图中显示了一个自定义的 View,主要效果是呈现多张重叠的卡片。这个 View 的 onDraw 方法如下图所示:

http://yydcdut.github.io/img/android_perf_course_clip_3.png

打开开发者选项中的显示过度渲染,可以看到我们这个自定义的 View 部分区域存在着过度绘制。下面的代码显示了如何通过 clipRect 来解决自定义 View 的过度绘制,提高自定义 View 的绘制性能:

http://yydcdut.github.io/img/android_perf_course_clip_code_compare.png

下面是优化过后的效果:

http://yydcdut.github.io/img/android_perf_course_clip_result.png

负载过重

UI 线程是应用的主线程,很多的性能和卡顿问题是由于在主线程中做了大量的工作。除了主线程外,子线程占用过多 CPU 资源也会导致渲染性能问题。

在 UI 渲染的过程中,是 CPU 和 GPU 共同合作完成的,其中 CPU 负责把 UI 组件计算成 Polygons,Texture 纹理,然后交给 GPU 进行栅格化渲染。

http://yydcdut.github.io/img/gpu_cpu_rasterization.png

GPU 呈现模式分析

http://yydcdut.github.io/img/tools_gpu_profile.png

通过在 Android 设备的开发者选项里启动 “ GPU 呈现模式分析 ” ,可以得到最近 128 帧 每一帧渲染的时间。在 Android 6.0 之前,界面上显示的柱状图主要是三个颜色,分别是黄、红和蓝色。

通俗点来讲,黄色代表 CPU 通知 GPU,当 CPU 有太多事情做的时候,黄色的线就会长一些;红色代表渲染时间,比如层次深的情况下,渲染时间就会长一点,红色的线也会长一些;蓝色代表执行 onDraw() 时间。而横着的绿色的那条线代表 16ms 分割线。

栗子

http://yydcdut.github.io/img/cpu_gpu_good.jpghttp://yydcdut.github.io/img/cpu_gpu_bad.jpg

这是一个选择照片的功能的一个页面,用的 RecyclerView,两张图的唯一区别在于 Adapter 中加入了一段异步耗时操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MediaPhotoAdapter extends RecyclerView.Adapter<MediaPhotoViewHolder> {
@Override
public void onBindViewHolder(MediaPhotoViewHolder holder, int position) {
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
YLog.i("tag", "i-->" + i);
}
}
}).start();
//blablabla.....
}

每次更新 View 的时候都会开启新线程做一些耗时的操作,这个线程就用了大部分 CPU 资源,这个过程就跟在 ListView 滑动的时候异步加载图片类似。

Android System Trace

VSYNC-app 是均匀分布的宽条,每个宽条表示 16 ms。当发出 VSYNC 信号时, surfaceflinger 会去绘制刷新,在理想情况下 surfaceflinger 之间相距也是 16ms,因此如果出现长条空缺则表示 surfaceflinger 丢掉了一次 VSYNC 更新信号,屏幕就没有及时的刷新。

http://yydcdut.github.io/img/trace_0.png

从图片上来看,在加载页面的时候发生过好几次丢帧的情况,可以通过方法开查看具体什么原因导致的丢帧。Frames 是提供的判断绘制该帧的情况,分别有绿、黄和红色,当为空色的时候表示该帧耗时很严重,我们就可以从这些红色的 F 为出发点去分析。

http://yydcdut.github.io/img/trace_1.png

我们可以查从图片上可以看出,先进行了 dorceView-inflate 操作(UI Thread 绿色那部分),这个操作在 UI 线程,且速度很快,在 1ms 内就完成了,接下来就是两个蓝色的 inflate 操作了。

http://yydcdut.github.io/img/trace_2.png

http://yydcdut.github.io/img/trace_3.png

第一次 inflate 是在 Activity 的 setContent() 中完成,其中看到了之前所说的 MsgNumberView,第二次 inflate 是发生在 Fragment 中,界面中除了 Titlebar,其他的都是在这个 Fragment 中展示的,所以这个界面的 inflate 比 Activity 的更加耗时。

第一次 inflate 和 第二次 inflate 之间还有一段时间的白色间隙,这是因为初始化 View (比如 findViewById 等)、网络请求封装、业务逻辑等操作。在完成第二次 inflate 之后发现后面还有一小段的白色间隙,这是因为等待一下个 VSYNC 信号。

http://yydcdut.github.io/img/trace_4.png

这里的 F 是黄色的,我的猜测这里应该是网络请求的数据返回回来了,因为这个页面的数据量巨大,接近百个字段吧,同时数据解析是放在 UI 线程进行的,包括 InputStream 转 String,String 转 Json 再解析。同时在下面建议中也说明了建议放在后台线程中以免阻塞 UI 线程。

http://yydcdut.github.io/img/trace_5.png

这里又发生了在 UI 线程的耗时的 inflate 事情,这是因为对于不同的帖子,这些数据可能会展示可能会不展示,而在需求开发中明确了这些数据不展示的情况大于真是的情况,所以采用了动态的 inflate 操作,也可以采用 ViewStub 哈。

http://yydcdut.github.io/img/trace_6.png

这里又发生了超级耗时的操作, F 都为红色了,根据描述来分析是因为 Measure 和 Layout 以及 draw() 花费了太多的时间。

(Android System Trace 用的还不是很熟练,有不对的地方轻喷)

内存抖动

http://yydcdut.github.io/img/memory_monitor_gc.png

在我接触过的内存抖动中,主要导致原因是频繁创建大对象或者频繁创建大量对象,并且这些对象属于用完就废弃的,比如 byte[] 。我接触到的内存抖动是在 Camera 获取帧数据,在回调函数中 onPreviewFrame(byte[] data, Camera camera) 使用到了 byte[] ,等到下一帧数据回调回来的时候又是一个新的 byte[] 。而 GC 操作或多或少都会 “ stop-the-world “,比如 GC 操作花费了 5ms 的时间,那么该帧的绘制就会从原来的 16ms 变为 11ms

优化

  • 大对象可以使用对象池复用,比如 byte[]
  • 尽量在 16ms 内少创建对象,比如在 onDraw 中创建 Paint 对象,decode Bitmap 之类的

硬件加速

并非所有的都支持硬件加速,其中包括 clipPath() 等;同时也有一些方法在开启硬件加速之后与不开启硬件加速效果不一样,比如 drawBitmapMesh() 等。

Application 级别

1
<applicationandroid:hardwareAccelerated = "true" ...>

Activity 级别

1
<activity android:hardwareAccelerated = "true" ...>

Window 级别

1
2
3
getWindow().setFlags(
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED);

View 级别

1
View.setLayerType(View.LAYER_TYPE_HARDWARE, null);

参考