概述
Android 图形显示系统的相关原理可参考 Android图形系统综述 中的系列文章,在了解了 Android 图形显示系统的工作原理之后,可以得出造成页面卡顿的根本原因可分为两种:
- 绘制任务过于复杂,使得绘制一帧的时间过长。
- 主线程太忙碌,使得没有及时处理 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?) { 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 } }
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) { 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) { 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
| 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") } } } } }
Looper.getMainLooper().setMessageLogging(MyPrinter())
|
如果发现有卡顿后再收集调用栈,则调用栈信息可能不准确,因为此时卡顿代码可能已经执行完成了,此刻搜集到的信息可能不是卡顿发生的关键信息。就像 OOM 一样,它是一个随时都有可能发生的。所以我们需要高频率的收集日志信息,高频率的收集对后端有一定的压力,而我们高频收集的信息有很大一部分也是重复的,所以就需要日志去重操作。