StateFlow 与 SharedFlow:复杂页面状态管理的边界与落地
在简单页面里,一个 MutableStateFlow<Boolean> 表示加载状态,再加一个数据流,通常就能完成需求。但页面一旦同时包含首屏加载、下拉刷新、分页、筛选条件、错误提示和导航事件,零散的 Flow 很快会带来状态互相矛盾、事件重复消费、旋转屏幕后行为异常等问题。
本文从状态与事件的区别出发,逐步搭建一个可测试、可恢复、生命周期安全的页面状态模型。
先区分状态和事件
选择 StateFlow 还是 SharedFlow,关键不在于 API 名称,而在于数据的语义。
- 状态描述“现在是什么”,例如列表内容、筛选条件、是否正在刷新。新观察者应该立即拿到当前值。
- 事件描述“刚刚发生了什么”,例如弹出提示、跳转详情、请求一次权限。事件处理后通常不应被新观察者再次消费。
StateFlow 始终保存一个当前值,订阅时会立即收到它,适合状态。SharedFlow 可以通过 replay、缓冲区和溢出策略控制广播行为,更适合一次性事件或共享数据流。
把 Toast 文案放进 StateFlow<String?> 是常见错误。页面显示 Toast 后若没有及时清空,设备旋转或界面重新订阅时,同一条提示会再次出现。反过来,用 SharedFlow 保存页面列表也不合适,因为后来订阅的界面可能拿不到当前数据。
用单一 UiState 表达完整页面
以商品列表页为例,不要为 loading、items、error 分别暴露多个可变流。多个流分别更新时,观察者可能看见“加载已结束但数据尚未更新”的中间状态。
data class ProductListUiState(val items: List<Product> = emptyList(),val query: String = "",val isInitialLoading: Boolean = false,val isRefreshing: Boolean = false,val isLoadingMore: Boolean = false,val canLoadMore: Boolean = true,val inlineError: String? = null
)class ProductListViewModel(private val repository: ProductRepository
) : ViewModel() {private val _uiState = MutableStateFlow(ProductListUiState())val uiState: StateFlow<ProductListUiState> = _uiState.asStateFlow()
}
一个不可变数据类能形成页面的完整快照。每次更新都从旧状态复制出新状态,调试日志、单元测试和状态恢复都会更清晰。
这里把首屏加载、刷新和加载更多拆开,是因为它们对应不同的 UI:首屏可能展示全屏骨架,刷新使用顶部指示器,加载更多只影响列表底部。单个 isLoading 无法准确表达这些差异。
原子更新避免覆盖并发结果
更新 MutableStateFlow 时优先使用 update:
private fun setRefreshing(refreshing: Boolean) {_uiState.update { old ->old.copy(isRefreshing = refreshing)}
}
下面这种“先读取、后赋值”的方式在并发任务中可能丢失更新:
_uiState.value = _uiState.value.copy(isRefreshing = true)
假设刷新任务刚更新了列表,筛选任务同时基于更早的 value 修改查询词,后写入的旧副本可能把新列表覆盖掉。update 会以当前值执行原子更新,能减少这类竞态。
用 SharedFlow 承载一次性副作用
导航和短暂提示不属于页面快照,可以定义为事件:
sealed interface ProductListEvent {data class ShowMessage(val text: String) : ProductListEventdata class OpenDetail(val productId: Long) : ProductListEvent
}private val _events = MutableSharedFlow<ProductListEvent>(replay = 0,extraBufferCapacity = 1,onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val events: SharedFlow<ProductListEvent> = _events.asSharedFlow()
replay = 0 表示新的订阅者不会收到历史事件。额外缓冲允许调用方通过 tryEmit 非挂起地发送少量 UI 事件;缓冲满时丢弃旧事件,避免 ViewModel 因界面暂时不可见而阻塞。
这个策略不是万能答案。支付成功、订单提交等不能丢失的业务结果,不应只依赖内存事件流,而应落到持久化状态或由仓库提供可恢复的数据源。SharedFlow 适合 UI 副作用,不等于可靠消息队列。
fun onProductClick(productId: Long) {_events.tryEmit(ProductListEvent.OpenDetail(productId))
}
搜索条件驱动数据请求
当查询词变化时,可以让状态流直接驱动请求,并通过 flatMapLatest 取消过期任务:
private val query = MutableStateFlow("")private val searchResult = query.debounce(300).map(String::trim).distinctUntilChanged().flatMapLatest { keyword ->repository.search(keyword).map<Result<List<Product>>, SearchResult> { data ->SearchResult.Success(data.getOrThrow())}.catch { cause -> emit(SearchResult.Failure(cause)) }}init {searchResult.onEach(::applySearchResult).launchIn(viewModelScope)
}fun onQueryChange(value: String) {query.value = value_uiState.update { it.copy(query = value) }
}
flatMapLatest 很重要:用户连续输入时,旧关键词对应的请求结果即使更晚返回,也不会覆盖新关键词的数据。需要注意,底层网络调用必须支持协程取消;Retrofit 的挂起函数通常能够正确响应取消。
冷流转为 StateFlow 时如何选择启动策略
仓库通常暴露冷 Flow,每个收集者都会触发上游执行。ViewModel 可以使用 stateIn 把它共享为状态:
val favorites: StateFlow<Set<Long>> = repository.observeFavoriteIds().stateIn(scope = viewModelScope,started = SharingStarted.WhileSubscribed(5_000),initialValue = emptySet())
WhileSubscribed(5_000) 表示没有订阅者后延迟停止上游。短暂的配置变更通常能复用原有订阅,同时界面长期离开后不会继续消耗数据库或网络资源。
常见启动策略的取舍如下:
| 策略 | 行为 | 适用场景 |
|---|---|---|
Eagerly |
创建后立即启动 | 必须持续维护的进程内状态 |
Lazily |
出现订阅者后启动且不停止 | 初始化成本高、之后需常驻的数据 |
WhileSubscribed |
按订阅情况启停 | 大多数页面级状态 |
初始值也要有明确语义。emptyList() 到底表示“尚未加载”还是“加载完成但没有数据”,必须通过额外字段或密封类型区分,否则页面可能先闪一下空态再进入加载态。
在 Compose 中按生命周期收集
Compose 页面应使用 collectAsStateWithLifecycle 收集状态:
@Composable
fun ProductListRoute(viewModel: ProductListViewModel,openDetail: (Long) -> Unit
) {val state by viewModel.uiState.collectAsStateWithLifecycle()val lifecycleOwner = LocalLifecycleOwner.currentLaunchedEffect(viewModel, lifecycleOwner) {lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {viewModel.events.collect { event ->when (event) {is ProductListEvent.ShowMessage -> {// 调用 SnackbarHostState.showSnackbar(event.text)}is ProductListEvent.OpenDetail -> openDetail(event.productId)}}}}ProductListScreen(state = state,onQueryChange = viewModel::onQueryChange,onProductClick = viewModel::onProductClick)
}
状态收集与生命周期绑定后,页面进入后台会停止收集。事件流也应遵循同样原则,否则后台页面仍可能执行导航或弹出提示。
如果使用 Fragment,可在 viewLifecycleOwner.lifecycleScope 中结合 repeatOnLifecycle,不要直接使用 Fragment 自身的生命周期收集视图事件,否则 View 被销毁后仍可能访问旧绑定对象。
错误应该放状态还是事件
判断标准仍然是:用户重新进入当前界面时,是否应该继续看到它。
- 首屏失败并提供重试按钮:属于状态,应保存在
UiState。 - 列表已有内容,刷新失败后提示一次:通常属于事件,可显示 Snackbar。
- 表单字段校验错误:属于状态,因为用户修改前需要持续看到。
- 保存成功后的短提示和返回操作:通常属于事件。
同一个异常在不同场景中可能采用不同表达。不要创建一个全局 errorFlow 处理所有错误,否则 UI 很难决定应该展示全屏错误、行内错误还是短提示。
测试状态和事件
状态模型的价值之一是容易测试。协程测试中可以直接检查状态快照:
@Test
fun refresh_success_updates_items_and_stops_indicator() = runTest {repository.result = listOf(Product(id = 1, name = "Keyboard"))val viewModel = ProductListViewModel(repository)viewModel.refresh()advanceUntilIdle()assertEquals(1, viewModel.uiState.value.items.size)assertFalse(viewModel.uiState.value.isRefreshing)assertNull(viewModel.uiState.value.inlineError)
}
测试事件时,必须先启动收集者,再触发动作,因为 replay = 0 不会重放过去的事件。项目中可以使用 Turbine 简化 Flow 断言:
@Test
fun click_emits_navigation_event() = runTest {val viewModel = ProductListViewModel(repository)viewModel.events.test {viewModel.onProductClick(42)assertEquals(ProductListEvent.OpenDetail(42), awaitItem())cancelAndIgnoreRemainingEvents()}
}
常见问题清单
- 不要从界面直接修改
MutableStateFlow,只暴露只读的StateFlow。 - 不要用
StateFlow承载只消费一次的 Toast 或导航。 - 不要默认
SharedFlow会保存事件;replay = 0时没有订阅者就可能丢失。 - 不要为每个字段创建独立状态流,再期待 UI 自行拼出一致快照。
- 不要在状态更新中修改原列表,应创建新集合,保证不可变性和 Compose 重组判断可靠。
- 不要忽略请求取消和竞态,搜索、筛选等场景优先考虑
flatMapLatest。 - 不要让页面级冷流永久运行,按实际生命周期选择
SharingStarted。
总结
复杂页面的稳定性来自清晰的语义边界:可恢复、可重复读取的数据进入 StateFlow;只应处理一次的 UI 副作用进入 SharedFlow;多个字段组成不可变的 UiState,保证每次更新都是一致快照;冷流通过 stateIn 共享,并根据页面生命周期启停。
当状态、事件和业务结果被正确区分后,页面代码会从“监听许多变量并修补边界情况”,转变为“渲染状态并处理事件”。这不仅减少重复消费和竞态问题,也让测试、排错和后续扩展更容易。