0%

Android性能优化之布局与绘制优化

概述

Android 图形显示系统的相关原理可参考 Android图形系统综述 中的系列文章,在了解了 Android 图形显示系统的工作原理之后,可以得出造成页面卡顿的根本原因可分为两种:

  1. 绘制任务过于复杂,使得绘制一帧的时间过长。
  2. 主线程太忙碌,使得没有及时处理 Vsync 信号到来后的绘制任务。

因此可以针对这两个原因来做相关的优化工作。

屏幕适配

由于 Android 存在的系统,屏幕及分辨率的碎片化,使得 Android 的屏幕适配尤为重要,在此之前介绍一些相关概念:

  • px: 像素单位,通常说的手机分辨率 1200*1920 便是 px 单位。
  • dp(dip):设备独立像素,在不同的像素密度的设备上会自动适配,规定在 160dpi 的设备上 1 dp = 1 px。
  • dpi: dots per inch,对角线每英寸的像素点的个数。
  • sp: 同 dp 相似,它还会根据用户的字体大小偏好来缩放。
  • 计算公式:px = dp * (dpi/160) = dp * density。
  • 使用 sp 作为字体大小单位时会随着系统的字体大小改变,而 dp 则不会,因此建议字体大小的数值使用 sp 作为单位。

dp 与 px 值转换:

1
2
3
4
5
6
7
8
9
fun dp2px(context: Context, dpValue: Float): Int {
val scale: Float = context.resources.displayMetrics.density
return (dpValue * scale + 0.5f).toInt()
}

fun px2dp(context: Context, pxValue: Float): Int {
val scale: Float = context.resources.displayMetrics.density
return (pxValue / scale + 0.5f).toInt()
}

通常情况下,除了直接使用 Android 原生的 dp 等适配方案外,还可能使用一些其它的适配方案,关于屏幕的适配方案在网上一搜一大堆,这里只介绍一种,其余的网上搜索就行了:根据屏幕的宽/高度动态调整设备的 density 值(每 dp 的像素数),强行把所有不同尺寸分辨率的手机的 dp 值改成一个统一的值,对应公式: density = 设备屏幕宽度(像素) / 设计图宽度(dp)。代码如下:

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
object Utils {
private const val WIDTH = 400f
private var appDensity = 0f
private var appScaleDensity = 0f

fun setDensity(application: Application, activity: Activity) {
val displayMetrics = application.resources.displayMetrics
if (appDensity == 0f) {
appDensity = displayMetrics.density
appScaleDensity = displayMetrics.scaledDensity

application.registerComponentCallbacks(object : ComponentCallbacks {
override fun onConfigurationChanged(newConfig: Configuration?) {
// 字体发生更改,重新对 scaleDensity 赋值
if (newConfig != null && newConfig.fontScale > 0) {
appScaleDensity = application.resources.displayMetrics.scaledDensity
}
}

override fun onLowMemory() {}
})
}

val targetDensity = displayMetrics.widthPixels / WIDTH
val targetScaleDensity = targetDensity * (appScaleDensity / appDensity)
val targetDensityDpi = (targetDensity * 160).toInt()

val dm = activity.resources.displayMetrics
dm.density = targetDensity
dm.scaledDensity = targetScaleDensity
dm.densityDpi = targetDensityDpi
}
}

// 在目标 Activity 中:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Utils.setDensity(application, this)
setContentView(R.layout.activity_main)
}
}

布局优化

减少布局层级: merge

核心思想:

  • 删除布局中无用的控件和层次,有选择地使用性能好的 ViewGroup, 合理使用 RelativeLayout 和 LinearLayout,善于利用 ConstraintLayout 布局减少层级嵌套
  • 合理使用 <merge> 标签。

RelativeLayout 会对子 View 做两次测量,而 LinearLayout 如果存在 weight 属性则也会进行两次测量。

对于使用 merge 标签的布局文件,会直接将其子元素添加到 merge 标签的 parent 中,而不会引入额外的层级。注意:

  • merge 标签只能用作根元素。
  • 使用 merge 加载布局时,必须指定一个 ViewGroup 作为其父元素,并且要设置加载的 attachToRoot 参数为true。
  • 不能在 ViewStub 中使用 merge 标签,因为 ViewStub 的 inflate 方法中没有 attachToRoot 设置。

示例代码:

layout_merge.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_gravity="center_horizontal"
android:text="标签一" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_gravity="center_horizontal"
android:text="标签二" />
</merge>

当父布局是 RelativeLayout 时,<merge> 标签内的控件会按照相对布局排列;当父布局是 LinearLayout 时,<merge> 标签内的控件会按照线性布局排列。

懒加载布局: ViewStub

ViewStub 是一个轻量级的 View 组件,它不可见且不占布局位置,占用资源非常小。只有通过调用 ViewStub.setVisibility 方法或者 ViewStub.inflate 方法才会将其指向的布局文件加载出来,从而达到延迟加载的效果。注意:

  • ViewStub 只能加载一次,之后 ViewStub 对象会被置空。
  • ViewStub 只能加载布局文件,而不是某个具体的 View。
  • ViewStub 中不能嵌套 merge 标签。

