副标题:你调了
setItemEnable(false),按钮也确实灰了,可过一会儿它自己又变回可用------因为有一个观察者绕过了你的缓存,直接改了 View。
一、先看现象
有一个 bug,现象很诡异:
刚进入页面时,三个按钮灰了两个,第三个没灰。 你明明对三个按钮都调用了置灰方法。
加了日志,发现方法确实执行了、参数也确实是对的,但那个按钮就是"活"的。更蹊跷的是,它是在你置灰之后,又被某个东西"复活"的。
这篇文章复盘这个 bug,以及它背后一个经典的 Android 问题:状态缓存与直接操作 View 的"双写冲突"。
二、场景与现象
场景:一个业务界面,底部有一排功能按钮。当程序处于某些特定工作模式下,其中两个按钮(按钮 A 和按钮 B)应该被置灰不可用;切回其他模式再恢复。
实现是这样的:
kotlin
// 根据工作模式,置灰/恢复两个按钮
private fun updateBottomBarState() {
val mode = currentMode.get()
val disabled = (mode == MODE_A || mode == MODE_B)
bottomBar.setItemEnable(2, !disabled) // 按钮 A
bottomBar.setItemEnable(3, !disabled) // 按钮 B
}
setItemEnable(index, enabled) 是这个底部栏组件提供的封装方法。刚进页面时,回调注册了、也立即调用了一次。结果是:
- 按钮 B(索引 3)灰了 ✓
- 按钮 A(索引 2)没灰 ✗
于是你开始怀疑:是不是索引写错了?是不是模式判断错了?是不是回调没注册上?------都不是。
三、根因:封装方法的缓存,被外部观察者绕过了
问题的根源分两层。
3.1 setItemEnable 内部有一个缓存
这个底部栏组件的 setItemEnable 内部,会先把"启用状态"写进一个缓存数组(比如 mEnables),再根据缓存去刷新 View。它的初衷是:组件重绘、按钮重建时,能从这个缓存恢复状态。
问题在于,这个"写缓存"的逻辑,可能带有**"如果值没变就跳过"的优化**:
kotlin
// 组件的封装方法(示意,非真实源码)
fun setItemEnable(index: Int, enabled: Boolean) {
if (mEnables[index] == enabled) return // 值没变,跳过
mEnables[index] = enabled
getButton(index)?.isEnabled = enabled
}
3.2 外部模块的观察者,直接操作了 View,绕过了缓存
这个界面里,还有一个其他模块 内部的观察者:当业务状态变化时,它会直接操作按钮 A 的 isEnabled 属性:
kotlin
// 其他模块内部(示意)
someStatusLiveData.observe(...) { status ->
buttonA.isEnabled = (status == SOMETHING) // 直接改 View
}
注意它改的是 View 的 isEnabled 属性本身 ,而不是通过 setItemEnable 走缓存。
于是出现了"双写冲突":
- 你的
updateBottomBarState()通过setItemEnable(2, false)置灰------但可能因为缓存里值已经是 false 而跳过,或者写进了缓存却没真正刷到 View; - 其他模块的观察者在稍后 触发,直接
buttonA.isEnabled = true,把按钮重新点亮; - 由于它绕过了缓存,
mEnables[2]依然是 false(或状态不一致),你下次再调setItemEnable(2, false)时,又因为"缓存值没变"而跳过------形成一个永远修不回来的死循环。
根因一句话 :"走缓存的封装方法"和"直接操作 View 的外部代码"两套写状态的方式在打架,缓存与实际 View 状态脱节。
四、修复:绕开缓存,直接操作 View,并补一次兜底
修复分两步。
4.1 对你关心的按钮,直接操作 View,不经过缓存
kotlin
private fun updateBottomBarState() {
val mode = currentMode.get()
val disabled = (mode == MODE_A || mode == MODE_B)
// 直接操作按钮 View,绕开 setItemEnable 的缓存判断
bottomBar.getButton(2)?.isEnabled = !disabled
bottomBar.getButton(3)?.isEnabled = !disabled
}
关键变化:不再用 setItemEnable,而是直接拿按钮对象设 isEnabled。这样就不受"缓存值没变就跳过"的影响,每次都能真实刷新到 View。
4.2 在 onResume 里再补一次兜底
因为其他模块的观察者可能在你的初始化之后才回调(LiveData 的回调时机取决于 lifecycle 状态,不一定同步),所以还要在 onResume 里再执行一次:
kotlin
override fun onResume() {
super.onResume()
// 兜底:确保在外部观察者可能触发之后,再刷新一次
updateBottomBarState()
}
这个兜底的作用是:在外部观察者把按钮点亮之后,再用你自己的逻辑把它压回去,保证最终状态符合你的业务规则。
五、为什么这个案例值得记
5.1 "缓存"和"直接操作"是两种不可混用的状态源
这是这个 bug 最本质的教训:当一个对象的状态既有"缓存层"又有"直接 View 操作"两种写入路径时,二者必须统一,否则一定会打架。
要么全部走缓存(那就要保证所有写入方都走缓存、缓存永远和 View 一致),要么全部直接操作 View(那缓存就形同虚设,别指望它)。混用,就是给自己埋雷。
5.2 依赖模块的内部行为,可能和你的假设相反
你假设"我调了 setItemEnable 就万事大吉",但其他模块内部还有一个观察者在你的假设之外 操作同一个按钮。模块之间的封装越厚,你越要警惕对方内部有没有"看不见的写状态"逻辑。
5.3 LiveData 观察者的回调时机是不确定的
LiveData 的 observe 回调不一定同步 触发,取决于 lifecycle 当前状态。所以"我先置灰、观察者后回调"这个顺序,是不能保证的。凡是和外部观察者竞争同一状态的场景,都需要一个 onResume 级别的兜底,而不是只靠初始化时的一次调用。
六、可复用的结论
- 一个对象的状态,只能有一种"权威写入路径"。 要么都走缓存,要么都直接操作 View,混用必打架;
- 绕开缓存、直接操作 View,是打破"缓存死锁"的有效手段。 当你发现封装方法因为"值没变"而跳过时,直接
view.isEnabled = x反而更可靠; - 和外部观察者竞争状态时,要加生命周期兜底。 在
onResume里再刷一次,覆盖"观察者后回调"的不确定性; - 封装方法不一定比直接操作安全。 要看清它内部有没有缓存、有没有"值不变就跳过"的优化,这些都可能让你的调用"看起来执行了、实际没生效"。
写在最后
这个 bug 的现象很会骗人:日志显示方法执行了、参数对了,按钮却还是"活"的。真正的坑不在你自己的代码,而在你看不见的其他模块逻辑 和组件封装的缓存优化之间。
排查类似问题时,先问一句:"还有谁在写这个 View 的状态?" 往往比盯着自己的代码更接近真相。
如果你也遇到过类似的"幽灵状态",欢迎在评论区聊聊排查过程。