ViewModel 纯逻辑实现:不引用任何 UI 框架

4.1 原理概述

纯逻辑 ViewModel 的核心在于:不与任何 UI 框架(Android Activity/Fragment、iOS ViewController、SwiftUI View、React Component 等)产生直接依赖。它只负责两件事:管理应用状态 与 编排业务操作。UI 层通过观察暴露的只读状态流获取数据,通过调用 ViewModel 的方法下发用户意图。整个交互被限制为单向数据流,消除 UI 与逻辑之间的循环耦合。

设计目标:使 ViewModel 可在纯单元测试环境、命令行工具、后台服务中被直接复用,无需模拟任何 UI 上下文。
关键约束: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 的版本简单:

  1. 日志拦截:在 _state.update 前后打印新旧状态快照。
  2. 状态历史板:内部保存最近 10 次状态变更的列表,便于崩溃时 dump。
  3. 单元测试直写:直接创建 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 方法必须清理资源。
纯逻辑 ViewModel 内部结构(单向数据流) UI 层(观察者) 用户动作 UI 订阅 ViewModel 纯逻辑层 业务方法(increment / fetch) 协程 / Rx 作用域 私有 MutableStateFlow 公开 StateFlow (asStateFlow) 核心约束 • 无 UI 框架依赖 • 状态不可变快照 • 外部仅调用方法 / 订阅流

4.8 本章小结