状态管理中的副作用处理:密封类作为事件总线
说到状态管理,很多人只盯着状态本身怎么存、怎么改。但真正做过大型项目的都明白——副作用处理才是状态管理中最容易翻车的地方。什么是副作用?说白了就是状态变更后引发的那些「额外操作」:弹个Toast、跳转页面、写本地缓存、发网络请求……这些操作如果散落在各个ViewModel里,代码很快就会变成一团乱麻。
我个人习惯用密封类来构建事件总线,专门处理这些副作用。今天咱们就聊聊这个思路。
为什么需要事件总线?
先看一个典型场景:用户点击「提交订单」,ViewModel里要做什么?
- 校验表单 → 更新Loading状态 → 调用API → 成功则跳转订单页 → 失败则弹错误提示
如果把这些逻辑全塞在ViewModel里,你会看到这样的代码:
class OrderViewModel : ViewModel() {
fun submitOrder() {
_state.value = OrderState.Loading
viewModelScope.launch {
try {
val result = repository.submit(order)
_state.value = OrderState.Success(result)
// 副作用:跳转页面
navigationEvent.postValue(NavEvent.ToOrderDetail(result.id))
// 副作用:显示成功提示
toastEvent.postValue("订单提交成功")
} catch (e: Exception) {
_state.value = OrderState.Error(e.message)
// 副作用:显示错误提示
toastEvent.postValue(e.message ?: "提交失败")
}
}
}
}
嗯,这里要注意——ViewModel直接持有LiveData来触发导航和Toast,这其实是个坏味道。我在项目中遇到过好几次,团队里有人直接在Fragment里观察这些LiveData,结果生命周期一乱,页面跳转重复执行,Toast疯狂弹出……
密封类事件总线的设计思路
更好的做法是:把副作用抽象成「事件」,用密封类统一管理。ViewModel只负责发出事件,由上层(Activity/Fragment)统一消费。
先定义事件类型:
sealed class UiEvent {
data class ShowToast(val message: String, val duration: Int = Toast.LENGTH_SHORT) : UiEvent()
data class NavigateTo(val route: String, val args: Bundle? = null) : UiEvent()
data class ShowDialog(val title: String, val message: String) : UiEvent()
object GoBack : UiEvent()
data class LaunchDeepLink(val url: String) : UiEvent()
}
然后在ViewModel里用Channel来发送事件:
class OrderViewModel : ViewModel() {
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
fun submitOrder() {
viewModelScope.launch {
_state.value = OrderState.Loading
try {
val result = repository.submit(order)
_state.value = OrderState.Success(result)
_events.send(UiEvent.ShowToast("订单提交成功"))
_events.send(UiEvent.NavigateTo("order_detail/${result.id}"))
} catch (e: Exception) {
_state.value = OrderState.Error(e.message)
_events.send(UiEvent.ShowToast(e.message ?: "提交失败"))
}
}
}
}
你想想看,这样一改,ViewModel的职责就清晰了——它只负责状态管理和事件触发,不关心事件怎么被消费。Fragment那边只需要收集Flow:
lifecycleScope.launch {
viewModel.events.collect { event ->
when (event) {
is UiEvent.ShowToast -> Toast.makeText(this@MainActivity, event.message, event.duration).show()
is UiEvent.NavigateTo -> findNavController().navigate(event.route, event.args)
is UiEvent.ShowDialog -> showDialog(event.title, event.message)
UiEvent.GoBack -> findNavController().popBackStack()
is UiEvent.LaunchDeepLink -> {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(event.url))
startActivity(intent)
}
}
}
}
核心思想:状态(State)和事件(Event)要分开。状态是持久化的、可观察的;事件是一次性的、消费即丢弃的。用密封类做事件总线,天然具备类型安全和穷尽匹配的优势。
密封类 vs 接口 vs 枚举
为什么非要用密封类?我简单对比一下:
| 方案 | 类型安全 | 可扩展性 | 携带数据 | when穷尽检查 |
|---|---|---|---|---|
| 枚举 | ✅ | ❌ 不能继承 | ❌ 有限制 | ✅ |
| 接口 | ❌ 需要手动处理 | ✅ | ✅ | ❌ 容易遗漏 |
| 密封类 | ✅ | ✅ | ✅ | ✅ 编译器强制 |
说白了,密封类把接口的灵活性和枚举的穷尽检查结合起来了。我曾经在一个项目里用接口定义事件,结果新同事加了个事件类型,忘了在三个Fragment里处理,线上直接崩了……从那以后,我所有事件总线都用密封类。
内联类的妙用:轻量级事件包装
有时候事件本身很简单,比如就是一个ID或者一个字符串。这时候用data class有点重,可以用内联类来包装:
@JvmInline
value class EventId(val id: String)
sealed class AnalyticsEvent {
data class ScreenView(val screenName: String) : AnalyticsEvent()
data class ButtonClick(val buttonId: EventId) : AnalyticsEvent()
data class Error(val errorCode: Int, val message: String) : AnalyticsEvent()
}
内联类在编译期会展开成原始类型,没有额外的对象开销。对于高频事件(比如埋点上报),这个优化很有意义。
小技巧:如果事件只携带一个基本类型参数,优先考虑用内联类包装。既保留了类型安全,又避免了装箱开销。
事件总线的生命周期管理
用Channel做事件总线,有个关键点要注意——事件不能被重复消费。Channel的receiveAsFlow()是冷流,每个订阅者都会独立收到事件。但如果你用SharedFlow,记得配置replay=0:
private val _events = MutableSharedFlow<UiEvent>(replay = 0)
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
为什么?因为事件是一次性的。如果replay>0,屏幕旋转后重新订阅,会把旧事件再执行一遍——用户可能莫名其妙地又跳转了一次页面。我在项目中踩过这个坑,调试了半天才发现是replay参数的问题。
SVG:密封类事件总线的核心流程
避坑指南:事件总线的常见陷阱
用密封类做事件总线虽然好,但有几个坑我得提醒你:
- 事件丢失:如果用Channel且没有订阅者,事件就丢了。建议在Activity的onCreate里订阅,确保整个生命周期都能收到。
- 重复消费:如果用SharedFlow且replay>0,屏幕旋转后会重复执行。记住设置replay=0。
- 背压问题:短时间内发送大量事件,Channel可能会满。用Channel.BUFFERED或者指定容量。
- 跨页面事件:如果事件需要跨多个Fragment共享,考虑用共享ViewModel或者全局事件总线(但慎用)。
我曾经在一个项目里用全局事件总线传递导航事件,结果两个Fragment同时消费了同一个事件,页面被push了两次。后来改成每个页面各自持有事件Channel,问题才解决。记住:事件总线的范围越小越好。
总结
密封类作为事件总线,本质上是把「状态」和「副作用」解耦。ViewModel只关心「发生了什么」,不关心「怎么处理」。UI层统一消费事件,逻辑清晰,测试也方便——你可以单独测试ViewModel的事件输出,不用Mock任何UI组件。
嗯,这套模式我在三个生产项目里验证过,效果都不错。如果你现在还在ViewModel里直接调用navigation或者Toast,不妨试试用密封类重构一下。代码会清爽很多。
公众号:蓝海资料掘金营,微信deep3321