示例如下:

activity_main.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?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="match_parent"
android:orientation="vertical">

<com.hearing.demo.trace_test.MyView
android:layout_width="200dp"
android:layout_height="200dp"
android:onClick="click" />

<ViewStub
android:id="@+id/view_stub"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout="@layout/layout_text" />
</LinearLayout>

layout_text.xml

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/app_name" />

使用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 方式一
fun click(view: View) {
// 再次点击时会抛出 NullPointerException 异常,因为 viewStub 为 null
val viewStub = findViewById<ViewStub>(R.id.view_stub)
val textView = viewStub.findViewById<TextView>(R.id.text_view)
viewStub.visibility = View.VISIBLE
viewStub.post {
if (viewStub.visibility == View.VISIBLE) {
textView.text = "hearing"
}
}
}

// 方式二
fun click(view: View) {
// 再次点击时会抛出 NullPointerException 异常,因为 viewStub 为 null
val viewStub = findViewById<ViewStub>(R.id.view_stub)
val textView = viewStub.inflate() as TextView
textView.text = "hearing"
}

布局复用: include

布局复用可以通过 <include> 标签实现:

1
2
3
4
5
6
7
8
9
10
<?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="match_parent"
android:orientation="vertical">

<!-- ... -->

<include layout="@layout/layout_text"/>
</LinearLayout>

其它方案

  • 使用低端机器进行测试,便于发现性能瓶颈。
  • 使用 <Space> 标签添加间距。
  • 尽可能少用 wrap_content 尺寸,因为它会增加布局 measure 的计算成本,已知宽高为固定值就使用固定值。

避免过度绘制

产生过度绘制的主要原因是:

  • XML 布局: 控件重叠或设置了无必要的背景。
  • View 绘制: View.OnDraw 中同一个区域被绘制多次。

可以使用手机开发者选项中的 Show GPU Overdraw 功能来查看过度绘制,它会通过不同的颜色来表示绘制的次数:无,蓝色,绿色,粉色,红色,分表代表没有过度绘制,过度绘制 1–4 次,参考 直观呈现 GPU 过度绘制

XML 优化

  • 减少重叠的元素,并可以将重叠的元素背景设置为空
  • 移除 XML 中非必需的背景
  • 移除非必要的 Window 背景: window?.setBackgroundDrawable(null)

自定义 View 优化

  • 可以使用 canvas.clipRect 方法指定一块矩形区域,在绘制一个元素之前先判断该元素是否在指定的区域内,若不在则直接返回,否则才绘制。比如说自定义重叠的图片控件时可以通过这个方法避免过度绘制。
  • 可以使用 invalidate(Rect dirty) 指定脏区,避免整个 View 都重绘。
  • onDraw 方法中避免创建对象,避免做耗时操作。

提高动画性能

提升动画性能主要有以下三个角度:

  • 流畅度:控制每一帧动画在 16ms 内完成。
  • 内存:避免内存泄漏,减小内存开销。
  • 耗电:减小运算量,优化算法,减小 CPU 占用。

卡顿监控方案

Choreographer

可以使用 Choreographer.getInstance().postFrameCallback() 监听帧率情况,原理在之前的文章中已经解析过了。

Printer

查看 Looper 机制,发现在其源码中有以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Looper
public static void loop() {
for (;;) {
Message msg = queue.next();
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " + msg.callback + ": " + msg.what);
}
// 处理消息
// ...
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
// ...
}
}

可以看到 Looper 循环消息时,会通过 Printer 输出特定格式的消息,因此可以给主线程的 Looper 设置一个自定义的 Printer 对象用来监听每个消息的处理时长:

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
class MyPrinter : Printer {

private var start = 0L
private var end = 0L

companion object {
private const val LOG_START = ">>>>> Dispatching"
private const val LOG_END = "<<<<< Finished"
private const val TIME_OUT = 1000
}

override fun println(x: String?) {
x?.let {
if (x.startsWith(LOG_START)) {
start = System.currentTimeMillis()
}
if (x.startsWith(LOG_END)) {
end = System.currentTimeMillis()
val t = end - start
if (t > TIME_OUT) {
Log.d("LLL", "timeout happened when handle msg: $it")
}
}
}
}
}

// 设置自定义 Printer 对象
Looper.getMainLooper().setMessageLogging(MyPrinter())

如果发现有卡顿后再收集调用栈,则调用栈信息可能不准确,因为此时卡顿代码可能已经执行完成了,此刻搜集到的信息可能不是卡顿发生的关键信息。就像 OOM 一样,它是一个随时都有可能发生的。所以我们需要高频率的收集日志信息,高频率的收集对后端有一定的压力,而我们高频收集的信息有很大一部分也是重复的,所以就需要日志去重操作。