4. State Hoisting 与 ViewModel 的关系:提升后状态层级与组合复用

4.1 架构边界与分层职责

在 MVVM 架构中,ViewModel 是 View 与 Model 之间的桥梁,其职责定义决定了 State Hoisting 的边界。State Hoisting 的本质是将状态从 View(通常是 Composable、Fragment 或 ViewController)提升到 ViewModel 中,从而实现状态的持久化与生命周期解耦。

层次 职责 状态存活期 State Hoisting 作用
View 层 界面渲染、用户事件捕获、UI 状态绑定 与界面生命周期一致 仅持有局部的、不可持久化的瞬时状态
ViewModel 层 状态容器、业务逻辑编排、数据转换、状态分发 比 View 更长(与 Navigation 生命周期绑定) 接收所有需要持久化或跨态的状态
Model 层 数据来源(网络、数据库、本地存储) 全局 不直接参与 Hoisting,但作为下游数据提供者

在架构边界上:State Hoisting 的核心决策是“哪些状态应该跨过 View 与 ViewModel 的边界”。凡是需要在界面旋转、配置变更或短暂后台后仍保持的状态,必须提升到 ViewModel。凡是仅与当前界面可见区域相关的临时交互状态(如动画进度、滚动位置),可以保留在 View 内。

4.2 ViewModel 作为状态容器的核心职责

ViewModel 作为状态容器,并非简单存储状态,而是承担以下核心职责:

  • 单一状态源:同一组相关的状态数据(如用户信息、列表数据、加载标识)在 ViewModel 内由唯一的状态对象承载,避免分散到多个视图组件导致状态不一致。
  • 状态不可变性:ViewModel 暴露的状态应为不可变类型,通过副本替换而非原地修改的方式更新状态,保证数据流的可追踪性。
  • 状态汇总与分发:ViewModel 可以将多个分散的源状态组合为一个 UI 状态对象,通过 StateFlow / LiveData 向 View 层分发。
关键认知: ViewModel 不是状态的管理器,而是状态的容器。其核心区分在于:
  • 容器:负责存放、汇总、分发状态,状态由 ViewModel 初始化并由业务逻辑驱动变化。
  • 管理器:额外的职责如缓存控制、垃圾回收、跨屏状态协调等,这些属于更高级的架构组件(如 UseCase、Repository)范畴。

4.3 提升后状态层级与数据流

状态提升后,数据的流动方向发生根本变化:

控制流(View → ViewModel):用户事件(点击、输入、手势)由 View 层捕获,通过 ViewModel 提供的函数接口发送至 ViewModel。此时状态只能在函数调用结束后才发生变更。

数据流(ViewModel → View):ViewModel 通过响应式状态对象(StateFlow、LiveData)向下推送不可变的状态快照。View 层通过 collect / observe 订阅这些状态,并重新渲染 UI。

信任链路:信任链路由 ViewModel 单向控制。View 只能通过 ViewModel 的公开接口发送意图,无法直接修改 ViewModel 内部状态。ViewModel 确保状态在变更时触发一致性更新。

State Hoisting 之后的状态层级与数据流 View 层 UI 组件(Composable / Fragment) 接收状态 (collect / observe) 发送事件 (调用 ViewModel 函数) ViewModel【状态容器】 单一状态源(不可变状态对象) 状态汇总与分发(StateFlow / LiveData) 事件处理 → 状态转换 生命周期感知(viewModelScope) Model 层(数据来源) Repository / UseCase / DataSource 不直接参与 Hoisting,只作为下游数据提供者 数据流 (状态推送) 控制流 (事件/意图) 查询/写入 信任链说明:View 层仅通过 ViewModel 公开的意图函数传递事件, ViewModel 内部的私有状态状态变更不暴露给 View,确保状态单一源头且不可被外部篡改。

4.4 组合与复用

State Hoisting 到 ViewModel 后,状态的组合与复用遵循以下设计原则:

状态组合:当多个不同的 View 组件需要共享同一份状态时,ViewModel 作为组合点聚合所有子状态。例如表格列表与筛选面板共用一个

data class FilterState(
    val keyword: String,
    val categoryId: Int?,
    val sortOrder: SortOrder
)

ViewModel 存放该组合状态,两个子组件各自订阅需要的子字段。

状态复用:同一 ViewModel 可以被多个 View 实例共享(通过作用域内唯一性保证)。典型场景如底部导航栏切换 Fragment 时,各 Fragment 共享同一个 ViewModel,避免了状态冗余。

组合优先级:状态组合应在 ViewModel 层完成,而不是在 View 层由多个 ViewModel 的结果拼接。这保持了状态层的集中管理,减少了 View 层的协调逻辑。

常见陷阱:
  • “过度提升”:将仅属于局部动画或触摸手势的瞬时状态提升到 ViewModel,导致 ViewModel 状态频繁变化且生命周期不匹配。
  • “重复容器”:多个 ViewModel 持有同一个冗余状态副本,破坏了单一状态源。应通过依赖注入或共享 ViewModel 模式解决。
  • “组合遗漏”:子组件需要但父级 ViewModel 未暴露的状态,导致子组件去订阅与自己无关的数据,增加了不必要的订阅和重组。

4.5 选型权衡:组合 vs 继承

在状态组合的实现上,存在两种常见选型:

策略 描述 适用场景 风险点
组合(推荐) ViewModel 持有状态对象,通过标准化接口暴露子状态选择器(StateFlow 派生流) 子组件高度独立,仅需部分数据;状态结构稳定 派生流过多时需注意自动关闭
继承(谨慎使用) 基类 ViewModel 定义通用的状态字段,子类继承并扩展 多级相似的 ViewModel 共享结构 增加层级耦合,修改通用字段易引发连锁影响

落地建议:在 85% 的常规业务中,组合方式优于继承。继承仅在跨模块的基础 ViewModel 组件(如基础的列表加载状态)时才是更优解。

4.6 落地步骤

  1. 识别状态归宿:列出现所有驱动 UI 的状态,标注其生命周期(瞬时/页面/全局)。只有非瞬时的状态才提升到 ViewModel。
  2. 定义状态容器接口:ViewMoedl 暴露为不可变状态对象,并提供意图函数(fun onEvent(event: UiEvent)),View 层只是调用接口。
  3. 汇总子状态:将关联的原子状态组合为 UI State 数据类,确保数据类只包含 View 层需要的字段。
  4. 实现复用场景:对于跨 View 共享的状态(如全局搜索),将 ViewModel 作用域提升到父级 Activity 或 Navigation 图级别。
  5. 验证单一状态源:在测试层断言:同一 UI 状态在任何时刻只能有一个来源。

4.7 验收标准

  • ViewModel 内的状态对象内部不存在冗余或重复字段。
  • 所有需要旋转配置、后台恢复的状态都已经提升到 ViewModel(或更上层)。
  • View 层不直接修改状态:唯一路径是调用的意图函数。
  • 共享状态的子组件使用派生流订阅,不直接暴露完整状态流。
  • 单元测试可以独立模拟 ViewModel 所有状态的转换逻辑,无需依赖真实 View。
质量指标:State Hoisting 后的 ViewModel 状态变更数应少于或等于界面实际需要更新的次数。当状态变更与界面重绘次数不匹配时,应检查是否存在重复订阅或不必要的合并。