第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 有多个方法,应拆分为多个小接口。
正确做法错误做法
interface FetchUserUseCase {
    suspend fun invoke(): User
}
interface UserService {
    suspend fun getUser(): User
    suspend fun saveUser(u: User)
    suspend fun deleteUser(id: String)
    // … 更多无关方法
}
实践技巧:为每个 UseCase 定义独立的接口,即使只有一个方法。这样 ViewModel 的构造函数只注入它需要的微小接口,测试时也只需 mock 单一行为。

2.4 ViewModel 无 UI 依赖的契约设计

这是上述三条原则在 ViewModel 层面的统一约束。契约设计要求:

  1. ViewModel 的所有公开属性(val)和公开函数(fun)必须是对 UI 无偏见的 —— 不返回 View、Fragment、Context、Drawable 等 UI 类型。
  2. ViewModel 的构造参数只能包含抽象接口或纯数据对象。不允许直接传递 Android 组件引用。
  3. ViewModel 内部不能使用 android.os.Handler、android.app.Application(除非注入为抽象接口)等。
  4. UI 夹具必须通过 observe 或 collect 订阅状态流,不得调用 ViewModel 的私有方法或反射修改状态。

下面通过内联 SVG 展示四个原则如何构成 ViewModel 与 UI 夹具之间的清晰边界:

ViewModel 与 UI 夹具分离原则结构图 UI 夹具 (View) Activity / Fragment 契约接口 StateFlow / Callback ViewModel 状态管理 + 业务编排 抽象接口 (UseCase) FetchUserUseCase 具体实现 UserRepositoryImpl 订阅状态 依赖抽象 调用接口 注入实现 SRP: 单一职责 DIP: 依赖倒置 ISP: 接口隔离 无 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 依赖。