讲了个 "快递站送货" 的故事 ------ 毕竟 View 的绘制流程,本质就是一场 "指令上报→调度→执行" 的快递游戏。
一、先搞懂:正常情况下,"快递" 是怎么送到的?
我们先把 View 体系比作一个城市快递网络:
- 你写的
自定义View
= 小区里的 "快递站"(负责接收指令、安排送货); invalidate()
= 你给快递站打 "要送货" 的电话(请求重绘);onDraw()
= 快递站的 "送货员"(实际执行绘制逻辑);ViewGroup
(父容器)= "区域调度中心"(转发快递站的请求);ViewRootImpl
= 快递总公司(连接快递站和 "城市交通系统"------Android 的 UI 线程);Choreographer
= 总公司的 "帧调度室"(负责安排每帧的工作,避免堵车)。
正常送货的时序图(代码 + 流程)
先看一段 "正常能收到货" 的自定义 View 代码:
java
// 小区快递站(自定义View)
public class NormalCustomView extends View {
private Paint mPaint;
public NormalCustomView(Context context) {
super(context);
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setTextSize(50);
}
// 送货员(执行绘制)
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员onDraw出发!画个文字");
canvas.drawText("快递送到啦~", 100, 100, mPaint);
}
}
// 你(开发者)打电话下单
NormalCustomView view = new NormalCustomView(this);
view.invalidate(); // 打"要送货"的电话
这通电话后,"快递" 会按以下流程送到(时序图用文字拆解):

