布局优化基础:过度绘制(Overdraw)的原理与检测
说到布局优化,我第一个想聊的就是过度绘制。这玩意儿,说白了就是屏幕上的同一个像素点,被重复画了好几次。
你想想看,手机屏幕就那么大,像素点就那么多。每次刷新,GPU都要把每个像素算一遍颜色。如果一个像素被画了三次,那GPU就得干三倍的活。低端机本来就性能吃紧,这不是雪上加霜吗?
过度绘制是怎么产生的?
我举个例子你就明白了。假设你有一个Activity,背景是白色的。然后你放了一个LinearLayout,背景也是白色的。再往里面放一个TextView,背景还是白色的。
这三个层叠在一起,GPU会怎么处理?它会先画Activity的背景,再画LinearLayout的背景,最后画TextView的背景。但实际上,用户看到的只是最上面那层白色。下面两层全白画了。
这就是典型的过度绘制。我在项目中遇到过最夸张的情况,一个简单的列表项,过度绘制达到了5层。那台测试机是红米2A,滑动起来卡得跟幻灯片似的。
核心原则:能不画的背景就别画。能合并的层级就合并。能裁剪的区域就裁剪。
怎么检测过度绘制?
Android自带了过度绘制检测工具,就在开发者选项里。打开「调试GPU过度绘制」,屏幕就会变成彩色地图。
颜色代表什么意思?
- 蓝色:1次过度绘制,正常
- 绿色:2次,可以接受
- 淡红:3次,需要优化
- 深红:4次以上,必须优化
我个人的习惯是,打开这个开关后,把App的所有页面都跑一遍。看到深红色区域,就截图记录下来。然后一个一个去排查。
小技巧:在开发者选项里,还可以开启「显示布局边界」。这样每个View的轮廓都会显示出来。配合过度绘制一起看,效果更好。
使用Hierarchy Viewer分析布局层级
Hierarchy Viewer,现在叫Layout Inspector了。这工具能让你看到整个View树的层级结构。说白了,就是把你写的XML布局,变成一棵可视化的树。
为什么要看这个?因为布局嵌套越深,性能越差。我见过最离谱的布局,一个简单的登录页面,嵌套了12层。你想想看,每次测量、布局、绘制,都要递归遍历12层。低端机不卡才怪。
怎么用Layout Inspector?
在Android Studio里,Tools -> Layout Inspector。选上你的进程,就能看到当前页面的View树了。
重点关注什么?
- 树的深度:超过10层就要警惕了
- 叶子节点数量:太多说明布局碎片化
- 每个节点的Measure/ Layout/ Draw耗时:红色表示耗时过长
嗯,这里要注意。Layout Inspector只能在debug模式下用。而且它会拖慢App的运行速度。所以别在生产环境用,不然用户会骂娘的。
避坑指南:我曾经在线上环境误开了Layout Inspector,结果App启动慢了3秒。后来我就在代码里加了个判断,只有debug模式才允许连接。
减少布局嵌套的实战技巧
好了,前面讲了原理和工具。现在来点干货,怎么减少布局嵌套。
技巧一:用ConstraintLayout替代多层嵌套
ConstraintLayout是我最常用的布局容器。它可以用扁平的结构,实现复杂的布局关系。
举个例子,以前要实现「图片在左边,文字在右边,文字下面有个小标签」,你可能需要这样写:
<!-- 旧方案:3层嵌套 -->
<LinearLayout>
<ImageView />
<LinearLayout orientation="vertical">
<TextView />
<TextView />
</LinearLayout>
</LinearLayout>
用ConstraintLayout,一层就够了:
<!-- 新方案:1层搞定 -->
<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="parent"/>
<TextView
app:layout_constraintStart_toEndOf="@id/image"
app:layout_constraintTop_toBottomOf="@id/title"/>
</androidx.constraintlayout.widget.ConstraintLayout>
你看,从3层变成了1层。GPU少遍历两次,性能自然就上来了。
技巧二:使用merge标签
merge标签是个好东西。它能把子布局直接合并到父布局里,减少一层嵌套。
我习惯在自定义ViewGroup里用merge。比如你写了一个通用的卡片布局:
<!-- card_layout.xml -->
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<ImageView .../>
<TextView .../>
</merge>
然后在主布局里include它:
<LinearLayout>
<include layout="@layout/card_layout"/>
</LinearLayout>
这样,card_layout里的内容就直接放到了LinearLayout里,没有多余的层级。
注意:merge只能作为根标签。而且它不能设置layout_width和layout_height。这些属性会由父布局决定。
技巧三:避免不必要的背景
这个我前面提过。每个View的背景,都会增加一次绘制。能去掉就去掉。
具体怎么做?
- Activity的主题里设置了背景,那布局里就别再设了
- RecyclerView的item,如果背景和父容器一样,就别设了
- 用selector做点击效果时,尽量用drawable代替背景
我记得有一次,一个同事写的列表页,每个item都设了白色背景。而整个Activity的背景也是白色。结果就是每个item都多画了一层白色。去掉之后,列表滑动流畅了不少。
技巧四:使用ViewStub延迟加载
有些布局,不是一开始就需要显示的。比如加载失败的提示、新手引导、广告位等。这些可以用ViewStub来延迟加载。
ViewStub本身很轻量,不占布局位置。只有当你调用inflate()时,它才会真正创建子View。
<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 instanceof ViewStub) {
errorView = ((ViewStub) errorView).inflate();
}
这样做的好处是,初始布局的层级少了,绘制也快了。等真正需要时再加载,用户感知不到延迟。
技巧五:自定义View减少层级
有些复杂的UI,用系统控件组合,嵌套会很多。这时候可以考虑自定义View,直接在onDraw里画。
比如一个带圆角和阴影的卡片,用CardView + 阴影层,可能要3层。但自己画,一层就够了。
// 自定义View,直接绘制圆角和阴影
@Override
protected void onDraw(Canvas canvas) {
// 画阴影
canvas.drawRoundRect(shadowRect, radius, radius, shadowPaint);
// 画内容
canvas.drawRoundRect(contentRect, radius, radius, contentPaint);
}
当然,自定义View的代价是开发成本高。我一般只在性能瓶颈的地方用。比如列表的item,或者频繁刷新的区域。
总结一下:布局优化的核心,就是减少GPU的工作量。少画、少嵌套、少加载。低端机资源有限,每一帧都要精打细算。
好了,这一章的内容就到这里。下一章我会讲内存优化的实战技巧,包括Bitmap的加载和缓存策略。到时候见。