同一个页面旋转后又请求了一次网络?新打开的界面立刻看到了旧状态?发送一次事件却没有任何 UI 收到?这些问题往往不是某个 collect 写错了,而是没有先想清楚:数据什么时候开始生产、多个订阅者是否共享生产、后来订阅的人应该拿到什么。
本文从普通 Flow 的冷流行为出发,比较 StateFlow 与 SharedFlow,再落到 ViewModel 与页面生命周期。一次性导航/Toast 事件的完整选型会在下一篇单独展开。
一、普通 Flow:每次收集通常重新执行上游
kotlin
val numbers = flow {
println("start")
emit(1)
emit(2)
}
numbers.collect { println(it) }
numbers.collect { println(it) }
这里 start 会打印两次。flow {} 定义的是一段生产过程;通常创建 Flow 不会立刻执行上游,新的 collect 会重新运行这段过程。如果上游是网络请求,两位收集者可能触发两次请求。
"冷"不等于"自动在后台"。flow {} 默认遵循收集方上下文;需要安排上游执行环境时可用 flowOn,但真正的非阻塞还取决于底层 API。别把阻塞 I/O 原样放到主线程 Flow 中。
二、StateFlow:永远有一个当前状态
kotlin
data class UiState(val loading: Boolean, val title: String)
private val _uiState = MutableStateFlow(UiState(loading = true, title = ""))
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun showTitle(value: String) {
_uiState.update { current -> current.copy(loading = false, title = value) }
}
StateFlow 是热流,有必需的初始值,能通过 value 读取当前状态。新订阅者会先看到当前最新状态,而不是从应用启动那刻重播所有中间变化。这很适合页面上的"现在是什么":加载中、内容、错误、选择项。
StateFlow 基于相等性合并相同状态;并发更新时用 update 等原子更新方式,避免 _uiState.value = _uiState.value.copy(...) 这类先读后写在并发下丢失修改。即便更新了多个不同值,慢订阅者也可能只看到较新的值,不要把每一个支付或审计事件都当作 StateFlow 状态变化来可靠传送。
三、SharedFlow:可配置的热流广播
kotlin
private val _announcements = MutableSharedFlow<String>(replay = 0)
val announcements: SharedFlow<String> = _announcements.asSharedFlow()
suspend fun publish(text: String) {
_announcements.emit(text)
}
SharedFlow 也已经存在于收集者之外,可向多个当前收集者广播值;它不要求初始值。replay 决定新订阅者会收到多少个历史值,默认是 0。缓冲容量与溢出策略可以影响慢订阅者,但**replay = 0 时,没有订阅者期间发出的值不会被以后来的订阅者补领**。即使配置了 extraBufferCapacity,无人订阅时也不要误以为它能替你永久存事件。
这意味着 SharedFlow 适合广播"当前在听的人需要知道"的消息;需要跨页面重建、进程死亡或离线重试的业务结果,不能仅依赖内存中的广播。
四、三者放在一起比较
| 维度 | 普通 Flow |
StateFlow |
SharedFlow |
|---|---|---|---|
| 冷/热 | 通常冷 | 热 | 热 |
| 订阅前是否需要初始值 | 不需要 | 必须有 | 不需要 |
| 新订阅者看到什么 | 通常重新执行上游 | 当前最新状态 | replay 中保留的值;默认没有 |
| 多个订阅者是否共享生产 | 默认不共享 | 共享当前状态 | 共享广播源 |
| 常见用途 | 数据查询/转换管线 | UI 当前状态 | 多订阅者通知与事件广播 |
这不是"哪个 API 更高级"的排序。先确定业务要的是过程 、当前值 ,还是广播;再选类型。
stateIn 与 shareIn:把冷上游变为共享热流
kotlin
val sharedState = repository.observeItems()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
stateIn 使多个 UI 收集者共享上游,并保留一个当前值;shareIn 则产生按 replay 配置的 SharedFlow。关键参数不只是 replay,还有谁持有 Scope、何时启动、没人订阅后是否停止 。SharingStarted.Eagerly、Lazily、WhileSubscribed 对资源消耗和重新订阅行为不同,不能复制一份配置到所有数据源。
上例的 5_000 是停止上游前的宽限时间,常用于短暂旋转重建时避免立刻停止又启动。它不是通用最佳值;网络轮询、数据库观察与页面状态的成本不同,应按实际生命周期选。
五、算子位置决定你能捕获和切换什么
kotlin
repository.observeItems()
.map { items -> expensiveTransform(items) }
.flowOn(Dispatchers.Default)
.catch { error -> emit(emptyList()) }
.collect { items -> render(items) }
flowOn 改变其上游 的执行上下文,下面的 collect 不会因此自动跑到 Default。catch 捕获其上游 流操作的异常,不能兜住后续 render(items) 抛出的异常。协程取消也不应被当成普通数据错误吞掉。
如果 expensiveTransform 是 CPU 密集工作,Default 可能合适;若上游底层已经是异步数据库/网络 API,不要机械地再套一层 IO。先确认具体瓶颈和线程行为。
六、生产快、消费慢:buffer、conflate、collectLatest
| 算子 | 发生什么 | 适合什么 |
|---|---|---|
buffer |
在缓冲范围内让生产与消费重叠 | 消费偶尔慢,但值仍应逐个处理 |
conflate |
消费跟不上时跳过中间值 | 只关心较新的进度/状态 |
collectLatest |
新值到来时取消上一次处理 | 搜索建议、只展示最新结果 |
例如搜索框文本变化后加载建议,旧查询的结果不再有展示价值,可以在合适边界使用 collectLatest。支付流水、上传确认和审计日志不能随意跳过中间值。背压策略必须由业务语义决定。
七、Android 页面怎样收集才不重复工作?
Fragment 中优先按视图生命周期收集:
kotlin
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state -> render(state) }
}
}
低于 STARTED 时,repeatOnLifecycle 会取消内部收集块;重新进入时再启动。若上游是冷流,会重新执行;若 ViewModel 使用 stateIn 共享,上游行为取决于其启动策略。Compose 页面可用 collectAsStateWithLifecycle() 把 Flow 转为生命周期感知的 State。不要在每次 onResume() 或每次重组时无管理地新开收集器。
八、面试高频问答
Q1:flow {} 为什么叫冷流?
通常没有收集者就不生产;每次新的收集会重新执行上游。
Q2:StateFlow 和 SharedFlow(replay = 1) 一样吗?
不一样。StateFlow 必须有初始值、可通过 value 读当前状态,并基于相等性合并;普通 SharedFlow 的初始值、重放和缓冲语义由配置决定。
Q3:SharedFlow(extraBufferCapacity = 1, replay = 0) 会在无人订阅时保留一个事件吗?
不会;无人订阅时只保留 replay 指定的历史,额外缓冲不能当离线邮箱。
Q4:flowOn 会把 collect 切到后台吗?
不会,它影响上游;收集逻辑使用收集方上下文。
Q5:上游 catch 能接住 collect {} 的异常吗?
不能。算子只捕获位于它上游的流异常。
Q6:为什么页面旋转后重复请求?
可能每次都重新收集冷上游;检查收集位置、共享 Scope 与启动策略,而不是只在界面加布尔开关。
Q7:StateFlow 适合保存一次性导航事件吗?
通常要谨慎。新订阅者会重新看到当前值,可能重复执行导航;先定义事件能否丢失、是否要跨重建保留,再选模型。
一句话总结:**Flow 描述过程,StateFlow 表达现在,SharedFlow 广播给订阅者。**这不是绝对的 API 限制,却是做 Android 页面状态设计时很稳的起点。