第三章: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。原因很简单:它的订阅机制天然支持「重放」,我们可以轻松拦截发射的每个值。

特性mutableStateOfStateFlow
生命周期感知依赖 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 就返回队列里的下一个值,实现「时间旅行」的效果。

小技巧: 如果你的 ViewModel 依赖很多,推荐用 Hilt 或 Koin 来管理注入。但即使不用 DI 框架,手动传参也比硬编码好一万倍。我见过有人用「工厂模式」封装 ViewModel 的创建,也能达到同样的效果。

3.4 状态回放的链路:UI → ViewModel → Mock

为了让你更直观地理解这条链,我画了一张图。这张图展示了一个完整的「状态回放」流程:从 UI 发起动作,到 ViewModel 更新状态,再到回放控制器替换数据源,最后让 UI 收到 Mock 数据。

ViewModel 状态回放流程 Composable UI 订阅 uiState ViewModel StateFlow 状态管理 IDataRepository 接口注入 MockDataSource 预置回放数据 回放控制器 控制数据注入时机 真实API/数据库 生产环境使用 触发动作 调用 fetchData 回放模式切换 注入Mock数据 生产模式 关键原则 • 数据源接口化,方便切换 • 回放控制器独立于 ViewModel • UI 只消费状态,不关注来源

这张图里,你可以看到两条路径:

  • 生产模式:ViewModel 通过 Repository 接口调用真实数据源(绿色→青色)
  • 回放模式:回放控制器将 Repository 的实装替换成 MockDataSource(紫色),并控制每次返回的数据。ViewModel 完全无感

实际项目中,我一般会在 ViewModel 的构造参数里加一个可选的 replayController,当它不为 null 时,替换数据源并进入回放模式。UI 层不需要做任何额外代码,神奇得很。

3.5 避坑:ViewModel 的状态泄漏

讲到这里,我还想提醒一个坑——我曾经在项目里用 MutableStateFlow 直接暴露给了 UI,结果测试时发现状态被外部篡改了。这是因为 MutableStateFlow 的 value 是公开可写的。

解决办法很简单:对外只暴露 StateFlow,内部使用 MutableStateFlow 来更新。就像前面代码里写的 _uiState.asStateFlow()。这不是什么高级技巧,但少写这一步,测试时就会莫名其妙失败。

警告: 永远不要把 MutableStateFlow 直接暴露给 UI 层。哪怕你觉得自己能管住手,团队其他成员或者未来的你都有可能直接 write value,导致状态链路断裂。asStateFlow() 一行代码,省得后面 debug 一天。

3.6 小结:ViewModel 在回放中的角色

说到底,ViewModel 就是一个透明的「状态管道」。它不决定数据从哪来,也不关心数据怎么变,它只做一件事:把数据转化成 UI 可以消费的状态。而 Mock 回放,就是在这个管道上安了一个「水龙头」——管子本身不动,水龙头控制流过去的是真水还是模拟水。

我建议你在设计初期就把 ViewModel 的依赖全部接口化,哪怕只是一个小 Demo。养成习惯后,回放功能几乎可以无痛接入。你对 ViewModel 的控制力越强,Mock 回放就越好做。