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 基本就绝迹了。

相关推荐
January12079 分钟前
【AI方法笔记】2026-11-02:模型流式吐 JSON 时,前端会遇到四种崩法
前端·人工智能·笔记·json
asdzx6713 分钟前
C# 操作 PDF 元数据:从属性写入到字段提取的完整流程
前端·pdf·c#
吹什么轩30 分钟前
constexpr 完全指南:从 const 的两种含义,到编译期计算的四次演进
java·前端·javascript
美狐美颜SDK开放平台1 小时前
开发直播APP、电商短视频与视频社交平台时,能否共用一套美颜SDK?
android·图像处理·人工智能·音视频·直播美颜sdk
Bruce_Liuxiaowei1 小时前
前端校验登录态,等于没校验——一次应急广播终端高危漏洞的发现与报送
运维·前端·安全
事圆则缓1 小时前
Android 中的 MQTT 协议:从设备状态推送到可靠消息处理
android
Meta Luo1 小时前
AI 时代,Web 自动化测试该怎么做?
前端·人工智能·playwright 替代工具·无代码自动化测试工具·低代码 ui 自动化测试·web 回归测试工具
去伪存真1 小时前
AI Agent 性能评估指标与自动化测试落地方案
前端·agent
三雷科技1 小时前
Android 开发环境搭建指南
android
赵庆明老师1 小时前
Android Studio Google源代理配置
android·ide·android studio