第三章:ViewModel 与状态管理
说到 Compose 的状态管理,绕不开的一个角色就是 ViewModel。老实说,我见过不少朋友在 Compose 里直接用 mutableStateOf 满屏飞,页面一复杂,状态改得乱七八糟,测都测不了。ViewModel 的核心价值,就是帮我们把状态从 UI 层抽离出来,变成一个可观测、可控制、可回放的数据源。
我个人习惯是,但凡一个屏幕有超过 3 个交互状态,就老老实实上 ViewModel。它不只是为了配合 AAC 的生命周期,更是为了给 Mock 回放一个干净的落脚点。你想想看,如果状态散落在各个 Composable 函数的闭包里,你连「把状态替换成 Mock 数据」这件事都不知道从哪下手。
3.1 ViewModel 在 Compose 里的定位
在 Compose 世界里,ViewModel 不是「必须」的,但它是「最省心」的。它的职责就两条:
- 持有状态:用 StateFlow 或 mutableStateOf 封装可观察的数据
- 暴露动作:通过函数让 UI 触发业务逻辑
UI 只做一件事:订阅 ViewModel 的状态,然后渲染。逻辑层负责处理数据,包括从哪里拿、怎么存、要不要回放。
这样的分层有什么好处?举个例子——我在项目中做过一个实时搜索页面,用户每输入一个字都要请求后端。如果状态直接写在 Composable 里,热重载一次状态就丢了,回放更是无从谈起。后来我把它拆到 ViewModel 里,用 StateFlow 暴露搜索结果,回放实现直接替换数据源就行了,改一行代码的事。
3.2 状态管理的两种主流模式
虽然在 Compose 里 mutableStateOf 和 StateFlow 都能用,但考虑到 Mock 回放,我更推荐统一用 StateFlow。原因很简单:它的订阅机制天然支持「重放」,我们可以轻松拦截发射的每个值。
| 特性 | mutableStateOf | StateFlow |
|---|---|---|
| 生命周期感知 | 依赖 Composition | 独立,可跨协程 |
| 回放支持 | 需额外封装 | 通过 replayCache 可直接回放 |
| 测试难度 | 中等 | 低,直接 collect 即可断言 |
| 推荐场景 | 极简单局部状态 | 业务状态,尤其是需要 Mock 的场景 |
你看这张表,StateFlow 在回放支持上一栏明显占优。不是说我不用 mutableStateOf,但凡是需要「把 UI 的输入/输出录下来用于回归验证」的场景,我会毫不犹豫选 StateFlow。
3.3 设计可回放的 ViewModel
要让 ViewModel 支持 Mock 回放,核心思路就一条:把数据源注入,而不是直接硬编码。我在初学 ViewModel 时也犯过这个错——直接在 ViewModel 里 new 一个 Repository,结果测试的时候替换不了数据,只能 mock 整个 ViewModel,那回放方案就白搭了。
正确的做法是这样的:
class MainViewModel(
private val repository: IDataRepository // 接口注入
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun loadData() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
try {
val data = repository.fetchData()
_uiState.value = _uiState.value.copy(
data = data,
isLoading = false
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e, isLoading = false)
}
}
}
}
看到了吗?repository 通过构造函数传进来,而不是在里面直接 new。这样一来,生产环境注入真实 Repository,测试或回放时注入 MockRepository。回放时,MockRepository 甚至可以预先准备好一个数据队列,每次调用 fetchData 就返回队列里的下一个值,实现「时间旅行」的效果。
3.4 状态回放的链路:UI → ViewModel → Mock
为了让你更直观地理解这条链,我画了一张图。这张图展示了一个完整的「状态回放」流程:从 UI 发起动作,到 ViewModel 更新状态,再到回放控制器替换数据源,最后让 UI 收到 Mock 数据。
这张图里,你可以看到两条路径:
- 生产模式:ViewModel 通过 Repository 接口调用真实数据源(绿色→青色)
- 回放模式:回放控制器将 Repository 的实装替换成 MockDataSource(紫色),并控制每次返回的数据。ViewModel 完全无感
实际项目中,我一般会在 ViewModel 的构造参数里加一个可选的 replayController,当它不为 null 时,替换数据源并进入回放模式。UI 层不需要做任何额外代码,神奇得很。
3.5 避坑:ViewModel 的状态泄漏
讲到这里,我还想提醒一个坑——我曾经在项目里用 MutableStateFlow 直接暴露给了 UI,结果测试时发现状态被外部篡改了。这是因为 MutableStateFlow 的 value 是公开可写的。
解决办法很简单:对外只暴露 StateFlow,内部使用 MutableStateFlow 来更新。就像前面代码里写的 _uiState.asStateFlow()。这不是什么高级技巧,但少写这一步,测试时就会莫名其妙失败。
3.6 小结:ViewModel 在回放中的角色
说到底,ViewModel 就是一个透明的「状态管道」。它不决定数据从哪来,也不关心数据怎么变,它只做一件事:把数据转化成 UI 可以消费的状态。而 Mock 回放,就是在这个管道上安了一个「水龙头」——管子本身不动,水龙头控制流过去的是真水还是模拟水。
我建议你在设计初期就把 ViewModel 的依赖全部接口化,哪怕只是一个小 Demo。养成习惯后,回放功能几乎可以无痛接入。你对 ViewModel 的控制力越强,Mock 回放就越好做。