2. ViewModel 的边界原则:单一职责、最小暴露、生命周期绑定
2.1 原则总览:三把尺子界定 ViewModel 边界
ViewModel 的边界并非由框架强制划定,而是由三条相互制衡的设计原则共同定义。这三条原则分别回答了 ViewModel 的三个本质问题:做什么、露什么、活多久。任何跨模块边界泄漏的根源,都可以追溯到其中一条原则被打破。
| 原则 | 核心追问 | 边界作用 | 违反后果 |
|---|---|---|---|
| 单一职责 | 这个 ViewModel 应该负责什么? | 划定职责上限,防止逻辑膨胀 | 职责扩散 → 跨模块耦合 → 难以测试 |
| 最小暴露 | 外部模块必须看到什么? | 限定公开接口,隐藏实现细节 | 内部状态泄漏 → 信任链断裂 → 紧耦合 |
| 生命周期绑定 | ViewModel 何时创建与销毁? | 确立存活范围,防止内存泄漏 | 资源泄漏 → 状态残留 → 数据不一致 |
2.2 单一职责原则:聚焦 UI 状态与业务编排
ViewModel 的职责边界应当严格限定在 UI 状态的持有与转换、业务逻辑的编排与调度 两个维度。它不应当知道 View 的具体实现细节,也不应当直接操作数据源。
应当承担的职责
- 状态持有:以不可变快照形式暴露 UI 所需的状态集合。
- 状态转换:接收用户事件或系统信号,驱动状态变更。
- 业务编排:协调 UseCase / Repository 的调用顺序与策略。
- 跨模块桥接:作为当前模块对外暴露的唯一入口,封装内部逻辑。
不应承担的职责
- 直接操作 View:不持有 View 引用,不调用 TextView.setText() 等 UI 方法。
- 持有 Context / Activity 引用:造成内存泄漏与测试困难。
- 直接执行网络请求或数据库操作:委托给 Repository 或 UseCase 层。
- 处理纯 UI 逻辑:如动画控制、焦点管理、滚动位置等由 View 层自行负责。
2.3 最小暴露原则:对外接口的克制设计
跨模块边界的本质是 信任链路。ViewModel 向外部模块暴露的接口越宽,信任链路越脆弱。最小暴露原则要求:只暴露不可变的 UI 状态和语义化事件,隐藏所有可变实现与内部状态。
公开 vs 隐藏 的边界划分
| 公开(对外模块可见) | 隐藏(模块内部私有) |
|---|---|
| 不可变的 UI 状态(StateFlow / LiveData) | 可变的状态副本(MutableStateFlow) |
| 语义化的用户事件(sealed class / 接口) | 内部回调、监听器注册 |
| 明确的 ViewModel 生命周期回调 | 协程 Job、定时器、缓存池 |
| 模块边界 DTO(数据传输对象) | 内部实体、数据库模型 |
信任链路设计
跨模块调用方只能通过 ViewModel 公开的 不可变状态 和 事件通道 进行交互。调用方不持有 ViewModel 引用内部的可变字段,也不直接访问其协程作用域。信任链路的完整路径为:
UI 层 → 事件密封类 → ViewModel → 状态 StateFlow → UI 层
2.4 生命周期绑定原则:与界面同生共死
ViewModel 的存活范围必须与对应的界面生命周期强绑定。当界面销毁时,ViewModel 必须同步清理所有资源、取消协程、释放缓存,确保不产生悬空引用或状态残留。
生命周期绑定的三个层次
- 创建绑定:ViewModel 在界面首次创建时初始化,由 ViewModelStoreOwner 管理。
- 作用域绑定:通过 viewModelScope 启动的协程,在 ViewModel 清除时自动取消。
- 销毁绑定:onCleared() 回调中释放非协程资源(如监听器、文件句柄、Socket)。
生命周期不绑定的典型风险
| 风险场景 | 表现 | 后果 |
|---|---|---|
| 协程泄漏 | ViewModel 已销毁,协程仍在运行 | 内存泄漏、无意义网络请求、状态更新崩溃 |
| 监听器未注销 | 跨模块回调仍持有 ViewModel 引用 | 内存泄漏、野调用、数据污染 |
| 缓存未释放 | 图片缓存、数据快照残留 | 内存膨胀、界面闪退 |
| 定时任务未取消 | 轮询、延时操作在销毁后继续执行 | 电量消耗、无意义日志、异常崩溃 |
2.5 三原则协同:数据流与控制流
三条原则并非独立运作,而是在同一个 ViewModel 中形成 约束三角:单一职责定义了 ViewModel 的横向边界(做什么),最小暴露定义了纵向边界(露什么),生命周期绑定定义了时间边界(活多久)。三者共同决定了跨模块信任链路的完整形态。
图中展示了三条原则如何从三个维度约束 ViewModel:单一职责 从横向界定职责范围,最小暴露 从纵向限定接口宽度,生命周期绑定 从时间维度确立存活区间。数据层经由 ViewModel 编排后,以不可变状态流向 UI 层,整个过程在三原则的约束框架内完成。
2.6 选型权衡与风险点
原则冲突时的优先级决策
- 生命周期绑定 优先于 单一职责:当职责划分导致 ViewModel 存活时间超出界面生命周期时,必须拆分 ViewModel 而非延长其存活。
- 单一职责 优先于 最小暴露:当职责清晰但接口较多时,应通过聚合接口(Facade 模式)而非合并职责来减少暴露。
- 最小暴露 不优先于 生命周期绑定:即便暴露接口极少,若 ViewModel 生命周期管理不当,仍会导致内存泄漏。
常见反模式与风险
| 反模式 | 表现 | 破坏的原则 | 改进方案 |
|---|---|---|---|
| 万能 ViewModel | 一个 ViewModel 管理多个不相关页面 | 单一职责 | 按页面/功能拆分为多个 ViewModel |
| 全公开 ViewModel | 将 MutableStateFlow 直接暴露为 public | 最小暴露 | 使用 StateFlow 公开,MutableStateFlow 私有 |
| 静态 ViewModel | 使用静态变量持有 ViewModel 实例 | 生命周期绑定 | 由 ViewModelStoreOwner 自然管理 |
| 泄漏的 ViewModel | 在 ViewModel 中持有 Activity 或 Fragment 引用 | 生命周期绑定 | 使用 EventBus / Channel 代替直接引用 |
| 胖接口 ViewModel | 对外暴露数十个方法或属性 | 最小暴露 | 按场景分组为多个小接口或使用状态聚合 |
2.7 落地步骤与验收标准
落地实施步骤
- 职责梳理:为每个 ViewModel 列出其管理的所有 UI 状态和业务编排点,标记超出单一职责的部分并拆分。
- 接口审计:检查 ViewModel 的所有公开属性和方法,将可变状态改为不可变快照,将直接方法调用改为事件密封类。
- 生命周期对齐:确认 ViewModel 的创建与销毁对应正确的 ViewModelStoreOwner(Activity / Fragment / NavBackStackEntry)。
- 资源清理验证:在 onCleared() 中释放所有非协程资源,对 viewModelScope 启动的协程进行超时与取消测试。
- 跨模块边界契约:为跨模块调用的 ViewModel 编写接口契约文档,明确公开状态与事件的范围。
- 自动化检查:通过 lint 规则或单元测试约束 MutableStateFlow 的公开访问、禁止在 ViewModel 中持有 Context 引用。
验收标准清单
| 验收项 | 验收方法 | 通过标准 |
|---|---|---|
| 单一职责 | Review ViewModel 方法列表 | 每个方法只做一件事,不混合 UI 操作与数据操作 |
| 最小暴露 | 检查 public 修饰符 | 无可变状态公开,无内部 Repository 直接暴露 |
| 生命周期绑定 | 单元测试 + 内存监测 | 界面销毁后无协程运行,无泄漏引用 |
| 跨模块信任链路 | 接口契约检查 | 外部模块只通过事件 + 不可变状态与 ViewModel 交互 |
| 资源释放 | onCleared() 覆盖测试 | 所有注册的监听器、定时器、缓存均在 onCleared 中清理 |