[Android 从零到一] StateFlow 与 SharedFlow:复杂页面状态管理的边界与落地
# StateFlow 与 SharedFlow:复杂页面状态管理的边界与落地 在简单页面里,一个 `MutableStateFlow` 表示加载状态,再加一个数据流,通常就能完成需求。但页面一旦同时包含首屏加载、下拉刷新、分页、筛选条件、错误提示和导航事件,零散的 Flow 很快会带来状态互相矛盾、事件重复消费、旋转屏幕后行为异常等问题。 本文从状态与事件的区别出发,逐步搭建一个可测试、可恢复、生命周期安全的页面状态模型。 ## 先区分状态和事件 选择 `StateFlow` 还是 `SharedFlow`,关键不在于 API 名称,而在于数据的语义。 - 状态描述“现在是什么”,例如列表内容、筛选条件、是否正在刷新。新观察者应该立即拿到当前值。 - 事件描述“刚刚发生了什么”,例如弹出提示、跳转详情、请求一次权限。事件处理后通常不应被新观察者再次消费。 `StateFlow` 始终保存一个当前值,订阅时会立即收到它,适合状态。`SharedFlow` 可以通过 `replay`、缓冲区和溢出策略控制广播行为,更适合一次性事件或共享数据流。 把 Toast 文案放进 `StateFlow` 是常见错误。页面显示 Toast 后若没有及时清空,设备旋转或界面重新订阅时,同一条提示会再次出现。反过来,用 `SharedFlow` 保存页面列表也不合适,因为后来订阅的界面可能拿不到当前数据。 ## 用单一 UiState 表达完整页面 以商品列表页为例,不要为 `loading`、`items`、`error` 分别暴露多个可变流。多个流分别更新时,观察者可能看见“加载已结束但数据尚未更新”的中间状态。 ``` data class ProductListUiState( val items: List = 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 = _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) : ProductListEvent data class OpenDetail(val productId: Long) : ProductListEvent } private val _events = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) val events: SharedFlow = _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>, 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> = 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.current LaunchedEffect(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` 共享,并根据页面生命周期启停。 当状态、事件和业务结果被正确区分后,页面代码会从“监听许多变量并修补边界情况”,转变为“渲染状态并处理事件”。这不仅减少重复消费和竞态问题,也让测试、排错和后续扩展更容易。