你送货员送货员(onDraw)帧调度室帧调度室(Choreographer)快递总公司快递总公司(ViewRootImpl)区域调度中心区域调度中心(ViewGroup)快递站快递站(CustomView)你(开发者)你送货员送货员(onDraw)帧调度室帧调度室(Choreographer)快递总公司快递总公司(ViewRootImpl)区域调度中心区域调度中心(ViewGroup)快递站快递站(CustomView)你(开发者)打call:invalidate()1. 检查自身状态(门开了吗?有货要送吗?)2. 上报:"我要送货,帮我转总公司!"3. 层层转发:"总公司,有个快递站要送货!"4. 申请排期:"下一帧给这个快递站留个位置!"5. 下一帧到了:"可以开始送货流程了!"6. 下达指令:"执行draw(),让送货员出发!"7. 派单:"onDraw,去把货(绘制)送了!"8. 完成:log打印"送货员onDraw出发!"
二、"快递失踪" 的 6 种常见原因(故事 + 代码 + 解决方案)
小明的问题,本质是 "快递在某个环节卡住了"。我们一个个拆穿这些 "卡壳点"------ 每个原因都对应故事里的场景,再给代码验证。
原因 1:快递站 "没开门"(View 不可见)
故事场景:小明早上给快递站打电话,站长接了说:"兄弟,我们还没开门(visibility=GONE),货送不了,挂了啊!"
原理 :View 在收到invalidate()
后,会先检查visibility
属性:
- 只有
visibility == View.VISIBLE
时,才会继续上报请求; - 如果是
GONE
(完全隐藏,不占空间)或INVISIBLE
(隐藏但占空间),直接 "挂电话",不触发后续流程。
代码验证(坑) :
java
public class ClosedStationView extends View {
public ClosedStationView(Context context) {
super(context);
// 坑:设置为GONE,快递站没开门
setVisibility(View.GONE);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!"); // 永远不会打印
}
}
// 你打电话,但快递站没开门
ClosedStationView view = new ClosedStationView(this);
view.invalidate(); // 白打!
解决方案 :确保visibility
是View.VISIBLE
(代码里setVisibility(View.VISIBLE)
,或 XML 里android:visibility="visible"
)。
原因 2:快递站 "没地方放货"(宽高为 0)
故事场景:小明这次确认快递站开了门,但站长说:"我们仓库是 0 平米(宽高 = 0),货没地方放,送不了!"
原理 :View 绘制需要 "有空间"------getMeasuredWidth()
和getMeasuredHeight()
必须都大于 0。如果宽高为 0,即使invalidate()
,也会跳过后续流程(总不能在 "空气" 里画画吧)。
代码验证(坑) :
xml
<!-- XML里坑:宽高设为0 -->
<com.example.MyView
android:layout_width="0dp"
android:layout_height="0dp" />
java
public class ZeroSizeView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!"); // 不打印,因为宽高0
}
}
解决方案:
- 检查 XML 的
layout_width
/layout_height
(别设 0dp); - 代码里避免
setLayoutParams(new LayoutParams(0, 0))
; - 重写
onMeasure()
时,确保setMeasuredDimension(width, height)
的宽高大于 0。
原因 3:快递站 "只中转不送货"(ViewGroup 默认不绘制)
故事场景:小明找的是 "区域调度中心"(ViewGroup)当快递站,结果调度中心说:"我们只负责转发子快递站的货,自己不送货(willNotDraw=true)!"
原理 :ViewGroup
的默认值willNotDraw = true
,意思是 "我是容器,只管子 View 的布局,自己不用绘制"。所以即使你给ViewGroup
调用invalidate()
,它也会跳过onDraw()
。
代码验证(坑) :
java
// 区域调度中心(ViewGroup),默认不送货
public class NoDrawViewGroup extends ViewGroup {
public NoDrawViewGroup(Context context) {
super(context);
// 坑:没改willNotDraw,默认true
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 布局子View(省略)
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("调度中心", "自己送货!"); // 不打印
}
}
// 你给调度中心打电话
NoDrawViewGroup group = new NoDrawViewGroup(this);
group.invalidate(); // 白打!
解决方案 :在ViewGroup
的构造里加一句setWillNotDraw(false)
,告诉它 "我也要自己送货(绘制)":
java
public NoDrawViewGroup(Context context) {
super(context);
setWillNotDraw(false); // 打开"自己绘制"开关
}
原因 4:你 "打错电话"(非 UI 线程调用 invalidate ())
故事场景:小明在外地出差,用 "公用电话"(非 UI 线程)给快递站打电话,结果电话直接被总公司拦截:"非本人手机(UI 线程),不接!"
原理 :Android 的 View 体系是线程不安全 的,只有创建 View 的 "UI 线程(主线程)" 才能调用invalidate()
。非 UI 线程调用会:
- 要么直接抛异常(
Only the original thread that created a view hierarchy can touch its views
); - 要么 "悄悄失败"(没抛异常但不触发
onDraw()
)。
代码验证(坑) :
java
public class WrongThreadView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!"); // 不打印
}
}
// 你用"公用电话"(非UI线程)打电话
WrongThreadView view = new WrongThreadView(this);
new Thread(() -> {
view.invalidate(); // 非UI线程!要么抛异常,要么白打
}).start();
解决方案 :确保在 UI 线程调用invalidate()
,常用方式:
- 用
view.post(Runnable)
:view.post(() -> view.invalidate())
; - 用
Handler
发消息到主线程; - 在
Activity
的runOnUiThread(Runnable)
里调用。
原因 5:区域调度中心 "拦截了请求"(父 View 阻断上报)
故事场景:小明的快递站属于 "郊区调度中心",调度中心跟总公司关系不好,收到快递站的请求后,直接扔了:"不给你转总公司,爱咋咋地!"
原理 :View 的invalidate()
需要通过ViewParent
(父 View)层层上报到ViewRootImpl
。如果父 View 重写了invalidateChildInParent()
(上报方法)并返回null
,就会 "拦截" 请求,导致后续流程中断。
代码验证(坑) :
java
// 坑爹的区域调度中心(父View),拦截请求
public class BlockParentViewGroup extends ViewGroup {
public BlockParentViewGroup(Context context) {
super(context);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 布局子View(省略)
}
// 重写上报方法,返回null=拦截请求
@Override
public ViewParent invalidateChildInParent(int[] location, Rect dirty) {
Log.d("坑爹调度中心", "拦截请求,不转总公司!");
return null; // 关键:返回null阻断上报
}
}
// 子快递站(被拦截)
public class ChildView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("子快递站", "送货员出发!"); // 不打印
}
}
// 布局关系:BlockParentViewGroup包含ChildView
BlockParentViewGroup parent = new BlockParentViewGroup(this);
ChildView child = new ChildView(this);
parent.addView(child);
child.invalidate(); // 子View的请求被父View拦截
解决方案:
- 检查父 View 是否重写了
invalidateChildInParent()
,避免返回null
; - 若父 View 有
clipChildren="true"
(XML 属性),且子 View 超出父 View 范围,超出部分的invalidate()
也会被拦截,可设clipChildren="false"
。
原因 6:快递站 "用了缓存,不用重送"(硬件加速 Layer 缓存)
故事场景:快递站之前送过一次货,把货存在了 "临时仓库"(硬件加速 Layer)里。这次小明再打电话,站长说:"仓库里有现成的,直接拿,不用再让送货员跑一趟!"
原理 :当 View 设置了硬件加速 Layer (setLayerType(LAYER_TYPE_HARDWARE, null)
),系统会把 View 的绘制结果缓存成一个 "图片(Layer)"。后续调用invalidate()
时:
- 如果只是轻微修改(比如文字颜色不变,只改内容),系统直接复用 Layer,不调用
onDraw()
; - 只有 Layer 失效(比如 View 大小改变、Layer 类型切换),才会重新调用
onDraw()
生成新 Layer。
代码验证(坑) :
java
public class LayerCacheView extends View {
private Paint mPaint;
private String mText = "第一次送货";
public LayerCacheView(Context context) {
super(context);
mPaint = new Paint();
mPaint.setColor(Color.BLUE);
mPaint.setTextSize(50);
// 坑:设置硬件加速Layer,开启缓存
setLayerType(LAYER_TYPE_HARDWARE, null);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!当前文字:" + mText); // 只打印一次
canvas.drawText(mText, 100, 100, mPaint);
}
// 你修改文字后打电话
public void updateText() {
mText = "第二次送货";
invalidate(); // 调用后,onDraw不回调(复用Layer缓存)
}
}
// 调用流程
LayerCacheView view = new LayerCacheView(this);
view.invalidate(); // 第一次:onDraw回调(生成Layer)
view.updateText(); // 第二次:invalidate()但onDraw不回调(复用Layer)
解决方案:
- 若需要每次
invalidate()
都回调onDraw()
,可关闭 Layer:setLayerType(LAYER_TYPE_NONE, null)
; - 若必须用 Layer,可手动让 Layer 失效:
invalidate()
后加setLayerType(LAYER_TYPE_HARDWARE, null)
(强制重建 Layer)。
三、总结:"快递失踪" 排查四步法
小明听完故事,半小时就解决了他的问题(原来是忘了给 ViewGroup 加setWillNotDraw(false)
)。最后我给他总结了一套 "排查口诀",小白也能套用:
- 查基础状态 :View 是不是
VISIBLE
?宽高是不是大于 0?(对应原因 1、2) - 查绘制开关 :如果是 ViewGroup,有没有开
setWillNotDraw(false)
?(对应原因 3) - 查线程归属 :
invalidate()
是不是在 UI 线程调用的?(对应原因 4) - 查拦截和缓存:父 View 有没有拦截请求?View 是不是开了硬件加速 Layer?(对应原因 5、6)
按这四步走,90% 的 "invalidate () 不回调 onDraw ()" 问题都能解决。记住:View 的绘制流程就像快递,每个环节都不能少,卡住一个就 "送货失败"~