置灰按钮为什么自己又“亮“了?一次状态缓存“双写冲突“的排查记录

副标题:你调了 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 走缓存。

于是出现了"双写冲突":

  1. 你的 updateBottomBarState() 通过 setItemEnable(2, false) 置灰------但可能因为缓存里值已经是 false 而跳过,或者写进了缓存却没真正刷到 View
  2. 其他模块的观察者在稍后 触发,直接 buttonA.isEnabled = true,把按钮重新点亮;
  3. 由于它绕过了缓存,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 级别的兜底,而不是只靠初始化时的一次调用。


六、可复用的结论

  1. 一个对象的状态,只能有一种"权威写入路径"。 要么都走缓存,要么都直接操作 View,混用必打架;
  2. 绕开缓存、直接操作 View,是打破"缓存死锁"的有效手段。 当你发现封装方法因为"值没变"而跳过时,直接 view.isEnabled = x 反而更可靠;
  3. 和外部观察者竞争状态时,要加生命周期兜底。onResume 里再刷一次,覆盖"观察者后回调"的不确定性;
  4. 封装方法不一定比直接操作安全。 要看清它内部有没有缓存、有没有"值不变就跳过"的优化,这些都可能让你的调用"看起来执行了、实际没生效"。

写在最后

这个 bug 的现象很会骗人:日志显示方法执行了、参数对了,按钮却还是"活"的。真正的坑不在你自己的代码,而在你看不见的其他模块逻辑组件封装的缓存优化之间。

排查类似问题时,先问一句:"还有谁在写这个 View 的状态?" 往往比盯着自己的代码更接近真相。

如果你也遇到过类似的"幽灵状态",欢迎在评论区聊聊排查过程。

相关推荐
rannn_1111 小时前
【力扣hot100】图论专题+模板|DFS、BFS、拓扑排序...
java·算法·leetcode·深度优先·图论
用户3126874877201 小时前
ReentrantLock 到底怎么排队的?AQS 源码拆解
java
Zane19941 小时前
ArrayList 插入慢,LinkedList 一定快吗
java·后端
吃饱了得干活1 小时前
从类爆炸到协作——DDD战略设计登场
java·后端·架构
wno7042 小时前
Spring Boot异常处理
java·spring boot·后端
君顾12 小时前
智慧场馆解决方案小程序系统开发实战指南
java·开发语言·智慧场馆
学习星球2 小时前
【LeetCode算法题精讲】二分查找精讲
java·数据结构·算法·leetcode·职场和发展·图搜索
Escalating_xu2 小时前
【Linux线程同步】从数据竞争到 mutex、条件变量与生产者消费者(上篇)
android·java·linux
承渊政道2 小时前
10:30提交预约会不会撞上10:00的会议?我用飞算JavaAI3.9.1和3.9.9跑了四个时间段
java·springboot·ai编程·飞算javaai·java代码生成