自定义 View 的 “快递失踪案”:为啥 invalidate () 喊不动 onDraw ()?

讲了个 "快递站送货" 的故事 ------ 毕竟 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(); // 白打!

解决方案 :确保visibilityView.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发消息到主线程;
  • ActivityrunOnUiThread(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 设置了硬件加速 LayersetLayerType(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))。最后我给他总结了一套 "排查口诀",小白也能套用:

  1. 查基础状态 :View 是不是VISIBLE?宽高是不是大于 0?(对应原因 1、2)
  2. 查绘制开关 :如果是 ViewGroup,有没有开setWillNotDraw(false)?(对应原因 3)
  3. 查线程归属invalidate()是不是在 UI 线程调用的?(对应原因 4)
  4. 查拦截和缓存:父 View 有没有拦截请求?View 是不是开了硬件加速 Layer?(对应原因 5、6)

按这四步走,90% 的 "invalidate () 不回调 onDraw ()" 问题都能解决。记住:View 的绘制流程就像快递,每个环节都不能少,卡住一个就 "送货失败"~

相关推荐
没有了遇见8 小时前
Android 稀奇古怪系列:新版本签名问题-Algorithm HmacPBESHA256 not available
android
小妖怪的夏天8 小时前
react native android设置邮箱,进行邮件发送
android·spring boot·react native
东风西巷8 小时前
Avast Cleanup安卓版(手机清理优化) 修改版
android·学习·智能手机·软件需求
用户2018792831678 小时前
Android断点续传原理:小明的"读书笔记"故事
android
用户2018792831679 小时前
ART 内存模型:用 “手机 APP 小镇” 讲明白底层原理
android
liulangrenaaa9 小时前
Android NDK 命令规范
android
用户2018792831679 小时前
Android中的StackOverflowError与OOM:一场内存王国的冒险
android
用户20187928316710 小时前
类的回收大冒险:一场Android王国的"断舍离"故事
android
用户20187928316710 小时前
Android Class 回收原理及代码演示
android