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 的 rememberderivedStateOf 都能正确触发重组。DiffUtil 也能精确算出哪些 item 变了。

小技巧: 在 Compose 里,如果你发现 UI 不更新,十有八九是状态对象没变——你改了可变对象的属性,但没创建新实例。检查一下你的 data class 是不是用了 var 而不是 val。

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 的时候就用到了这个模式。用户每画一笔,就提交一个新状态。撤销?从历史列表里取上一个状态就行。代码量不大,但体验非常好。

不可变状态的常见误区

嗯,这里我得提醒你几个容易翻车的地方。

误区一: 以为 data class 的 copy() 是深拷贝。
copy() 默认是浅拷贝。如果属性里包含可变对象(比如 MutableList),那新对象和旧对象会共享同一个引用。你改那个引用里的内容,两边都会变。
正确做法: 嵌套的集合也要用不可变类型,比如 List 而不是 MutableList
误区二: 在 ViewModel 里暴露可变 StateFlow。
我见过有人写 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 状态管理中的核心作用 不可变状态 data class + copy() ✅ 可预测性 状态变化可追溯 每次变化留下快照 ✅ 线程安全 无需加锁 天然防并发问题 ✅ 引用透明 UI 比较准确 Compose 正确重组 ✅ 时间旅行 撤销/重做天然支持 历史状态可回放 ⚠️ 常见误区:浅拷贝陷阱 / 暴露可变 StateFlow

总结一下

不可变状态不是银弹,但它解决的是 Android 开发中最头疼的一类问题:状态被谁改了、什么时候改的、改完之后 UI 为什么不更新。

我个人觉得,在 ViewModel 层强制使用不可变状态,是投入产出比最高的架构决策之一。它不会让你的代码变多——实际上,用 copy() 和用 setter 的代码行数差不多。但它会让你的代码变得更安全、更可测、更容易推理。

你想想看,一个状态对象从创建到销毁,它的值始终不变。所有读取它的地方,看到的都是同一个版本。这不就是我们想要的「确定性」吗?

一句话总结: 不可变状态 = 可预测 + 线程安全 + 引用透明 + 时间旅行。这四个特性,每一个都能在实战中帮你省下大量调试时间。

公众号:蓝海资料掘金营,微信deep3321