在日常开发中,像“提示信息(Toast/Snackbar)”、“页面跳转”、“弹出 Dialog”这类由业务逻辑触发、且在 UI 层面有且仅能消费一次(One-Shot Events)的通知,在架构上被称为单次/瞬时事件(UI Events),极易面临以下几个经典设计痛点:
核心痛点
- 违反 DRY(Don’t Repeat Yourself)原则:如果在每个 ViewModel 中都去手写一遍
Channel信道和对应的 Flow 暴露逻辑,会导致项目中产生大量重复的垃圾样板代码。 - 基类膨胀(Bloated Base Class):为了图省事,将事件发送和监听写在
BaseViewModel和BaseFragment里,导致不需要提示的页面(如后台静默数据计算的 VM)也必须强制继承,严重违反单一职责原则(SRP)。 - 违背“接口隔离”原则(ISP):由于基类硬编码注入,不需要弹 Toast 的 ViewModel 也必须被迫持有这些事件,造成不必要的代码污染。
- 违背依赖倒置原则(DIP):如果直接硬编码依赖底层实现类(如直接在 ViewModel 中写死
by MessageDelegateImpl()),高层的 ViewModel 就会与低层的数据/物理库产生硬耦合,导致无法为其编写纯净的、零系统依赖的单元测试(Unit Test) [1]。
本方案遵循“依赖倒置原则(DIP)”、“单一职责原则(SRP)”以及“状态与事件语义分水岭”的设计哲学。我们抛弃了将物理实现(如 Toast)直接泄露给业务层的错误做法,利用Kotlin 类委托特性 配合Hilt 依赖注入,实现低耦合、零样板代码、高可测性的优雅设计。
一、 核心概念:状态(State)与事件(Event)的语义区别
在单向数据流(UDF)架构中,UI 的更新被严格划分为两类,绝不可混淆:
- 状态(UI State):长期持续存在(如
isLoading、数据列表)。UI 与其是**绑定(Bind / Sync)**关系。状态存在,绑定关系就在。 - 事件(UI Event):瞬时发生,一次性消费(如 Message/Toast 提示)。UI 与其是**观察/收集(Observe / Collect)**关系。事件稍纵即逝,消费即刻消失。
二、 完整物理文件清单与物理路径
app ├── src/main/xxx │ ├── di │ │ └── MessageModule.kt # 1. Hilt 模块:基于 DIP 的消息契约映射 │ │ │ ├── ui/common/delegate │ │ ├── MessageDelegate.kt # 2. 核心契约:干净、不泄露 UI 细节的业务层接口 │ │ └── MessageDelegateImpl.kt # 3. 契约实现:基于安全缓存 Channel 的信道处理器 │ │ │ └── util/ext │ ├── ActivityExt.kt # 4. 物理归位:仅限 ComponentActivity 的事件收集扩展 │ └── FragmentExt.kt # 5. 物理归位:仅限 Fragment 的事件收集扩展三、 完整代码实现
1. 核心契约接口:MessageDelegate.kt
packagexxx.ui.common.delegateimportkotlinx.coroutines.flow.Flow/** * 💡 完美的业务层消息契约:只定义“发送消息”和“消息数据流”,不含任何平台 UI 痕迹 */interfaceMessageDelegate{valmessageEvent:Flow<String>funemitMessage(message:String)}2. 契约实现类:MessageDelegateImpl.kt
采用Channel(Channel.BUFFERED).receiveAsFlow()作为底层信道。它能在 App 处于后台时将消息安全缓存,在回到前台重新收集时派发,且消费一次即消失,彻底避免了 Activity 销毁重建后“消息重复弹出”的 Bug [2]。
packagexxx.ui.common.delegateimportkotlinx.coroutines.channels.Channelimportkotlinx.coroutines.flow.Flowimportkotlinx.coroutines.flow.receiveAsFlowimportjavax.inject.Inject/** * 契约的具体业务实现 * 支持通过 Hilt 自动注入系统依赖(如 Context、网络配置等) */classMessageDelegateImpl@Injectconstructor():MessageDelegate{privateval_messageChannel=Channel<String>(Channel.BUFFERED)overridevalmessageEvent:Flow<String>=_messageChannel.receiveAsFlow()overridefunemitMessage(message:String){_messageChannel.trySend(message)