ViewModel 纯逻辑实现:不引用任何 UI 框架
4.1 原理概述
纯逻辑 ViewModel 的核心在于:不与任何 UI 框架(Android Activity/Fragment、iOS ViewController、SwiftUI View、React Component 等)产生直接依赖。它只负责两件事:管理应用状态 与 编排业务操作。UI 层通过观察暴露的只读状态流获取数据,通过调用 ViewModel 的方法下发用户意图。整个交互被限制为单向数据流,消除 UI 与逻辑之间的循环耦合。
设计目标:使 ViewModel 可在纯单元测试环境、命令行工具、后台服务中被直接复用,无需模拟任何 UI 上下文。
关键约束:ViewModel 内部不得 import 任何框架的 View 基类、生命周期类、上下文对象。
关键约束:ViewModel 内部不得 import 任何框架的 View 基类、生命周期类、上下文对象。
4.2 状态容器与不可变快照
状态管理采用 单一状态源 模式:ViewModel 持有一个私有可变状态容器,对外仅暴露只读的异步流。每次状态更新都生成新快照,保证外部观察者不会收到可变引用。
| 角色 | 类型 | 对齐方案 |
|---|---|---|
| 私有状态 | MutableStateFlow<State> / BehaviorSubject<State> | 唯一写入入口 |
| 公开流 | StateFlow<State> / Observable<State> | 只读,不暴露写方法 |
| 状态快照 | data class / struct (只含值类型) | 每次 copy() 后发布 |
| 无关时间点 | 初始值、replay 1 | 新订阅者立即获得最新状态 |
4.3 实现流程与代码结构
下面以纯 Kotlin 协程 + StateFlow 为例,演示一个不依赖 Android 框架的 CounterViewModel。iOS / Flutter / 前端可套用相同模式,仅替换反应式库。
// ---------- 状态定义 ----------
data class CounterState(
val count: Int = 0,
val isLoading: Boolean = false,
val error: String? = null
)
// ---------- ViewModel 纯逻辑实现 ----------
class CounterViewModel {
// 私有的可变状态
private val _state = MutableStateFlow(CounterState())
// 外部只读状态流
val state: StateFlow<CounterState> = _state.asStateFlow()
// 内部协程作用域(纯逻辑,不依赖 Lifecycle)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun increment() {
// 不可变快照更新
_state.update { it.copy(count = it.count + 1) }
}
fun decrement() {
_state.update { it.copy(count = it.count - 1) }
}
fun fetchRemoteCount() {
scope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val newCount = SomeRemoteService.getCount() // 纯数据层
_state.update { it.copy(count = newCount) }
} catch (e: Exception) {
_state.update { it.copy(error = e.message) }
} finally {
_state.update { it.copy(isLoading = false) }
}
}
}
// 清理:外部应主动调用(如 onCleared 等效方法)
fun dispose() {
scope.cancel()
}
}
平台无关提示:Swift 端使用
@Published + CurrentValueSubject,Dart 端使用 ValueNotifier + 流转换器,原理完全一致。关键点是将「UI 生命周期绑定」与「逻辑生命周期」彻底解耦。
4.4 业务逻辑组织原则
- 每个公开方法代表一个用户意图:increment、submitForm、loadUserProfile,而非 getState、setCount。
- 异步操作统一由协程/Rx 作用域管理:ViewModel 内部创建独立作用域,不依赖 Activity/ViewController 的生命周期,避免泄漏。
- 异常处理内聚到 ViewModel:将错误转化为状态字段(error: String?),UI 层只需订阅渲染,无需 try-catch。
- 禁止使用 UI 框架的调度器:例如 Android 的
MainScope或 iOS 的DispatchQueue.main应由外层注入,或通过纯抽象调策略。
典型反模式:在 ViewModel 中保存 Activity/Context 或调用
LiveData.setValue() 但未解绑。这会使 ViewModel 耦合 UI 生命周期,导致单元测试困难、内存泄漏。纯逻辑 ViewModel 应只持有值对象和协程/Rx Disposable。
4.5 调试与快照验证
纯逻辑 ViewModel 的调试比耦合 UI 的版本简单:
- 日志拦截:在
_state.update前后打印新旧状态快照。 - 状态历史板:内部保存最近 10 次状态变更的列表,便于崩溃时 dump。
- 单元测试直写:直接创建 ViewModel 实例,调用方法并断言
state.value是否符合预期,无需启动模拟器。
// 调试辅助(可选,不会引入 UI 依赖)
class DebugCounterViewModel(private val logger: (String) -> Unit) : CounterViewModel() {
init {
// 监听自有状态变化
state.onEach { newState ->
logger("[State] count=${newState.count}, loading=${newState.isLoading}, error=${newState.error}")
}.launchIn(scope) // 需注意不要产生额外订阅泄漏
}
}
4.6 常见错误与规避
| 错误 | 后果 | 解法 |
|---|---|---|
在 ViewModel 内部使用 GlobalScope | 协程无法回收,内存泄漏 | 使用独立 CoroutineScope,并在销毁时 cancel |
将 MutableStateFlow.value 暴露为可写 | 外部可以直接修改状态,破坏数据流一致性 | 始终使用 asStateFlow() 或 share(replay: 1) |
| 状态类包含可变字段(如 ArrayList) | 外部可能修改内部状态而不触发更新 | 使用 data class 或不可变集合,每次 copy 后重新赋值 |
| 业务方法中直接调用 UI 线程切换 | ViewModel 与特定调度器耦合,无法跨平台 | 仅暴露结果状态,由 UI 层决定观察线程 |
4.7 性能与稳定性优化
- 状态去重:使用
distinctUntilChanged或协程的filterNotNull,避免重复相同状态导致 UI 频繁渲染。 - 批量更新:如果多个业务操作需要连续修改状态,使用
update的原子操作或一次修改多个字段,减少中间快照数量。 - 协程取消传播:在
fetchRemoteCount等挂起函数中使用ensureActive()或isActive检查,及早响应取消。 - 内存控制:ViewMode l 实例由外部工厂管理(如纯 DI 容器),不要驻留在静态变量中;dispose 方法必须清理资源。