13. 状态管理中的不变性:为什么不可变状态如此重要?
这个话题,我得先跟你聊聊我早年踩过的一个坑。
那时候我刚接手一个电商项目,购物车模块。代码写得挺顺,状态用可变对象,到处 setter 一调,感觉挺爽。结果上线第一周,用户反馈说「加购商品偶尔会变成别的商品」。查了两天,最后发现是某个 Fragment 在后台偷偷改了购物车里的商品对象——它本来只是想改个选中状态,结果把整个商品 ID 都覆盖了。
嗯,从那以后,我对「可变状态」就多了一份敬畏心。
什么是不可变状态?
说白了,不可变状态就是一个对象一旦创建,它的内部数据就不能再变了。你想改?那就创建一个新的副本,带着新数据。
在 Kotlin 里,我们常用 data class 配合 copy() 来实现:
data class CartState(
val items: List<CartItem>,
val totalPrice: Double,
val isLoading: Boolean
)
// 更新状态时,不是修改原对象,而是创建新副本
fun updateItemQuantity(state: CartState, itemId: String, newQty: Int): CartState {
return state.copy(
items = state.items.map { item ->
if (item.id == itemId) item.copy(quantity = newQty) else item
},
totalPrice = recalculateTotal(state.items)
)
}
你看,copy() 返回的是新对象,原来的 state 纹丝不动。这就是不可变的核心思想。
为什么不可变状态如此重要?
我总结了四个核心原因,都是我在实际项目中反复验证过的。
1. 可预测性:你永远知道状态从哪里来
可变状态最大的问题是什么?是「谁改了我」。你调试的时候,断点打在某一行,一看对象的值变了,但你不知道是哪个线程、哪个回调、哪个监听器干的。这种问题在 Android 里尤其常见——Activity 重建、LiveData 回调、协程并发,任何一个环节都可能偷偷改你的数据。
不可变状态就不一样了。状态只会在你显式创建新对象的时候变化。你想想看,整个数据流是单向的、透明的。出 bug 了?顺着 copy() 的调用链往上查就行,没有「暗箱操作」。
2. 线程安全:不用加锁,天然安全
Android 里主线程和后台线程共享状态是家常便饭。可变对象在这种场景下,要么加锁,要么用 ConcurrentHashMap,要么祈祷别出并发问题。说实话,我见过太多因为忘记加锁导致的诡异崩溃了。
不可变对象呢?它根本不怕并发。因为没人能改它,所有线程读到的都是同一个稳定版本。你想更新?那就创建一个新对象发布出去,旧对象自然就没人用了。
// 可变版本:需要小心同步
class MutableViewModel {
private var count = 0
@Synchronized
fun increment() {
count++
}
}
// 不可变版本:天然线程安全
class ImmutableViewModel {
private val _state = MutableStateFlow(AppState(count = 0))
val state: StateFlow<AppState> = _state.asStateFlow()
fun increment() {
_state.update { it.copy(count = it.count + 1) }
}
}
我个人习惯用 StateFlow + 不可变数据类,这样连 synchronized 都省了。协程调度器随便切,状态不会乱。
3. 引用透明:UI 可以放心做比较
Jetpack Compose 和 RecyclerView 的 DiffUtil 都依赖「状态是否变化」来判断要不要刷新 UI。如果状态是可变的,你没法简单地用 == 或 === 来判断——因为对象引用没变,但内部数据变了。
不可变状态就简单了:新对象 = 新状态,旧对象 = 旧状态。Compose 的 remember、derivedStateOf 都能正确触发重组。DiffUtil 也能精确算出哪些 item 变了。
4. 撤销/重做:天然支持时间旅行
这个功能在可变状态下几乎不可能实现。你要记录每次修改前的完整快照,还得保证回滚时不影响其他引用。但在不可变状态下,每次状态变化都是一个新对象,你只需要维护一个历史列表:
class HistoryManager<T> {
private val past = mutableListOf<T>()
private val future = mutableListOf<T>()
fun commit(state: T) {
past.add(state)
future.clear()
}
fun undo(current: T): T? {
if (past.isEmpty()) return null
future.add(current)
return past.removeLast()
}
fun redo(current: T): T? {
if (future.isEmpty()) return null
past.add(current)
return future.removeLast()
}
}
我在做一个绘图 App 的时候就用到了这个模式。用户每画一笔,就提交一个新状态。撤销?从历史列表里取上一个状态就行。代码量不大,但体验非常好。
不可变状态的常见误区
嗯,这里我得提醒你几个容易翻车的地方。
copy() 默认是浅拷贝。如果属性里包含可变对象(比如 MutableList),那新对象和旧对象会共享同一个引用。你改那个引用里的内容,两边都会变。
正确做法: 嵌套的集合也要用不可变类型,比如
List 而不是 MutableList。
我见过有人写
val state = MutableStateFlow(...) 然后直接暴露给 UI。这样 UI 层就能直接调用 state.value = ...,绕过了 ViewModel 的业务逻辑。
正确做法: 对外暴露
StateFlow,对内用 MutableStateFlow。用 .asStateFlow() 转换一下。
不可变状态在 Android 中的最佳实践
结合我这几年的经验,给你一套比较稳的实践方案:
| 层级 | 推荐做法 | 原因 |
|---|---|---|
| 数据层 | Room Entity 用 data class + val | 数据库查询结果天然不可变,避免意外修改 |
| 领域层 | 用 sealed class 定义状态 | 区分加载中、成功、失败等状态,类型安全 |
| 表现层 | ViewModel 用 StateFlow + 不可变 data class | Compose 和 ViewBinding 都能正确响应变化 |
| UI 层 | 只用 val 接收状态,不修改 | UI 只负责展示,不负责修改状态 |
我曾经在一个项目里强制团队遵守这个规范。刚开始大家觉得麻烦——「改个字段还要 copy 一下,多写一行代码」。但三个月后,线上 bug 率降了 40%,尤其是那些「偶发性的、复现不了」的诡异问题,几乎绝迹了。
用 SVG 梳理一下核心逻辑
下面这张图展示了不可变状态在 Android 状态管理中的核心位置:
总结一下
不可变状态不是银弹,但它解决的是 Android 开发中最头疼的一类问题:状态被谁改了、什么时候改的、改完之后 UI 为什么不更新。
我个人觉得,在 ViewModel 层强制使用不可变状态,是投入产出比最高的架构决策之一。它不会让你的代码变多——实际上,用 copy() 和用 setter 的代码行数差不多。但它会让你的代码变得更安全、更可测、更容易推理。
你想想看,一个状态对象从创建到销毁,它的值始终不变。所有读取它的地方,看到的都是同一个版本。这不就是我们想要的「确定性」吗?