[Android 从零到一] MVVM 迁移到 MVI:复杂页面状态如何收敛
当页面同时包含筛选、分页、刷新和错误恢复时,单纯增加 ViewModel 字段往往会让状态彼此打架。本文用一个列表页面说明何时需要从 MVVM 的多状态更新迁移到单向数据流,以及如何控制迁移范围。
问题不是架构名字,而是状态组合
假设一个商品列表页有关键词、分类、排序、分页、加载状态和错误提示。若 ViewModel 分别暴露 items、loading、error、page、filter,页面可能收到互相矛盾的组合:旧列表配新筛选、加载结束却仍显示旧错误,或刷新和加载更多同时发生。
MVVM 本身并不要求状态散落。问题通常出在状态没有明确的单一所有者,以及异步结果没有验证其所属请求。迁移到 MVI 的价值,是把用户意图、状态转移和渲染结果串成可追踪的单向链路,而不是为了给类改名。
先定义页面状态
将页面可见信息聚合成一个不可变状态。筛选条件和列表数据属于稳定状态,正在执行的动作属于加载状态,错误则作为可恢复的界面结果表达。
data class ProductUiState(
val query: String = "",
val categoryId: String? = null,
val sort: Sort = Sort.Recommended,
val items: List<Product> = emptyList(),
val nextPage: Int = 1,
val isRefreshing: Boolean = false,
val isAppending: Boolean = false,
val endReached: Boolean = false,
val message: String? = null,
)
sealed interface ProductIntent {
data class QueryChanged(val value: String) : ProductIntent
data class CategorySelected(val id: String?) : ProductIntent
data class SortSelected(val sort: Sort) : ProductIntent
data object Refresh : ProductIntent
data object LoadNextPage : ProductIntent
data object MessageConsumed : ProductIntent
}
UI 只订阅一个 StateFlow<ProductUiState> 并发送意图。状态更新始终基于旧状态生成新值,不从 View 层反向修改数据。一次性提示可以用有明确消费动作的状态字段,也可以用独立事件流;选择哪种方式要看是否需要在配置变更或进程重建后重放。
用请求代次处理竞态
仅靠单向数据流不能自动解决网络竞态。用户快速切换筛选时,较早发出的请求可能较晚返回并覆盖新结果。让每次查询变更取消前一任务,并在更新状态时确保任务仍然有效:
private var searchJob: Job? = null
private fun search(query: String, categoryId: String?, sort: Sort) {
searchJob?.cancel()
searchJob = viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true, message = null) }
runCatching { repository.search(query, categoryId, sort) }
.onSuccess { result ->
_uiState.update {
it.copy(
items = result.items,
nextPage = result.nextPage,
endReached = result.endReached,
isRefreshing = false,
isAppending = false,
)
}
}
.onFailure { error ->
if (error is CancellationException) throw error
_uiState.update {
it.copy(isRefreshing = false, isAppending = false, message = error.toUserMessage())
}
}
}
}
生产代码还应确保 Repository 的取消能够传递到底层请求;如果数据源不支持取消,可给每次查询分配递增序号,只允许最新序号提交结果。不要吞掉 CancellationException,否则上游取消可能被错误地当作普通失败。
将分页规则集中在状态所有者
LoadNextPage 应在正在刷新、正在追加或已经到末页时忽略。追加成功后合并数据并推进页码;失败时保留已有列表,避免一次网络错误清空用户正在看的内容。相同商品是否去重、刷新期间旧数据是否继续展示,也应作为明确的产品规则,而非散落在 Adapter 中的补丁。
这一点体现了 MVI 的实际收益:状态转移可以围绕意图集中实现并单测,而不必在多个回调中猜测当前组合是否合理。但如果页面只有简单加载和空态,使用一个清晰的 UiState 的 MVVM 也完全足够,不需要额外引入 reducer 框架。
渐进迁移,不重写整个模块
迁移可分成三步:先把 ViewModel 的多个可变字段收敛成一个不可变 UI 状态;再把页面回调整理成有限的意图入口;最后把重复的异步转移抽成私有函数或 reducer,并补上并发与失败测试。Repository、导航和现有列表组件通常无需同时改造。
测试优先覆盖状态转移边界:快速变更筛选时旧请求不能覆盖新结果;刷新失败时旧列表仍然存在;追加失败不会推进页码;末页之后不会继续请求;重复点击加载不会并发发出重复任务。使用协程测试工具控制调度和请求完成顺序,比依赖真实网络更稳定。
什么时候值得采用
当多个异步动作会互相影响、状态组合难以推理、线上问题无法还原具体转移路径时,单向数据流通常能降低维护成本。若页面状态简单、团队已经有成熟且一致的 MVVM 约定,先完善状态建模和并发控制即可。架构选择的判断标准应是状态变化能否被解释、测试和恢复,而不是模式名称是否流行。