8.2 ViewModel与StateFlow数据流


8.2 ViewModel 与 StateFlow 数据流

本节摘要: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)在这里成为代码。

SavedStateHandle:进程死亡后的恢复

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 到一个值后,需要动多少视图。需要一起动的字段应该在同一个流里;永远各自动的,拆开。这比记住任何教条都有用。

本节要点回顾

  • 生产端模板:意图函数防重入、密封状态、runSuspendCatching 收容、copy 更新快照——前七章机制在同一个类里合流;
  • 状态是完整 UI 描述:Data 里带 refreshing 而非全局 Loading,状态粒度服务渲染而非镜像接口;
  • 状态与事件分道:StateFlow 粘性可恢复,SharedFlow 一次性不重放——混装必然出幽灵重放;
  • 错误分层落地:网络波动降级缓存加 Toast,真正的异常进 Error 给重试;
  • SavedStateHandle 只存最小恢复集:Bundle 有限容量,存恢复体验所需而非全部状态;
  • 粒度按渲染端判断:需要一起动的字段同流,独立演化的区域拆流。

生产端与消费端都齐了,最后一节补上护航:性能防线、StrictMode、Detekt 静态检查与协程测试——把全书的纪律交给机器执行。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U