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 初始化并由业务逻辑驱动变化。
- 管理器:额外的职责如缓存控制、垃圾回收、跨屏状态协调等,这些属于更高级的架构组件(如 UseCase、Repository)范畴。
4.3 提升后状态层级与数据流
状态提升后,数据的流动方向发生根本变化:
控制流(View → ViewModel):用户事件(点击、输入、手势)由 View 层捕获,通过 ViewModel 提供的函数接口发送至 ViewModel。此时状态只能在函数调用结束后才发生变更。
数据流(ViewModel → View):ViewModel 通过响应式状态对象(StateFlow、LiveData)向下推送不可变的状态快照。View 层通过 collect / observe 订阅这些状态,并重新渲染 UI。
信任链路:信任链路由 ViewModel 单向控制。View 只能通过 ViewModel 的公开接口发送意图,无法直接修改 ViewModel 内部状态。ViewModel 确保状态在变更时触发一致性更新。
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 落地步骤
- 识别状态归宿:列出现所有驱动 UI 的状态,标注其生命周期(瞬时/页面/全局)。只有非瞬时的状态才提升到 ViewModel。
- 定义状态容器接口:ViewMoedl 暴露为不可变状态对象,并提供意图函数(fun onEvent(event: UiEvent)),View 层只是调用接口。
- 汇总子状态:将关联的原子状态组合为 UI State 数据类,确保数据类只包含 View 层需要的字段。
- 实现复用场景:对于跨 View 共享的状态(如全局搜索),将 ViewModel 作用域提升到父级 Activity 或 Navigation 图级别。
- 验证单一状态源:在测试层断言:同一 UI 状态在任何时刻只能有一个来源。
4.7 验收标准
- ViewModel 内的状态对象内部不存在冗余或重复字段。
- 所有需要旋转配置、后台恢复的状态都已经提升到 ViewModel(或更上层)。
- View 层不直接修改状态:唯一路径是调用的意图函数。
- 共享状态的子组件使用派生流订阅,不直接暴露完整状态流。
- 单元测试可以独立模拟 ViewModel 所有状态的转换逻辑,无需依赖真实 View。