本节摘要:ViewModel 是单向数据流的生产端:意图函数接收交互、仓库返回数据、StateFlow 发射密封状态。本节给出完整的实现模板(含状态初始化、幂等刷新、Loading 防抖)、状态与事件的分道处理(为什么 Toast 不该进 StateFlow)、SavedStateHandle 的进程死亡恢复,以及 UI 状态的粒度设计。
先给全貌,再逐块拆。订单历史页的 ViewModel:
class OrderHistoryVM( private val repo: OrderRepo, private val savedState: SavedStateHandle, ) : ViewModel() { sealed interface UiState { data object Loading : UiState data class Data(val orders: List<Order>, val refreshing: Boolean) : UiState data class Error(val message: String, val retryAt: Long) : UiState } private val _state = MutableStateFlow<UiState>(UiState.Loading) val state: StateFlow<UiState> = _state.asStateFlow() private val _events = MutableSharedFlow<Event>() val events: SharedFlow<Event> = _events.asSharedFlow() sealed interface Event { data class Toast(val text: String) : Event data class Navigate(val route: String) : Event } init { refresh(initial = true) } fun refresh(initial: Boolean = false) { val current = _state.value if (current is UiState.Data && current.refreshing) return // 防重入 正在刷新就忽略 if (initial) _state.value = UiState.Loading else if (current is UiState.Data) _state.value = current.copy(refreshing = true) viewModelScope.launch { runSuspendCatching { repo.fetchOrders() } .onSuccess { orders -> savedState["last_orders"] = orders.take(20) // 最小恢复快照 _state.value = UiState.Data(orders, refreshing = false) } .onFailure { e -> if (e is java.io.IOException) { _events.emit(Event.Toast("网络开小差了")) _state.value = UiState.Data(cachedOrders(), refreshing = false) // 降级到缓存 } else { _state.value = UiState.Error(e.message ?: "加载失败", System.currentTimeMillis()) } } } } private fun cachedOrders(): List<Order> = savedState.get("last_orders") ?: emptyList() fun retry() = refresh() }
这个模板浓缩了前面几乎所有章节。密封状态与穷举来自第 3 章;runSuspendCatching 是第 2 章的边界收容(放行取消异常);copy 更新状态是第 4 章的替换式更新(current.copy(refreshing = true) 不动旧快照);viewModelScope 是第 5 章的树根;防重入检查(refreshing 时直接 return)把"连点五次刷新"挡在生产端——UI 层不需要任何禁用按钮的逻辑。
几个设计决策单独说。为什么 Data 里带 refreshing 而不是单独一个 Loading 状态:下拉刷新时旧数据还在屏幕上,全局 Loading 状态会把列表换成转圈——状态粒度要能表达"有数据同时正在刷新"。Loading 状态只属于首次加载。这是密封状态建模的实战细节:状态是 UI 的完整描述,不是接口调用的镜像。为什么错误分两种:IOException 是可预期的网络波动,降级到缓存加 Toast 就够;其他异常(解析错、逻辑错)进 Error 状态给重试入口。错误处理分层(第 2 章表格)在生产端的落地形态。
新手最常犯的错误是把 Toast、页面跳转这类"一次性事件"塞进 UiState:
data class Data( val orders: List<Order>, val toast: String? = null, // 反面教材 事件混进状态 )
这个字段会在每次配置变更后重放:旋转屏幕,StateFlow 把当前值(带着 toast)重新发给新 UI,用户看到同一条 Toast 弹第二遍;更糟的是 toast 字段的清除时机——UI 消费后要通知 VM 置空,这个"消费回执"协议写错就是经典的幽灵重放 bug。根本矛盾在于状态是"现在如何"(应该被恢复、被重放),事件是"发生过一次"(错过就错过)——两个语义装进一个容器,必然有一个是错的。
正确做法是分道:状态走 StateFlow(粘性、当前值、可恢复),事件走 SharedFlow(非粘性、广播、错过不重放)。8.1 节模板的 UI 侧再补一条收集:
lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.events.collect { event -> when (event) { is Event.Toast -> toast(event.text) is Event.Navigate -> findNavController().navigate(event.route) } } } }
SharedFlow 默认 replay 为零,恰是事件的语义。第 5.5 节的选型口诀(状态 StateFlow、事件 SharedFlow)在这里成为代码。
ViewModel 能扛配置变更,扛不了进程死亡——系统在后台把进程整个杀掉后,ViewModel 与 StateFlow 全灭。SavedStateHandle 是兜底:一个随进程死亡存活的键值仓库(底层是 Bundle 机制的包装):
// 写入时机 状态更新成功后 存最小恢复快照 savedState["last_orders"] = orders.take(20) savedState["scroll_pos"] = layoutManager.findLastVisibleItemPosition() // 恢复时机 init 里读 private fun cachedOrders(): List<Order> = savedState.get("last_orders") ?: emptyList()
使用纪律有三。只存最小恢复集:不要把整个状态对象序列化进去,Bundle 有容量上限(约一兆)且跨进程序列化有开销——存"恢复体验所需的最小数据"(首页二十条、滚动位置、筛选条件)。可序列化限制:键值必须是 Bundle 支持的类型(基本类型、String、Parcelable、Serializable 与 kotlinx 序列化扩展)——这又是一个第 3 章"数据类字段选型"影响深远的场合。测试友好:SavedStateHandle 可以在单元测试里直接构造,恢复逻辑可以脱离设备测试。

状态流的粒度是实战里反复出现的决策。三个流派:单一大状态流(本节模板,一个 UiState 包打天下)、多个特征流(orders、badge、title 各一个 StateFlow,UI 分别收集组合)、派生流(基础流加 map、combine 派生)。没有绝对答案,但有倾向性建议:页面状态强关联、渲染原子性强(一次渲染需要全部字段)用单一大流——copy 更新的成本换来渲染一致性;区域独立演化(角标与列表各自刷新)用多流——避免角标变化触发整个列表重算。派生流是多流的自然延伸:
val badge: StateFlow<String> = orders .map { "${it.size} 单" } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "0 单")
判断标准回到渲染端:collect 到一个值后,需要动多少视图。需要一起动的字段应该在同一个流里;永远各自动的,拆开。这比记住任何教条都有用。
生产端与消费端都齐了,最后一节补上护航:性能防线、StrictMode、Detekt 静态检查与协程测试——把全书的纪律交给机器执行。