MVVM 迁移到 MVI:复杂页面状态如何收敛

Android 从零到一 MVVM 迁移到 MVI:复杂页面状态如何收敛

当页面同时包含筛选、分页、刷新和错误恢复时,单纯增加 ViewModel 字段往往会让状态彼此打架。本文用一个列表页面说明何时需要从 MVVM 的多状态更新迁移到单向数据流,以及如何控制迁移范围。

问题不是架构名字,而是状态组合

假设一个商品列表页有关键词、分类、排序、分页、加载状态和错误提示。若 ViewModel 分别暴露 items、loading、error、page、filter,页面可能收到互相矛盾的组合:旧列表配新筛选、加载结束却仍显示旧错误,或刷新和加载更多同时发生。

MVVM 本身并不要求状态散落。问题通常出在状态没有明确的单一所有者,以及异步结果没有验证其所属请求。迁移到 MVI 的价值,是把用户意图、状态转移和渲染结果串成可追踪的单向链路,而不是为了给类改名。

先定义页面状态

将页面可见信息聚合成一个不可变状态。筛选条件和列表数据属于稳定状态,正在执行的动作属于加载状态,错误则作为可恢复的界面结果表达。

kotlin 复制代码
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 层反向修改数据。一次性提示可以用有明确消费动作的状态字段,也可以用独立事件流;选择哪种方式要看是否需要在配置变更或进程重建后重放。

用请求代次处理竞态

仅靠单向数据流不能自动解决网络竞态。用户快速切换筛选时,较早发出的请求可能较晚返回并覆盖新结果。让每次查询变更取消前一任务,并在更新状态时确保任务仍然有效:

kotlin 复制代码
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 约定,先完善状态建模和并发控制即可。架构选择的判断标准应是状态变化能否被解释、测试和恢复,而不是模式名称是否流行。

相关推荐
妖星2 小时前
Android 开机动画定制:从 bootanimation.zip 到源码编译
android
临期冰淇淋2 小时前
MTK平台 安卓16 GMS报错修改
android·java·前端
mmsx2 小时前
Android 模块化三件套:轻量组件化、宿主插件双形态、AIDL 隔离
android·网络·组件化·aidl
橙橙笔记3 小时前
QT安卓软件的制作
android·git·qt·软件
mmsx3 小时前
Android 架构分层:MVVM 与 MVP 共存,网络层怎么切
android·策略模式·mvvm·mvp
mmsx4 小时前
Android 本地优先的三级语义理解降级链
android·数码相机·策略模式
朱涛的自习室5 小时前
我让 Gemini 4 当高管、DeepSeek 当员工,开了一家软件公司(Day 1)
android·前端·人工智能
事圆则缓5 小时前
Android 中的 Socket 和 WebSocket:从一条聊天消息看清区别
android·websocket·网络协议
mmsx6 小时前
Android 流式对话实现:分层门面与 SSE 手工解析
android·github·sse