ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

StateFlow与SharedFlow实战:从原理到选型的Android协程热流指南

StateFlow与SharedFlow实战:从原理到选型的Android协程热流指南 上个月重构一个搜索页把 LiveData 换成 StateFlow 之后代码量直接少了一半而且再也不用纠结 setValue 到底该不该切主线程。StateFlow 和 SharedFlow 在协程生态里一直属于“看起来会用、但总差点意思”的那类组件——很多文章只告诉你 API 长什么样却没告诉你内部到底怎么运转、什么时候该用哪个、为什么别人家的代码写得那么干净。这篇文章我把两兄弟完全拆开讲从热流原理、缓冲策略到实战坑点和选型清单都覆盖一遍适合已经在项目里引入协程、想真正把 Flow 用明白的 Android 开发。1. 为什么我在新项目里用 StateFlow 替换掉了全部 LiveData1.1 LiveData 的三个让我难受的点先说清楚LiveData 不是不能用是它在 ViewModel 里管状态时有几个长期存在的别扭点。第一LiveData 虽然内部有postValue和主线程派发机制但它本质上不是协程原生的东西。你在 ViewModel 里做数据加载从仓库层拿到 Result 之后要更新 UI要么走postValue要么先把线程切回主线程再setValue。代码写多了之后整个链路里到处是“切线程”的痕迹而 Flow 这条链路从头到尾都是挂起函数没有线程切换的负担。第二LiveData 的操作符能力几乎为零。Transformations.map、switchMap写起来又绕又难读而且每次你需要在数据流中间做filter、debounce、catch这类操作时都得手动包一层 MediatorLiveData反复 addSource、removeSource维护成本很高。反观 Flowmap、filter、debounce、flatMapLatest这些操作符都齐了数据加工直接链式调用。第三粘性事件的问题在 LiveData 里尤其麻烦。LiveData 的粘性机制允许新观察者立刻拿到当前值这对状态恢复是好的但如果你拿它发一次性事件比如 Toast、导航指令第二次注册观察者时就会把旧事件再触发一遍。于是每个人都要自己写一套SingleLiveEvent这个坑我踩过无数次。1.2 StateFlow 简洁的纯 Kotlin 表达StateFlow 解决了上面这些问题的绝大部分。它本身就是挂起函数世界里的公民不需要额外处理线程自带操作符全家桶对于状态场景它默认只保留最新值并且只在值发生变化时通知观察者。看一个最简单的 ViewModel 写法class ProfileViewModel : ViewModel() { private val _uiState MutableStateFlow(ProfileUiState()) val uiState: StateFlowProfileUiState _uiState.asStateFlow() fun loadProfile() { viewModelScope.launch { repository.getProfile() .onStart { _uiState.update { it.copy(loading true) } } .catch { error - _uiState.update { it.copy(loading false, message error.message) } } .collect { profile - _uiState.update { it.copy(loading false, profile profile) } } } } }注意几个细节MutableStateFlow声明为 private对外只暴露只读的StateFlow更新状态用的是update而不是value 。update内部是 CAS 循环在高频并发更新时不会丢失中间状态但会保证最终值一致。这个写法比 LiveData 版本干净得多没有开关主线程的噪音状态转换也一目了然。1.3 asStateFlow 暴露边界的必要性我见过不少同学直接把MutableStateFlow暴露给 View 层对外可以随意修改value。这在小型 demo 里问题不大一旦多人协作外部代码一个手滑就把 UI 状态改了排查起来相当痛苦。正确做法永远是内部用MutableStateFlow对外用asStateFlow()转成只读StateFlow。这句话我每篇 Flow 相关文章里都要强调一次因为这是我实际在 code review 里看到频率最高的问题。只读暴露的意义不仅在于封装还在于让 View 层明确知道“你只能订阅不能修改”从 API 设计层面就把数据流的边界划清楚了。2. StateFlow 的本质热流、去重与“悬挂更新”机制2.1 冷流 vs 热流先搞清楚你操作的是什么Flow 有冷热之分这个无数文章讲烂了但我还是想用自己的话说一遍因为后面所有坑都源于没分清这一点。冷流flow { emit(1); emit(2) }这种。每次有一个新的收集者上游代码块就会从零开始重新执行一遍。也就是说冷流是“按需生产”没有观察者时它什么也不做每个观察者拿到的序列是独立完整的。类比一下冷流像拉面师傅你点一碗他才给你拉一碗。热流StateFlow 和 SharedFlow 都是热流。热流在创建时就把数据放在一个共享的地方不管有没有收集者数据都在那里。类比一下热流像保温壶里的水你随时拧开就能倒倒给谁、倒多少壶里的水位并不因为你倒了而减少。StateFlow 属于热流里“有状态”的那种——它永远保存一个当前值任何新订阅者加入时立刻就能拿到这个值。这个特性对 UI 场景极其重要。比如说用户把 App 切到后台Activity 被销毁重建新的订阅者一进来不需要等上游重新加载立刻就能拿到之前展示的状态。2.2 StateFlow 的三大基本特征StateFlow 有三个核心特征分别对应三个不同的 API 设计。特征一必须有初始值。MutableStateFlow(ProfileUiState())里的ProfileUiState()就是初始值。这意味着 StateFlow 永远不可能是“空”的UI 层在订阅的第一瞬间就能拿到一个确定的状态。相比之下LiveData 在还没有setValue之前观察者拿到的可能是 null。特征二只保存最新值。每次更新value新值会覆盖旧值StateFlow 内部只保留最近一个。这是“状态”这个语义的自然要求——状态只需要记住当前长什么样不需要记住历史演进过程。特征三自动去重。如果新值和当前值通过equals比较相等StateFlow 不会向收集者发送任何通知。这个特性非常重要直接决定了你可以在 UI 层放心地使用 StateFlow因为重复的网络请求返回相同数据也不会导致无意义的 UI 刷新。2.3 内部工作机制为什么 collector 慢也不会丢最新值StateFlow 的“悬挂更新”conflation机制是它和 Channel 最大的区别所在。假设上游以极快的速度连续发出值 1、2、3而下游收集者处理每个值需要 1 秒。在 Channel 场景下如果没有缓冲区生产者和消费者之间需要互相等待值会按顺序排队如果缓冲区只有 1多出来的值可能直接溢出。但在 StateFlow 里如果下游正在处理旧值上游发来的新值不会排队而是直接“覆盖”当前值。下游处理完当前值后会立刻去拿最新值中间那些来不及处理的值就被合并掉了。这个机制还有个专业名词叫 conflate中文常译作“合并”。用大白话说StateFlow 保证了下游永远能拿到最新状态并且永远不会因为速度跟不上而被中间值淹没。这正是 UI 层需要的语义我只需要知道“现在”是什么状态中间经过了什么并不重要。但也要提醒一句这套机制意味着 StateFlow 不适合做“每个事件都必须被处理一次”的通信。如果你需要保证每条数据都被完整消费请转身去找 SharedFlow 或者 Channel。3. SharedFlow 才是真正的“事件通道”参数配置与缓冲策略拆解3.1 MutableSharedFlow 的三个构造参数SharedFlow 是热流的另一个变体但它不像 StateFlow 那样死守着“当前值”而是给你三个自由配置的参数用来精确控制数据分发的行为。这三个参数是replay、extraBufferCapacity和onBufferOverflow。replay表示新订阅者加入时能立刻重放多少个旧值给它。replay 0表示新订阅者拿不到任何历史数据只能接收订阅之后的新事件replay 1表示新订阅者会立刻收到最近的一个值行为上接近 StateFlow 但少了去重机制replay n则会重放最近 n 个值。extraBufferCapacity表示在 replay 之外额外分配多少缓冲空间用于临时存储那些“还没有被任何订阅者消费”的新事件。注意这个参数要结合onBufferOverflow一起理解——缓冲满了之后新事件该何去何从取决于溢出策略。onBufferOverflow有三个可选项策略行为适用场景SUSPEND缓冲满时emit挂起直到有空间要求不丢弃任何数据生产者可以等待DROP_OLDEST丢弃最旧未消费的缓存数据事件密集、只关心最新事件DROP_LATEST丢弃最新发出的数据已缓存数据优先级更高新数据可丢这三个参数组合起来可以覆盖状态同步、事件总线、任务队列等几乎所有的热流通信需求。3.2 事件总线场景的推荐配置项目里最常用的 SharedFlow 配置是事件总线也就是replay 0、extraBufferCapacity取一个适当值、onBufferOverflow DROP_OLDEST。我自己写全局事件总线时一般这样配sealed class AppEvent { data object ShowMessage(val text: String) : AppEvent() data class OpenScreen(val route: String) : AppEvent() } class AppEventBus { private val _events MutableSharedFlowAppEvent( replay 0, extraBufferCapacity 64, onBufferOverflow BufferOverflow.DROP_OLDEST ) val events _events.asSharedFlow() suspend fun send(event: AppEvent) { _events.emit(event) } }为什么这样配replay 0保证迟到订阅者不会收到旧事件避免粘性事件问题extraBufferCapacity 64给突发高频事件留出缓冲余量DROP_OLDEST保证即使消费端短暂卡顿事件也不会无限积压超过 64 条时自动丢最旧的这比让 UI 层面前堆一堆待处理事件要合理得多。需要特别注意一点上面 event bus 的send方法是suspend的调用emit时如果缓冲满了且策略是SUSPEND它会一直挂起。我用DROP_OLDEST就是为了让emit几乎不会挂起但极端情况下仍然可能短暂挂起所以事件发送应该放在viewModelScope.launch里不要直接在主线程裸调。3.3 shareIn 与 stateIn冷流转热流真正干活的时候你不会总是手动创建一个MutableSharedFlow再往里emit。更多场景是你已经有一个冷流网络请求、数据库查询想把它共享给多个收集者这时就需要shareIn和stateIn。shareIn的签名是fun T FlowT.shareIn( scope: CoroutineScope, started: SharingStarted, replay: Int 0 ): SharedFlowTstateIn签名类似但它返回StateFlowfun T FlowT.stateIn( scope: CoroutineScope, started: SharingStarted, initialValue: T ): StateFlowT关键在于started参数它决定上游冷流何时开始执行。有三种选择SharingStarted.Eagerly创建后立刻启动上游即使还没有任何收集者SharingStarted.Lazily在第一个订阅者出现后启动SharingStarted.WhileSubscribed()在有订阅者时启动所有订阅者取消后再过指定的replayExpirationMillis就停止上游。我强烈建议在使用shareIn/stateIn时优先考虑WhileSubscribed尤其当上游是网络请求时。想象一个首页数据源Eagerly模式会让它在 Activity 还没绑定时就把请求发出去了浪费流量也浪费电量WhileSubscribed保证只有界面真的在展示时才拉数据页面销毁后自动停止。4. 实战中反复踩过的六个坑全部记下来给你4.1 坑一用 StateFlow 发一次性事件事件被“粘住”或丢失新手最容易犯的错误就是用MutableStateFlowBoolean或者MutableStateFlowString来发一次性事件。你看StateFlow 天然会保存最新值并且新订阅者一进来就收到这个值。如果你用它通知“登录成功”第一次发出时订阅者处理了但值已经保存在 StateFlow 里。下次用户再登录你把值改成true但true和上一次的true相等StateFlow 去重机制直接拦截订阅者一个通知都收不到。反过来如果你用一个自增 Int 事件第一次值为 1第二次值为 2订阅者都能收到但恰好页面销毁重建新订阅者注册时会立刻把旧事件再处理一次造成重复响应。正确的做法是一次性事件一律用SharedFlow(replay 0)。这样才能保证事件发出后转瞬即逝既不会重复响应也不会被去重机制误杀。4.2 坑二emit 是挂起函数缓冲满时直接卡住 UIMutableSharedFlow.emit()是挂起函数它有可能挂起当前协程。如果你在Main线程直接调用一个不支持DROP_OLDEST的 SharedFlow 的emit恰好缓冲又满了整个 UI 协程都会被卡住。轻则掉帧重则 ANR。我处理这个问题的固定套路分两步。第一步优先配置onBufferOverflow BufferOverflow.DROP_OLDEST第二步如果用tryEmit更安全因为tryEmit不会挂起返回Boolean表示是否成功入队。像很多需要在点击事件里发信号的场景我会直接用tryEmit失败了就丢弃因为事件本身就带有时效性晚处理不如不处理。但也要注意如果你用的是SUSPEND策略事件是不允许丢的此时使用emit挂起等待反而是正确行为。一切的取舍都看你对“可靠性”的要求有多高。4.3 坑三stateIn 的 WhileSubscribed 启动策略与页面切后台stateIn(scope viewModelScope, started SharingStarted.WhileSubscribed(), initialValue ...)是大多数 UI 状态场景的推荐配置但这里藏着一个隐蔽问题WhileSubscribed在没有任何订阅者时停止上游有订阅者时重新启动上游。Activity 一旦进入后台如果用repeatOnLifecycle(STARTED)收集订阅者会取消上游跟随停止。当用户回到前台时订阅者重新出现上游又得重新执行一次网络请求。这意味着你原本以为只加载一次的数据实际上每次前后台切换都会重新加载一遍。解决方案有两个方向。如果数据必须保持为最新且重新加载成本不高那这个行为反而是优点如果数据希望尽量缓存、不轻易刷新可以增大WhileSubscribed的replayExpirationMillis参数默认是 5 秒意味着所有订阅者取消 5 秒内重新订阅上游不会停。又或者干脆用SharingStarted.Eagerly把数据提前加载好但这样页面未展示时流量也会消耗。没有绝对正确的方案关键是你要清楚地知道自己选的是哪种语义。4.4 坑四collectLatest 与 StateFlow 结合时出现的“丢中间态”collectLatest本身是个好东西它能在新值到达时取消上一个处理块。但如果你把collectLatest用在 StateFlow 上又恰好做了耗时操作会有一种被误会的“丢数据”现象。举个例子你在搜索框里输入关键词StateFlow 每次更新都会触发collectLatest里的搜索请求。这个场景没问题因为新请求会取消旧请求符合预期。但如果你在做分页加载StateFlow 更新页码时collectLatest会取消已经发出的加载任务。等前一个分页请求被取消新的分页请求又要重新发起结果就是页码之间互相打断数据迟迟加载不完。我的建议是StateFlow 场景优先使用collect而不是collectLatest除非你的业务语义明确要求“新值到达时取消旧处理”。如果只想合并高频事件请在 ViewModel 层用debounce、sample或flatMapLatest做加工而不是把压力全部推给收集端。4.5 坑五把 MutableStateFlow 直接暴露给外部这个坑我在 1.3 里已经点名过一次但在实战坑列表里值得再提一遍。把MutableStateFlow暴露出去最直接的后果是外部代码可以直接改状态而改动源不止一个状态来源就很难追溯。多模块协作时尤其可怕——某个仓库里的代码在某个晚上偷偷把状态改了隔天线上 Bug你怎么查我的统一规范是所有MutableStateFlow声明为private val立即调用asStateFlow()暴露只读引用。这条规则优先级在我的 code review 检查单里仅次于“禁止在主线程做网络请求”。4.6 坑六全局单例 SharedFlow 的事件生命周期管理最后这个坑比较深。全局单例的 SharedFlow 事件总线好处是解耦坏处是事件被多个订阅者同时接收你很难控制“一次性”粒度。假设你有一个未读消息数变化的全局事件A 页面收到了B 页面也收到了两个页面都要刷新。这没问题。但如果是“跳转到订单详情”这类事件呢A 页面弹出B 页面也弹出用户会疯掉。好在 SharedFlow 本身是广播语义每个订阅者都会收到同一份事件。你要做的是在事件粒度上设计好导航类事件不要走全局总线应该走每个页面自己的SharedFlow(replay 0)保证事件只被真正的目标页面消费。全局总线里只放适合广播给所有界面的状态变更通知比如“用户登录状态变化了”“全局配置更新了”。5. 选型判断一张表带着你决定用哪个5.1 判断核心你要的是“状态”还是“事件”十次选型里九次纠结其实只要问一句话这个数据是“状态”还是“事件”状态的特点是有一个当前值任何时候新订阅者都想知道此刻的值旧值被覆盖了也没关系。比如页面 loading 状态、用户昵称、购物车商品数量。这类数据统一用 StateFlow。事件的特点是只会在某个时刻发生一次错过了就错过了不该通过“重放上一次事件”来弥补。比如请求失败后的 Toast、页面跳转指令、滚动到顶部操作。这类数据统一用 SharedFlowreplay 0。用这个标准筛一遍几乎不会选错。5.2 按订阅者数量、延迟、重放需求对照选择再给一张更细的对照表方便你在边界场景里快速决策需求特征推荐方案原因订阅者需要立刻拿到最新状态StateFlow自带初始值和去重订阅者不需要历史数据只要增量事件SharedFlow(replay 0)不粘滞不重复每个订阅者都要处理每一条独立数据SharedFlow SUSPEND等待消费不丢数多个订阅者共享同一份热数据shareIn 的 SharedFlow上游只跑一次页面销毁后数据仍要保留最新值ViewModel 持有的 StateFlow生命周期比 View 长兜住高突发事件的余量SharedFlow DROP_OLDEST丢弃最旧防积压另外补充一个容易忽略的点当你只在 ViewModel 和 View 单对单通信并且每次页面重建时都想恢复最新状态时StateFlow 是标准答案。但当你有一个数据源需要被多个页面或多个模块同时观察时请务必用shareIn将它包装成 SharedFlow避免每个订阅者各自触发一次上游冷流执行。我曾经见过三个页面各自 collect 一个冷流结果同一个网络请求发了三遍把服务端打到限流。6. 进阶细节生命周期感知与单元测试6.1 repeatOnLifecycle 的正确收集姿势收集 StateFlow 最安全的姿势不是lifecycleScope.launch { viewModel.uiState.collect { } }而是使用repeatOnLifecycle。原因很实在直接launch collect会让收集器在整个生命周期里一直活跃即使 Activity 已经进入 STOPPED 状态UI 还在被回调更新轻则浪费资源重则在恢复时引发闪烁。推荐的写法class ProfileFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { lifecycleScope.launch { viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state - render(state) } } } } }repeatOnLifecycle的含义是当生命周期至少到达 STARTED 时开始收集生命周期低于 STARTED 时自动取消收集再次回到 STARTED 时重新开始收集。配合 stateIn 的WhileSubscribed就形成了一套完整的“页面可见-数据活跃页面不可见-数据暂停”的闭环这是我目前觉得最稳的 UI 订阅方案。6.2 用 Turbine 给 Flow 写靠谱的测试既然用了 Flow单元测试就是一个绕不开的话题。StateFlow 的测试相对简单直接断言stateFlow.value即可。SharedFlow 的测试需要收集并验证事件推荐用app.cash.turbine库写起来非常顺手Test fun 登录失败时发送错误事件() runTest { val repository FakeRepository(fail true) val vm LoginViewModel(repository) backgroundScope.launch { vm.login().join() } val events vm.events.test(timeout 3_000) { assertEquals(AppEvent.ShowMessage(登录失败), awaitItem()) awaitComplete() } }Turbine 的awaitItem、awaitComplete能帮你精确断言事件流里的元素再也不用自己写ArrayList手动收集再逐个比对。测试 StateFlow 时还有一个常见误区直接去收集MutableStateFlow然后异步等一会儿再断言。更好的方式是状态转成值后用assertEquals做纯函数式验证这样测试速度更快、更稳定。只有当你测试的是收集端行为比如collect内的副作用才需要真的去收集 Flow。最后再分享一点这半年里我把项目里所有 LiveData 和 ObservableField 都换成了 StateFlow事件通信全部切到 SharedFlow。真实感受是代码的线程噪音少了状态流转清晰了以前三天两头出现的“LiveData 重复触发”也根除了。如果你正准备在项目里铺开这两兄弟我建议先从 ViewModel 层的 UI 状态开始把 StateFlow 跑顺了再逐步构建事件总线最后再处理 shareIn 这类共享逻辑。步子放稳比什么都重要。希望这份记录能让你少踩几个我已经替你踩过的坑。
返回列表