[Android MVVM架构笔记] 基于 Kotlin 类委托与 DIP 约束的全局单次事件 (Message) 优雅解耦方案
在日常开发中,像“提示信息(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.ktpackagexxx.ui.common.delegateimportkotlinx.coroutines.flow.Flow/** * 💡 完美的业务层消息契约:只定义“发送消息”和“消息数据流”,不含任何平台 UI 痕迹 */interfaceMessageDelegate{valmessageEvent:FlowStringfunemitMessage(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=ChannelString(Channel.BUFFERED)overridevalmessageEvent:FlowString=_messageChannel.receiveAsFlow()overridefunemitMessage(message:String){_messageChannel.trySend(message)