自定义 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 的绘制流程就像快递,每个环节都不能少,卡住一个就 "送货失败"~

相关推荐
阿巴斯甜11 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker12 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq952713 小时前
Andorid Google 登录接入文档
android
黄林晴14 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab1 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿1 天前
Android MediaPlayer 笔记
android
Jony_1 天前
Android 启动优化方案
android
阿巴斯甜1 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇1 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_1 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android