4. 布局层级优化:过度绘制(Overdraw)检测、减少布局层级(ConstraintLayout、Merge标签)、ViewStub延迟加载
触摸响应延迟,很多时候不是CPU算不动,而是GPU画得太累。
你想想看,用户点一下屏幕,系统要在一帧(16.6ms)内完成响应。如果布局层级太深,GPU每帧要把同一个像素反复画好几遍——这就是过度绘制(Overdraw)。我见过不少项目,性能瓶颈根本不是代码逻辑,而是布局文件里套了七八层LinearLayout。
4.1 过度绘制(Overdraw)检测
过度绘制说白了,就是屏幕上一个像素被重画了多次。比如你有一个红色背景的View,上面又盖了一个蓝色背景的View,那蓝色区域下面的红色其实白画了。
怎么发现这个问题?Android自带的工具就够用。
开启GPU过度绘制调试
在开发者选项里找到「调试GPU过度绘制」,选「显示过度绘制区域」。屏幕会变成彩色地图:
- 蓝色:过度绘制1次(正常)
- 绿色:过度绘制2次(可接受)
- 淡红:过度绘制3次(需要优化)
- 深红:过度绘制4次以上(必须优化)
我个人习惯,在真机上跑一遍核心页面。看到大片的深红区域,基本就是布局层级出了问题。我曾经在一个列表页面上看到整屏深红,排查后发现是每个item的背景和父容器背景重叠了——去掉父容器的背景色,直接降到淡蓝。
小技巧:用adb命令截图过度绘制状态,方便对比优化前后效果。
adb shell setprop debug.hwui.overdraw show
adb shell screencap /sdcard/overdraw.png
4.2 减少布局层级
层级越深,measure/layout/draw三阶段耗时越长。我见过最夸张的一个页面,LinearLayout嵌套了9层,光布局测量就占了8ms。你想想看,一帧总共才16.6ms,布局就吃掉一半,留给触摸响应的时间还有多少?
4.2.1 用ConstraintLayout扁平化
ConstraintLayout是我现在的主力布局容器。它用相对定位和约束关系,能把多层嵌套压成一层。
举个例子,一个典型的卡片布局:
<!-- 优化前:3层嵌套 -->
<LinearLayout>
<RelativeLayout>
<ImageView />
<TextView />
</RelativeLayout>
<LinearLayout>
<Button />
<Button />
</LinearLayout>
</LinearLayout>
<!-- 优化后:1层ConstraintLayout -->
<androidx.constraintlayout.widget.ConstraintLayout>
<ImageView
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
app:layout_constraintStart_toEndOf="@id/image"
app:layout_constraintTop_toTopOf="@id/image" />
<Button
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<Button
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
从3层降到1层,测量时间能减少40%以上。我在项目中做过对比测试,同一个页面用RelativeLayout嵌套需要5层,换成ConstraintLayout后只剩2层,帧率从45fps升到58fps。
注意:ConstraintLayout不是万能的。如果布局逻辑极其简单(比如只有一个TextView),直接用FrameLayout反而更快。别为了用而用。
4.2.2 用Merge标签消除冗余父布局
Merge标签是个容易被忽略的好东西。它能把include进来的布局直接合并到父容器里,省掉一层。
比如你有一个公共的头部布局:
<!-- header_layout.xml -->
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<ImageView
android:id="@+id/header_icon"
android:layout_width="40dp"
android:layout_height="40dp" />
<TextView
android:id="@+id/header_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</merge>
然后在主布局里include:
<LinearLayout>
<include layout="@layout/header_layout" />
<!-- 其他内容 -->
</LinearLayout>
如果不加merge,include会自带一个ViewGroup容器,白白多一层。加了merge,header里的ImageView和TextView直接挂到LinearLayout下面。
我记得有一次优化一个列表item,去掉include里的冗余父布局后,item的绘制时间从1.2ms降到了0.8ms。别小看这几百微秒,列表滑动时每帧要绘制十几个item,累积效果很明显。
4.3 ViewStub延迟加载
有些布局不是一开始就需要显示的。比如错误提示、加载动画、用户引导浮层。如果把这些布局直接写在主布局里,即使它们不可见(GONE),也会参与measure和layout,白白浪费性能。
ViewStub就是干这个的。它是一个轻量级的占位View,只有在调用inflate()时才真正加载布局。
<ViewStub
android:id="@+id/error_stub"
android:layout="@layout/layout_error"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
// 在需要时加载
View errorView = findViewById(R.id.error_stub);
if (errorView != null) {
errorView.setVisibility(View.VISIBLE); // 触发加载
}
加载过一次后,ViewStub会被替换成真正的布局,之后的操作和普通View一样。
使用场景:
- 网络请求失败时的错误页面
- 首次进入时的用户引导
- 需要用户主动触发的扩展面板
- 广告位(如果广告SDK支持)
我曾经在一个详情页里,把三个不常用的功能面板改成ViewStub延迟加载。页面首次加载时间从320ms降到了210ms,触摸响应延迟也明显改善。因为主线程不用花时间测量那些看不见的布局了。
避坑指南:ViewStub一旦加载后就不能再次隐藏或重新加载。如果你需要频繁切换显示/隐藏,用View.GONE配合普通布局反而更合适。
4.4 知识体系总览
下面这张图总结了布局层级优化的核心路径:
布局层级优化,说白了就是让GPU少做无用功。检测过度绘制、用ConstraintLayout和Merge减少层级、用ViewStub延迟加载——这三板斧下去,大部分触摸响应延迟问题都能缓解。我自己的经验是,先跑一遍过度绘制检测,找到深红区域,然后针对性地优化布局结构,最后把不常用的部分用ViewStub包起来。这套流程走下来,帧率提升10-20fps是很常见的事。