第2章 核心原则与边界定义:单一职责、依赖倒置、接口隔离、ViewModel无UI依赖的契约设计
在将 ViewModel 与 UI 夹具分离的实践中,必须遵守四项核心设计原则:单一职责、依赖倒置、接口隔离以及ViewModel 无 UI 依赖的契约设计。这些原则共同定义了架构的边界,确保 ViewModel 保持纯净、可测试、可替换。本章逐一解析每个原则的实质,并给出具体的契约边界定义。
核心目标:通过契约使 ViewModel 完全不感知 UI 实现细节(Activity、Fragment、Compose、SwiftUI 等),任何 UI 夹具都必须适配 ViewModel 定义的抽象接口。
2.1 单一职责原则(SRP)
每个模块只有一个引起变化的原因。ViewModel 的职责仅限于维护 UI 状态和驱动业务逻辑,不处理生命周期事件、不持有 View 引用、不执行 IO 或网络操作。UI 夹具(如 Adapter、Binder)仅负责将 ViewModel 的状态映射到 UI 渲染。
| 模块 | 单一职责 | 禁止行为 |
|---|---|---|
| ViewModel | 管理 UI 状态、调用 UseCase | 直接引用 View 类、操作 UI 线程锁 |
| UI夹具(Activity/Binder) | 订阅状态、更新视图 | 执行耗时计算、持有 ViewModel 强引用 |
| Repository | 数据源调度 | 暴露 LiveData 给 ViewModel 之外的层 |
边界定义:ViewModel 不应导入任何
android.view 或 android.widget 包下的类;UI 夹具不应调用 ViewModel 的私有方法。
2.2 依赖倒置原则(DIP)
高层模块不应依赖低层模块,二者都应依赖抽象。在分离实践中,ViewModel 是高层模块,UI 夹具和数据源是低层模块。ViewModel 通过定义纯抽象接口(契约)来解耦:
- 输入契约:ViewModel 对外暴露
StateFlow/LiveData/ 回调接口,UI 夹具只订阅这些流。 - 输出契约:ViewModel 依赖的 UseCase 或 Repository 必须是通过构造函数注入的抽象接口,而非具体实现。
// ViewModel 无 UI 依赖的契约设计示例
class UserViewModel(
private val fetchUserUseCase: FetchUserUseCase // 抽象接口
) : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
// 不依赖任何 UI 类
}
常见错误:在 ViewModel 内直接调用
viewModelScope.launch(Dispatchers.Main) 并不违反 DIP,但若在 launch 中访问 Context 或 View 则违反契约。使用 Dispatchers.IO 需要由 UseCase 层决定,ViewModel 不应硬编码调度器。
2.3 接口隔离原则(ISP)
不强迫模块依赖它们不需要的接口。ViewModel 不应暴露一个庞大的“万能状态对象”,而应按需拆分:
- 消费者隔离:UI 夹具只订阅它需要的子状态(如只读
state: StateFlow<List<Item>>,而非整个 ViewModel)。 - 生产者隔离:ViewModel 只依赖它实际调用的 UseCase 方法,而非整个 UseCase 接口。若 UseCase 有多个方法,应拆分为多个小接口。
| 正确做法 | 错误做法 |
|---|---|
|
|
实践技巧:为每个 UseCase 定义独立的接口,即使只有一个方法。这样 ViewModel 的构造函数只注入它需要的微小接口,测试时也只需 mock 单一行为。
2.4 ViewModel 无 UI 依赖的契约设计
这是上述三条原则在 ViewModel 层面的统一约束。契约设计要求:
- ViewModel 的所有公开属性(
val)和公开函数(fun)必须是对 UI 无偏见的 —— 不返回View、Fragment、Context、Drawable等 UI 类型。 - ViewModel 的构造参数只能包含抽象接口或纯数据对象。不允许直接传递 Android 组件引用。
- ViewModel 内部不能使用
android.os.Handler、android.app.Application(除非注入为抽象接口)等。 - UI 夹具必须通过
observe或collect订阅状态流,不得调用 ViewModel 的私有方法或反射修改状态。
下面通过内联 SVG 展示四个原则如何构成 ViewModel 与 UI 夹具之间的清晰边界:
2.5 边界定义总结
下面表格将四个原则映射为具体的代码级边界,便于在 Code Review 和自动化检查中落地:
| 原则 | 边界规则 | 违反示例 | 正确做法 |
|---|---|---|---|
| 单一职责 | ViewModel 不处理生命周期事件 | onCleared() 中执行大量清理 |
仅释放自己持有的资源 |
| 依赖倒置 | 构造函数只接收抽象接口 | class VM(val repo: UserRepoImpl) |
class VM(val repo: IUserRepo) |
| 接口隔离 | 每个接口方法数 ≤ 3 | 一个 UseCase 包含 CRUD 所有方法 | 拆分为单个操作的小接口 |
| 无 UI 依赖契约 | 公开属性类型非 UI 类 | val bitmap: Bitmap |
val state: StateFlow<Bitmap?> |
调试与检查:在编译层面可以使用 Lint 规则禁止 ViewModel 中导入
android.view 和 android.widget 包;在运行时可以通过 ViewModelStoreOwner 的构造检测来确保 ViewModel 非 UI 依赖。