一、绘制顺序的本质:从dispatchDraw说起
绘制顺序,说白了就是「谁先画、谁后画」的问题。你想想看,Android的View树是一棵层级结构,父View先画,子View后画——但子View之间呢?它们谁先谁后?
这里的关键方法就是 dispatchDraw()。我刚开始研究View绘制时,一直以为 onDraw() 才是核心,后来才发现 dispatchDraw() 才是控制子View绘制顺序的「幕后黑手」。
1.1 dispatchDraw的递归调用机制
当一个ViewGroup开始绘制时,流程是这样的:
draw()被调用- 先画自己的背景(
drawBackground()) - 再画自己内容(
onDraw()) - 然后调用
dispatchDraw()去画子View - 最后画前景(
onDrawForeground())
嗯,这里要注意:dispatchDraw() 内部会遍历所有子View,对每个子View调用 drawChild(),而 drawChild() 又会触发子View自己的 draw() 方法。这就是递归调用的本质。
核心结论:绘制顺序 = 父View先画自己 → 按顺序画子View → 子View再递归画自己的子View
1.2 子View的默认绘制顺序
默认情况下,子View是按照它们在 getChildren() 数组中的顺序绘制的。这个顺序由 addView() 的调用顺序决定——先添加的先画,后添加的后画。
我遇到过一个问题:两个子View重叠时,后添加的会盖在先添加的上面。当时产品经理说「这个弹窗怎么被按钮挡住了?」其实就是绘制顺序的问题。
// ViewGroup.java 中的 dispatchDraw 核心逻辑(简化版)
protected void dispatchDraw(Canvas canvas) {
final int childrenCount = mChildrenCount;
final View[] children = mChildren;
for (int i = 0; i < childrenCount; i++) {
// 按索引顺序绘制
drawChild(canvas, children[i], drawingTime);
}
}
你看,就是这样一个简单的for循环。但实际项目中,这个顺序会被各种因素影响。
二、Z轴层级:elevation与translationZ
Android 5.0(API 21)引入了 elevation 属性,让View可以在Z轴上有「高度」概念。说白了,就是给View加阴影,并且让高View盖在低View上面。
2.1 elevation的工作原理
elevation 的值会影响两件事:
- 阴影大小:elevation越大,阴影越明显
- 绘制顺序:elevation高的View,会在elevation低的View之后绘制
为什么会这样?因为Android在 dispatchDraw() 里会对子View进行排序。具体来说,ViewGroup 会调用 sortChildrenByZ() 方法,按照Z轴值从小到大排序。
个人经验:我在做一个卡片堆叠效果时,发现elevation相同的View,绘制顺序还是按添加顺序。所以如果你想让某个View「浮」起来,记得给它一个更大的elevation值。
2.2 translationZ:动态调整Z轴
translationZ 是 elevation 的补充。它不会改变View的「基准高度」,但可以在动画中临时改变Z轴位置。
// 代码示例:点击时让按钮浮起
button.setOnTouchListener((v, event) -> {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
v.setTranslationZ(8f); // 浮起
} else if (event.getAction() == MotionEvent.ACTION_UP) {
v.setTranslationZ(0f); // 恢复
}
return false;
});
我记得有一次做水波纹效果,用 translationZ 配合属性动画,实现了很自然的「按下弹起」交互。比单纯改背景色要高级得多。
2.3 Z轴对绘制顺序的影响
当ViewGroup中有多个子View时,绘制顺序的最终规则是:
- 先按
elevation从小到大排序 - 如果
elevation相同,再按添加顺序 translationZ会临时叠加到elevation上参与排序
| View | elevation | translationZ | 实际Z值 | 绘制顺序 |
|---|---|---|---|---|
| View A | 2dp | 0dp | 2dp | 第1个绘制(最底层) |
| View B | 4dp | 0dp | 4dp | 第2个绘制 |
| View C | 2dp | 4dp | 6dp | 第3个绘制(最顶层) |
避坑指南:我曾经在RecyclerView的Item中使用elevation,结果发现Item滑动时阴影闪烁。原因是RecyclerView的复用机制导致elevation重新计算。后来我改用 setOutlineProvider() 自定义阴影轮廓,才解决了问题。
三、绘制顺序的实战影响
3.1 重叠区域的显示规则
两个View重叠时,后绘制的View会覆盖先绘制的View。这个规则在以下场景特别重要:
- 弹窗与按钮:弹窗必须在按钮之后绘制,否则会被按钮挡住
- 悬浮按钮:FAB通常需要高elevation,确保它浮在内容之上
- 卡片堆叠:通过调整elevation实现层叠效果
3.2 如何手动控制绘制顺序
如果你需要完全控制子View的绘制顺序,可以重写 getChildDrawingOrder() 方法:
// 自定义绘制顺序:让第3个子View最后绘制(最上层)
@Override
protected int getChildDrawingOrder(int childCount, int i) {
if (i == childCount - 1) {
return 2; // 索引2的子View最后绘制
}
if (i == 2) {
return childCount - 1;
}
return i;
}
嗯,这个方法我用的不多,但在做自定义布局时确实救过急。比如做一个「轮播图指示器」,需要让选中的圆点始终在最上面。
四、知识体系总览
下面这张图总结了本章的核心知识点:
五、总结与避坑
绘制顺序这个知识点,平时可能不太注意,但一旦遇到UI重叠问题,它就是关键。我总结几个要点:
- 默认顺序:按添加顺序,先添加的先画
- Z轴影响:elevation高的后画,会覆盖低的
- 动态调整:translationZ可以临时改变Z轴,适合动画
- 手动控制:重写getChildDrawingOrder()可以完全自定义
一句话记住:绘制顺序 = 先画背景 → 画自己 → 按Z轴排序画子View → 画前景。Z轴相同时,先添加的先画。
我曾经在一个项目中,因为没注意elevation的默认值(0dp),导致两个Fragment切换时出现奇怪的覆盖现象。排查了半天才发现是其中一个Fragment的根布局设置了elevation="2dp",而另一个没有设置。嗯,这种细节问题,往往是最容易踩坑的。