StateFlow 与 SharedFlow 的边界:状态与事件的正确建模

做 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)
  1. **必须有初始值。**状态在任何时刻都应该有答案,构造时就得给出"现在是什么样"。
  2. **新收集者立刻收到当前值。**屏幕旋转后重建的 UI 需要马上恢复画面,重放正是需求。
  3. **值相等时跳过发射(基于 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 基本就绝迹了。

相关推荐
YF02114 小时前
Android遥控器对频详解
android·android things
心念科技4 小时前
1、搜索表单 xnSearch(基于:心念后台,后端 Java 21 + Spring Boot 4 + Spring Cloud,前端提供 ReactVue3+TS、Vue3+JS、Vue2+JS
前端
计算机魔术师5 小时前
Dwarkesh Patel 对 OpenAI/Hugging Face 事件的爆款解读被指危险误导
前端
码农coding5 小时前
android12 WindowManagerService窗口的布局过程
android·源码
自进化Agent智能体6 小时前
Hermes GitHub PR 审查 —— 自动化代码评审
前端
涛涛ing7 小时前
OpenAI Astra 泄露:零样本生成 3D 网页,前端开发者慌了吗?
前端
ssshooter8 小时前
现在网页都能提供 MCP 了?!
前端·人工智能·程序员
杉氧8 小时前
状态管理变迁史:为什么我们放弃了 Redux 选择 Zustand?
android·前端·react native
cidy_988 小时前
OpenCode 手动配置火山方舟 Agent Plan 教程
前端