- 文档
- 教程
- 知识库
【免费下载链接】android-tech-frontier
【停止维护】一个定期翻译国外Android优质的技术、开源库、软件架构设计、测试等文章的开源项目
本篇技术指南以 issue-24/Android LayerDrawable 和 Drawable.Callback.md 译文为骨架,结合 Android 框架的Drawable.Callback机制与仓库内其他 Drawable 实践,系统讲解LayerDrawable的多层结构、重绘回调的传递链路,以及"先设置 View 背景、再组装 LayerDrawable"这一错误顺序引发的经典动画失效 Bug。读完本文,你将能理解 Drawable 的 callback 归属关系,能独立排查"Drawable 更新了但界面不刷新"这类问题,并掌握正确的组装顺序与工程规避手段。
一、先看现象:一个"卡住"的 ActionBar 背景动画
原文作者要展示的 Bug 场景非常具体:在一个使用 ActionBar 的界面中,开发者打算用"绿色纯色层 + 一张重复图案图标层"合成一个LayerDrawable作为 ActionBar 的背景,并用ValueAnimator不断修改图标层的透明度来实现淡入淡出动画。结果发现:动画数值在跑,但 ActionBar 背景纹丝不动。
如果你不了解LayerDrawable的机制,这类问题很难定位——因为报错、崩溃、异常日志统统没有,只是"界面不刷新"。所有线索都藏在Drawable与其宿主(View 或外层容器 Drawable)之间的Drawable.Callback联系里。
二、LayerDrawable 是什么:多层 Drawable 的容器
LayerDrawable是一个特殊的Drawable,它内部持有一个Drawable数组,数组中每一个Drawable都是视图中的一层。绘制时各层按数组索引顺序叠加绘制,索引越大越靠上层。
在 XML 中,它通常写作layer-list:
<?xml version="1.0" encoding="utf-8"?> <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <!-- 底层:纯色 --> <item android:drawable="@color/colorPrimary" /> <!-- 上层:图标,可通过 left/top/right/bottom 或 width/height 控制位置与尺寸 --> <item android:drawable="@drawable/ic_launcher" android:top="16dp" android:left="16dp" /> </layer-list>在代码中,则通过构造函数传入Drawable[]数组创建(这正是原文 Bug 示例采用的方式)。LayerDrawable的常用能力还包括getNumberOfLayers()、setLayerInset(index, l, t, r, b)、setDrawableByLayerId(id, drawable)等,可动态调整每一层的缩进或替换某层内容。
值得补充的一点是:LayerDrawable这类"容器型 Drawable"在兼容包中还有特殊价值。仓库 issue-44/Android-Support-Library-23.2.md 中提到,在 Android Lollipop 之前的设备上直接引用矢量图会失败,但矢量图被StateListDrawable、InsetDrawable、LayerDrawable、LevelListDrawable、RotateDrawable等 drawable 容器间接引用时,兼容包可以正常加载——也就是说LayerDrawable也是绕过旧版本矢量图兼容限制的常见手法之一。
三、Drawable.Callback 与 invalidateSelf():重绘请求从哪发起
Drawable.Callback是 Drawable 与其宿主之间的"重绘契约"。接口定义了三个方法:
invalidateDrawable(Drawable who):宿主收到子 Drawable 的重绘请求;scheduleDrawable(Drawable who, Runnable what, long when):宿主安排一个延迟执行的动作(动画帧调度会用到);unscheduleDrawable(Drawable who, Runnable what):取消已安排的动作。
Drawable自身在状态变化(如setAlpha()、setLevel()、setColorFilter())需要重绘时,会调用invalidateSelf()。原文给出了这段核心实现:
public void invalidateSelf() { /* 获取注册的Callback实例,如果无则返回null。 */ final Callback callback = getCallback(); if (callback != null) { callback.invalidateDrawable(this); } }要点:invalidateSelf()只做一件事——把"我需要重绘"的消息转交给注册的Callback。如果getCallback()返回null,那么这次重绘请求就静默丢失,Drawable 内部数据变了,界面却永远不会刷新。这正是本文要讲的 Bug 的根源。
四、Callback 调用链:从内层 Drawable 一路传到 View
我们知道View实现了Drawable.Callback接口,因此一个被设置为 View 背景的 Drawable,其 callback 通常指向该 View;当 View 的重绘被安排进下一帧时,ViewRootImpl会执行 measure/layout/draw 流程完成实际刷新。
如果背景是一个LayerDrawable,情况会变成一条层层传递的调用链。原文明确指出:在LayerDrawable中,每一层 Drawable 都会把LayerDrawable注册为自己的Drawable.Callback,从而允许内层 Drawable 在需要重绘时通知LayerDrawable。于是当 View 背景是LayerDrawable时:
内层 Drawable(如图标层)调用 setAlpha()/setLevel() 等 → invalidateSelf() │ ▼ getCallback() → LayerDrawable L │ L.invalidateDrawable(内层 Drawable) ▼ L 更新自身状态并调用 invalidateSelf() → 通知自己的 Callback │ ▼ getCallback() → View V │ V.invalidateDrawable(L) ▼ View 被标记为需要重绘 → ViewRootImpl 安排下一帧 measure/layout/draw链条可以理解为:内层 → LayerDrawable → View → ViewRootImpl。链条上任何一环的 callback 被破坏,重绘信号就会在这一环断掉。
五、setBackgroundDrawable():更换背景时"无条件"清空旧 callback
理解了调用链,再来看 View 一侧的源码。在View.setBackgroundDrawable(Drawable background)中有这么一段:
if (mBackground != null) { mBackground.setCallback(null); unscheduleDrawable(mBackground); } … if (background != null) { background.setCallback(this); }结论非常明确:当 View 改变背景时,会无条件将原背景(如果原背景是 Drawable 的话)的Drawable.Callback设置为null,同时unscheduleDrawable(mBackground)会取消旧背景上尚未执行的所有动画调度。
这里有一个需要留意的历史细节:setBackgroundDrawable(Drawable)是早期的 API,后来被setBackground(Drawable)取代(后者在较新的框架版本内部同样是走相同的背景替换逻辑),但从"更换背景会切断旧背景 callback"这一行为上讲,两者一致。这是框架层面的硬性行为,不是某个版本特有的怪癖。
六、经典 Bug 复现:四步让 ActionBar 背景动画失效
把前面三节的知识串起来,原文给出了一个可以精确复现 Bug 的步骤:
- 把
DrawableA设置成ViewV的背景。此时A 的 callback 指向 V; - 将A作为一层放进
LayerDrawableL。此时A 的 callback 指向 L; - 为V设置另一个背景(也就是把L设上去)。在这一步,V 会把原背景(此时仍是 A)的 callback 强制置为 null,破坏了 A 与 L 之间的联系;
- Bug 出现:再更新
DrawableA(如修改透明度、level),L 不会收到任何通知,界面不刷新。
关键在第 3 步:虽然此刻 View 的新背景已经是LayerDrawableL,但 View 执行清空动作针对的是"当前记录的旧背景 mBackground"——也就是刚才被设上去的 A。于是 A 与 L 之间刚建立起来的 callback 联系,转瞬之间就被 View 的"换背景清理"动作打断了。
原文配套的 ActionBar 示例代码如下(两个按钮分别触发"正常"与"失效"两条路径):
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); Button btn1 = (Button) findViewById(R.id.button1); btn1.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { // 1. 将 launcherIconDrawable.callback 赋值给 actionBar actionBar.setBackgroundDrawable(launcherIconDrawable); animateActionBarWorking(); } }); Button btn2 = (Button) findViewById(R.id.button2); btn2.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { // 1. 将 launcherIconDrawable.callback 赋值给 actionBar actionBar.setBackgroundDrawable(launcherIconDrawable); animateActionBarNotWorking(); } }); actionBar = getSupportActionBar(); launcherIconDrawable = getResources().getDrawable(R.drawable.launcher_repeat); colorLayer = new ColorDrawable(Color.rgb(0, 255, 0)); actionBar.setBackgroundDrawable(colorLayer); } /* 这个函数运行后 ActionBar 不会得到更新。 */ private void animateActionBarNotWorking() { Drawable[] layers = new Drawable[] { colorLayer, launcherIconDrawable }; LayerDrawable layerDrawable = new LayerDrawable(layers); actionBar.setBackgroundDrawable(layerDrawable); ValueAnimator valueAnimator = ValueAnimator.ofInt(0, 255); valueAnimator.setDuration(1000); valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { // 4. 更新 launcherIconDrawable 不会触发 actionBar 背景的更新, // 因为此时 launcherIconDrawable.callback 已经是 null launcherIconDrawable.setAlpha((Integer) animation.getAnimatedValue()); } }); valueAnimator.start(); } /* 由于先移除了 launcherIconDrawable 与 ActionBar 的联系, 这个函数运行后会让 ActionBar 得到更新。 */ private void animateActionBarWorking() { actionBar.setBackgroundDrawable(null); animateActionBarNotWorking(); }逐步推演两条路径:
- NotWorking 路径(btn2):先把
launcherIconDrawable设为背景(A.callback = V)→ 构造LayerDrawable(A.callback = L)→actionBar.setBackgroundDrawable(layerDrawable)触发"清空旧背景 callback",旧背景正是 A,于是A.callback 变为 null。动画每帧setAlpha()都调用invalidateSelf(),但getCallback()为 null,重绘请求丢失,界面冻结。 - Working 路径(btn1):
animateActionBarWorking()先执行actionBar.setBackgroundDrawable(null),主动把 A 与 V 的联系切断;随后再走animateActionBarNotWorking()的流程——此时"旧背景"已经清空,setBackgroundDrawable(layerDrawable)不会再误伤 A;新背景 L 设置成功后,L 会重新把自己注册为各层(含 A)的 callback,链条 A → L → V 完好,动画正常生效。
原文作者还给出了一个可以自行下载运行的完整示例工程(位于原作者的开源仓库 blog-android-source-code 的 LayerDrawableCallback 目录),上述代码即为该示例的核心部分。
七、修复方案与工程实践建议
1. 牢记正确顺序
先在 View 上设置好(或清空)背景,再构造 / 组装LayerDrawable。用一句话总结:LayerDrawable的组装时机必须排在"把新背景赋给 View"之后,避免 View 的换背景清理动作切断内部子层的 callback。
2. 用 getCallback() 做现场诊断
排查"Drawable 更新了但界面不刷新"问题时,可以在更新前打印一下目标 Drawable 的 callback:
Log.d("CallbackDebug", "drawable.callback = " + launcherIconDrawable.getCallback()); // 期望输出:某个 LayerDrawable(且该 LayerDrawable 的 callback 又是对应的 View) // 若输出 null,则说明链条已断,重绘请求无法送达3. 不跨宿主复用 Drawable
一个 Drawable 同一时间只能有一个 callback。把一个已经"挂"在 View 上的 Drawable 再塞进另一个容器,或反过来把容器内的 Drawable 直接设为 View 背景,都会造成 callback 归属混乱。要么先解除旧关系(置 null),要么干脆不复用。
4. 优先用 XML 声明,减少代码顺序负担
像"纯色底 + 图标层"这类固定叠加效果,直接写在res/drawable/xxx.xml的layer-list中,由框架统一解析、统一设置 callback,天然避免"先设背景再组 LayerDrawable"这种错误顺序。只有当各层需要运行时动态创建、频繁替换时才走代码构造,此时务必遵守第 1 点的顺序约束。
5. 动画调度同样会被清理
注意setBackgroundDrawable里还有一行unscheduleDrawable(mBackground):换背景不仅清 callback,还会取消旧背景上排队中的scheduleDrawable调度(如帧动画、延迟动作)。因此涉及动画的背景替换,同样要在替换后重新启动调度,不能假设动画会自动继续。
八、延伸阅读:本仓库中相关的 Drawable 实践
LayerDrawable只是 Android Drawable 体系的一环,仓库中还有多篇与之互补的译文值得对照阅读:
- issue-26/Tinting drawables.md:自定义
BitmapDrawable子类结合LightingColorFilter实现按主题着色,并介绍了用StateListDrawable复用同一张图做多状态变色,与"Drawable 容器"思想一脉相承; - issue-17/Android中的帧动画.md:
AnimationDrawable与Animation-list的使用,展示另一种通过Drawable驱动 UI 动画的方式(其scheduleDrawable调度正是上节unscheduleDrawable清理的对象); - issue-44/Android-Support-Library-23.2.md:说明
LayerDrawable等容器 Drawable 在旧版本上间接加载矢量图的兼容技巧。
结语
LayerDrawable与Drawable.Callback的这套机制,本质是 Android 对"谁负责重绘"的职责划分:invalidateSelf()只负责发出请求,真正的刷新由注册的 callback(最终落到View)驱动。View 换背景时"无条件清空旧背景 callback"是框架的既定行为,理解了这一点,原文中的四步 Bug 就一目了然——它不是一个框架缺陷,而是"Drawable 复用 + 组装顺序错误"叠加的结果。记住正确的顺序,再配合getCallback()做诊断,这类"静默失效"问题就不再神秘。
- 文档
- 教程
- 知识库
【免费下载链接】android-tech-frontier
【停止维护】一个定期翻译国外Android优质的技术、开源库、软件架构设计、测试等文章的开源项目
相关推荐
Raspberry Pi OS 与 Linux 调度器:scheduler_tick、schedule 与上下文切换的深入剖析
Raspberry Pi OS 与 Linux 调度器:scheduler_tick、schedule 与上下文切换的深入剖析 本篇技术指南以 Linux v4
示例工程WPF UI 主题与外观系统深度解析:主题切换、强调色同步与窗口背景效果
WPF UI 主题与外观系统深度解析:主题切换、强调色同步与窗口背景效果 导读 本文基于 docs/architecture/cross cutting/the
UI组件桌面应用pandas 1.1.2 版本发布详解:回归修复、Bug 修复与 factorize API 调整全解析
pandas 1.1.2 版本发布详解:回归修复、Bug 修复与 factorize API 调整全解析 导读 本文基于 pandas 官方变更日志 doc/s
数据分析数据科学数据处理
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考