一、绘制顺序的本质:从dispatchDraw说起

绘制顺序,说白了就是「谁先画、谁后画」的问题。你想想看,Android的View树是一棵层级结构,父View先画,子View后画——但子View之间呢?它们谁先谁后?

这里的关键方法就是 dispatchDraw()。我刚开始研究View绘制时,一直以为 onDraw() 才是核心,后来才发现 dispatchDraw() 才是控制子View绘制顺序的「幕后黑手」。

1.1 dispatchDraw的递归调用机制

当一个ViewGroup开始绘制时,流程是这样的:

  1. draw() 被调用
  2. 先画自己的背景(drawBackground()
  3. 再画自己内容(onDraw()
  4. 然后调用 dispatchDraw() 去画子View
  5. 最后画前景(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轴

translationZelevation 的补充。它不会改变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时,绘制顺序的最终规则是:

  1. 先按 elevation 从小到大排序
  2. 如果 elevation 相同,再按添加顺序
  3. 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;
}

嗯,这个方法我用的不多,但在做自定义布局时确实救过急。比如做一个「轮播图指示器」,需要让选中的圆点始终在最上面。

四、知识体系总览

下面这张图总结了本章的核心知识点:

绘制顺序与层级知识体系 dispatchDraw() 递归调用:父View → 子View → 子子View 默认顺序:addView() 添加顺序 → 先添加先绘制 Z轴排序:elevation + translationZ → 高Z值后绘制 getChildDrawingOrder() 自定义顺序 绘制流程 默认规则 Z轴层级 进阶控制

五、总结与避坑

绘制顺序这个知识点,平时可能不太注意,但一旦遇到UI重叠问题,它就是关键。我总结几个要点:

  • 默认顺序:按添加顺序,先添加的先画
  • Z轴影响:elevation高的后画,会覆盖低的
  • 动态调整:translationZ可以临时改变Z轴,适合动画
  • 手动控制:重写getChildDrawingOrder()可以完全自定义

一句话记住:绘制顺序 = 先画背景 → 画自己 → 按Z轴排序画子View → 画前景。Z轴相同时,先添加的先画。

我曾经在一个项目中,因为没注意elevation的默认值(0dp),导致两个Fragment切换时出现奇怪的覆盖现象。排查了半天才发现是其中一个Fragment的根布局设置了elevation="2dp",而另一个没有设置。嗯,这种细节问题,往往是最容易踩坑的。

公众号:蓝海资料掘金营,微信deep3321