做 Android 状态管理时,很多团队把 StateFlow 和 SharedFlow 当成"差不多的东西"混着用,直到线上出现两类典型 Bug:一类是 Toast 弹了两次、页面重复跳转;另一类是横竖屏切换后按钮点了没反应、事件凭空丢失。这两类问题的根源是同一个:把"状态"和"事件"这两种语义塞进了错误的容器。这篇文章从一个真实 Bug 出发,把两者的边界划清楚。
一、先看一个线上 Bug
登录页的 ViewModel 用 StateFlow 承载"登录成功"信号:
kotlin
class LoginViewModel : ViewModel() {
private val _loginResult = MutableStateFlow<LoginResult?>(null)
val loginResult: StateFlow<LoginResult?> = _loginResult
fun login(name: String, pwd: String) {
viewModelScope.launch {
val result = repository.login(name, pwd)
_loginResult.value = result
}
}
}
Activity 里收集并跳转:
kotlin
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.loginResult.collect { result ->
if (result is LoginResult.Success) {
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
}
}
}
}
测试没问题,上线后用户反馈:登录成功进入主页,按 Home 再回到登录页(页面还在栈里),又被弹到主页一次。原因很直接------StateFlow 会向新收集者重放最新值。onStart 后重新收集,拿到的还是那个 Success,跳转逻辑再执行一遍。
这不是 StateFlow 的 Bug,是语义用错了。"登录成功"是一个事件 :发生一次、消费一次、不应重放。而 StateFlow 承载的是状态:任何时刻都有值、新订阅者需要立刻拿到当前值。
二、状态与事件的本质区别
划边界之前先把两个词定义清楚:
- 状态(State):描述"现在是什么样"。比如加载中/成功/失败、当前列表数据、开关是否打开。特征是幂等------UI 重复渲染同一个状态,结果不变。
- 事件(Event):描述"刚刚发生了什么"。比如弹一次 Toast、跳转一次页面、播放一次动画。特征是一次性------重复消费会产生副作用。
判断口诀:把这个值重放一遍,UI 会不会出错?不会出错就是状态,会出错就是事件。
列表数据重放一遍,UI 还是那个列表,没问题,是状态。"跳转主页"重放一遍,用户被多跳一次,出问题了,是事件。
三、StateFlow:为状态而生
StateFlow 的三个关键特性都在为"状态"服务:
kotlin
val uiState = MutableStateFlow(UiState.Loading)
- **必须有初始值。**状态在任何时刻都应该有答案,构造时就得给出"现在是什么样"。
- **新收集者立刻收到当前值。**屏幕旋转后重建的 UI 需要马上恢复画面,重放正是需求。
- **值相等时跳过发射(基于 equals 去重)。**状态没变就不必重绘,这是天然的防抖。
第三点常被忽略,但它是一个隐藏的坑:如果你用 StateFlow 传事件,连续两次相同的事件会被吞掉一次。比如连续两次"删除失败"提示,第二次不会发射,用户以为点击没生效。
四、SharedFlow:为事件而生
SharedFlow 是更底层、更可配置的热流:
kotlin
private val _effect = MutableSharedFlow<UiEffect>()
val effect: SharedFlow<UiEffect> = _effect
默认配置下(replay = 0):没有初始值、不重放历史、不做值去重。三个特性刚好和 StateFlow 相反,全部契合事件语义:
- 没有订阅者时事件不会被"记住"再补发(默认情况下直接丢弃);
- 新订阅者不会收到旧事件,不会出现重复跳转;
- 连续两次相同事件都会正常发射。
用 SharedFlow 改写登录跳转:
kotlin
class LoginViewModel : ViewModel() {
private val _effect = MutableSharedFlow<LoginEffect>()
val effect = _effect.asSharedFlow()
fun login(name: String, pwd: String) {
viewModelScope.launch {
val result = repository.login(name, pwd)
if (result is LoginResult.Success) {
_effect.emit(LoginEffect.NavigateToMain)
}
}
}
}
回到前台重新收集时不会收到历史事件,重复跳转消失。
五、SharedFlow 的事件丢失陷阱
切到 SharedFlow 后,另一类 Bug 可能找上门:事件丢失。
默认的 MutableSharedFlow() 参数是 replay = 0, extraBufferCapacity = 0。此时 emit 的行为是:有订阅者就挂起等所有订阅者处理完;没有订阅者就直接丢弃。
复现场景:用户点击按钮触发网络请求,请求期间旋转了屏幕。UI 重建的窗口期内没有活跃收集者(repeatOnLifecycle 在 STOP 时取消了收集),请求恰好在这个空档返回并 emit------事件无声无息地没了。用户看到的现象就是"点了没反应"。
另一个变体是用 tryEmit:
kotlin
// 缓冲区为 0 时,tryEmit 永远返回 false,事件必丢
_effect.tryEmit(LoginEffect.ShowToast("登录失败"))
tryEmit 不挂起,缓冲区放不下就返回 false。默认配置下缓冲区是 0,只要没有正在挂起等待的订阅者,tryEmit 必然失败。这类代码在 code review 里非常常见,编译不报错、大部分场景能跑,只在特定时序下丢事件,极难排查。
缓解手段是给缓冲区留出空间:
kotlin
private val _effect = MutableSharedFlow<UiEffect>(
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
但要明确:这只是降低丢失概率,没有根治。缓冲区里的事件仍然只发给"当时在场"的订阅者,订阅空档期结束后不会补发。如果业务要求事件绝不丢失(比如支付结果),SharedFlow 不是合适的容器。
六、不能丢的事件:用 Channel 或状态化
两条路线:
**路线一:Channel。**Channel 天然是"一次性消费 + 无订阅者时挂起保存"的语义:
kotlin
private val _effect = Channel<UiEffect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()
// 发送
_effect.send(UiEffect.NavigateToMain)
没有收集者时事件存在缓冲区里,收集者回来后继续消费,跨越旋转空档不丢事件。而且一个事件只会被一个收集者消费,不会多播重复。大多数"ViewModel → UI 单向事件"场景,Channel 比 SharedFlow 更稳。
**路线二:把事件建模成状态。**这是官方近年更推荐的方向------与其纠结事件容器,不如把"待处理的事件"放进 UiState,UI 消费后显式回调清除:
kotlin
data class UiState(
val isLoading: Boolean = false,
val pendingNavigation: Boolean = false
)
// UI 侧
if (state.pendingNavigation) {
navigateToMain()
viewModel.onNavigationHandled() // 通知 ViewModel 清除标记
}
好处是事件获得了状态的可靠性:进程重建、旋转、后台回收都不会丢。代价是多一次"消费确认"的握手代码。对支付结果、订单状态这类不容有失的信号,这个代价值得付。
七、边界清单
把选型规则整理成表:
| 场景 | 容器 | 理由 |
|---|---|---|
| 页面 UI 状态(加载/数据/错误) | StateFlow | 需要初始值 + 重放恢复画面 |
| Toast / Snackbar 提示 | Channel 或 SharedFlow(带缓冲) | 一次性消费,允许极端情况丢失 |
| 页面跳转 | Channel | 不能重放,也不应轻易丢失 |
| 支付/订单等关键信号 | 状态化进 UiState | 必须可靠,接受握手成本 |
| 多个页面共享的广播(如登录态变化) | SharedFlow | 需要多播给多个订阅者 |
补充两个实践细节:
- 收集事件流时同样要用
repeatOnLifecycle(STARTED),否则后台期间执行跳转会触发 Fragment 事务异常。 - StateFlow 的 equals 去重意味着 UiState 应该用 data class,且列表字段避免用可变 List 原地修改------原地改完引用没变,equals 相等,UI 不刷新,这是另一个高频坑。
八、小结
StateFlow 和 SharedFlow 的边界不在 API 差异,而在语义:**状态可重放、事件不可重放;状态要求随时有值,事件要求恰好一次。**选型时先问"这个值重放一遍 UI 会不会出错",再决定容器。对不容丢失的一次性信号,Channel 或状态化建模比调 SharedFlow 缓冲参数更可靠。把这条边界在团队里定成约定,上面两类线上 Bug 基本就绝迹了。