状态管理中的副作用处理:密封类作为事件总线

说到状态管理,很多人只盯着状态本身怎么存、怎么改。但真正做过大型项目的都明白——副作用处理才是状态管理中最容易翻车的地方。什么是副作用?说白了就是状态变更后引发的那些「额外操作」:弹个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:密封类事件总线的核心流程

密封类事件总线核心流程 用户操作 点击/输入/滑动 ViewModel 处理业务逻辑 密封类事件 ShowToast / NavigateTo Channel / SharedFlow Fragment/Activity 消费事件 触发 发送事件 传递事件 消费 状态与事件分离,ViewModel只负责发出事件,UI层统一消费

避坑指南:事件总线的常见陷阱

用密封类做事件总线虽然好,但有几个坑我得提醒你:

  • 事件丢失:如果用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