☰
Android状态管理升级:MVI如何用单向数据流根治MVVM的混乱
2026/9/28 5:47:24 网站建设 项目流程

先说个真实项目里的故事。前年我负责一个电商App的购物车模块,用的是当时很主流的 MVVM:ViewModel 里放了六个 LiveData,UI 层通过调用submitOrder()、applyCoupon()这类方法直接操作数据。功能倒是上得飞快,结果上线两个月后出现了一个让我头疼了很久的 Bug——用户快速连点“提交订单”,下单成功页偶尔会展示上一次订单的优惠券信息,问题随机、复现率极低。排查了一周多,最后定位到是两次网络请求回包顺序颠倒后,ViewModel 中间的临时字段被污染了。

那次之后我开始认真研究 MVI架构,并且把自己负责的模块全部迁了过去。今天这篇我不打算讲理论黑话,只说说这个架构到底解决了哪些真实问题,为什么它能从根上降低状态混乱的概率,以及它有哪些代价、哪些坑。适合正在用 MVP/MVVM、对状态管理感到头疼的团队参考。

1. MVI和MVVM到底差在哪:一条完整数据流的对比实战

1.1 它其实不是“新架构”,而是状态管理思路的一次回归

MVI 全称 Model-View-Intent,如果往前追溯,这套思路和前端 Redux、Elm 的那一套说白了是同源:单向数据流、不可变状态、统一的动作入口。在 Android 上,它把原来 MBVVM 里“ViewModel 提供一堆方法、UI 随意调用”的自由交互方式,收束成了一条闭环:

UI 层把用户动作转换成Intent,发送给 ViewModel;ViewModel 把Intent和当前不可变的State交给Reducer函数,计算出一个新的State;UI 只负责渲染State,唯一的外部动作就是把Intent发出去。画成文字就是:View → Intent → ViewModel → Reducer → State → View。

MVVM 最让我难受的一点,是交互“姿势”太随意了。同一个页面的状态,可能被 ViewModel 里五个不同的方法修改,有些方法直接改MutableLiveData,有些方法还带回调。这种自由在页面简单的时候无所谓,一旦页面复杂起来,会出现两个致命问题:状态更新点极度分散,改动不可追踪。你看到 Loading 消失了,却很难说清楚是哪次调用把它关掉的。

MVI 的做法是故意制造一点“不方便”:你想改数据?可以,但只能通过 Intent 来触发,而且最终一切状态变化都收敛到一个 Reducer 函数里。这个函数永远不被允许对状态做“直接修改”,只负责根据旧状态生成新状态。凡是经历过线上疑难 Bug 的工程师,都会觉得这种设计非常“香”。

1.2 用购物车页面把两种架构摆在桌子上对比

拿最常见的“购物车改数量”场景。MVVM 的写法一般是:

// MVVM:View 层直接调用 ViewModel 的方法 fun onAddButtonClick(itemId: Long) { viewModel.increaseQuantity(itemId) }

ViewModel 内部可能这样实现:

class CartViewModel : ViewModel() { private val _itemsLiveData = MutableLiveData<List<CartItem>>(emptyList()) fun increaseQuantity(itemId: Long) { _itemsLiveData.value = _itemsLiveData.value?.map { item -> if (item.id == itemId) item.copy(quantity = item.quantity + 1) else item } } }

这段代码本身没问题,问题在于“只是其中一种改法”。页面上还有选择优惠券、切换地址、库存校验失败重置等逻辑,这些乱七八糟的状态修改散落在各个方法中,每个方法都是一条独立的“搅动状态”的路径。

换成 MVI 之后,View 层变成了这个样子:

// View:不管什么操作,统一发送 Intent fun onAddButtonClick(itemId: Long) { viewModel.dispatch(CartIntent.IncreaseQuantity(itemId)) }

ViewModel 拿到 Intent,执行的是纯函数 Reducer,不直接访问网络、不直接发事件:

fun cartReducer(state: CartUiState, intent: CartIntent): CartUiState = when (intent) { is CartIntent.IncreaseQuantity -> { val newItems = state.items.map { item -> if (item.id == intent.itemId) item.copy(quantity = item.quantity + 1) else item } state.copy(items = newItems, totalPrice = calculateTotalPrice(newItems)) } else -> state }

两种写法的差别,用一张表格看更清楚:

对比维度MVVMMVI
状态可变性可变,多个字段都可被修改不可变,每次生成新对象
View 层交互方式调用任意方法、回调、直接赋值只能发送 Intent
状态变化位置分散在 ViewModel 多个函数收敛到唯一 Reducer
副作用处理方法和赋值间随意夹杂独立 Effect 通道
单元测试成本需要 mock LiveData、仓库、调度器纯函数直接喂参数断言输出

1.3 为什么“唯一入口”这么值钱

很多刚接触 MVI 的人觉得“把方法改成 Intent 就是脱裤子放屁,反正都要响应”。但这个唯一入口真正的价值,在于让状态变化变得可观测、可追踪、可回放。

MVVM 里如果线上出现了一个“很奇怪的状态”,你能拿到的信息只有用户操作日志和崩溃堆栈,堆栈往往指向一个无关紧要的 setter。MVI 里,我可以把每次 Intent 和 Reducer 前后的 State 变化都打印到日志,拿到线上信息后直接按时间线回放,所有状态变化一目了然。这就是我后面会说“调试体验翻倍”的根本原因所在。

2. MVI 的四个核心机制:State、Intent、Reducer、Effect

2.1 State:把页面外观建模成一个不可变快照

MVI 中最核心的概念就是 State。它把“页面此刻应该展示成什么样”完整地建模成一个数据类,例如:

data class CartUiState( val items: List<CartItem> = emptyList(), val isLoading: Boolean = false, val isSubmitting: Boolean = false, val totalPrice: Long = 0L, val error: UiErrorMessage? = null, )

注意几个硬性约束:所有字段都得是val,不能提供任何公开的 setter;更新状态时用copy()生成新实例;不能把Toast、跳转这类一次性事件直接塞进来。这种不可变性带来的最大好处,可以用一个生活化类比理解——MVVM 的状态像一块公用白板,谁都能拿起笔改,而且改完不留痕迹;MVI 的状态像一叠画纸,每次修改都拿一张新纸,旧纸整整齐齐留在抽屉里,随时可以翻出来看当时的样子。

在并发场景下,这个特性尤其重要。网络请求回包顺序颠倒导致的竞态,本质就是“一个可变对象被多个回调同时改”。MVI 用不可变 State 强行让每一次改动都成为一个独立的值,即使回包乱序,最终结果不过是“先 reduce 一次,后 reduce 一次”的顺序交换,不会再出现对象实例被某个回调偷偷改掉字段这种不可控情况。

2.2 Intent:把用户动作和异步结果都翻译成统一令牌

Intent 通常用sealed interface定义,它代表“用户或系统想做的一件事情”。页面加载、点击加号、网络请求成功、失败,全部可以建模成 Intent:

sealed interface CartIntent { data object Load : CartIntent data class IncreaseQuantity(val itemId: Long) : CartIntent data class DecreaseQuantity(val itemId: Long) : CartIntent data class ApplyCoupon(val code: String) : CartIntent data class SubmitOrder : CartIntent data class SubmitOrderSuccess(val orderId: String) : CartIntent data class SubmitOrderFailed(val message: String) : CartIntent }

一开始我容易犯的毛病是:只把用户点击操作建模成 Intent,网络请求回来之后直接改State。这样做的结果是状态变化又有两个源头。正确的做法是把异步结果也建模成 Intent:请求发出时发一个SubmitOrder,让 State 进入isSubmitting = true;成功回来发SubmitOrderSuccess,失败发SubmitOrderFailed。这样,任何一个 State 的变化都能找到一个对应的 Intent,相互之间有唯一的因果链。

2.3 Reducer:唯一合法的状态变更地点,且必须是纯函数

Reducer 是 MVI 的“裁判员”。它决定“这个 Intent 对当前 State 应该产生什么影响”,但绝不允许亲自去执行副作用。如果看到有人在 Reducer 里直接调 Retrofit 或者写Log,Profile Review 应该直接打回。

为什么这么严格?因为一旦 Reducer 不纯,它就会变成“又一段不受控制的状态修改代码”,整个 MVI 的预测性就垮了。正确的副作用处理方式,我在项目里总结为“ViewModel 负责指挥,Reducer 负责计算”:

class CartViewModel : ViewModel() { private val _state = MutableStateFlow(CartUiState()) val state: StateFlow<CartUiState> = _state.asStateFlow() private val _effect = MutableSharedFlow<CartEffect>() val effect: SharedFlow<CartEffect> = _effect.asSharedFlow() fun dispatch(intent: CartIntent) { when (intent) { is CartIntent.SubmitOrder -> { _state.value = cartReducer(_state.value, CartIntent.SubmitOrder) fetchOrderResult() // 副作用交给 suspend 函数处理 } else -> _state.value = cartReducer(_state.value, intent) } } private suspend fun fetchOrderResult() { val result = runCatching { orderRepository.submit() } val nextIntent = result.fold( onSuccess = { CartIntent.SubmitOrderSuccess(it.orderId) }, onFailure = { CartIntent.SubmitOrderFailed(it.message ?: "未知错误") } ) _state.value = cartReducer(_state.value, nextIntent) } }

这里的关键体验是:只要 Reducer 保持纯函数,你的测试就完全不需要 mock 网络仓库,也不需要处理协程调度器和 LiveData 生命周期,直接输入旧 State + Intent,断言新 State,稳定、快速、可靠。

2.4 Effect:一次性事件要走独立通道,别硬塞进 State

State 是“持续存在”的,比如isLoading、items、error。但“弹出 Toast”“跳转到订单详情页”这种是一次性的,它们没有“持续状态”的含义。如果把一次性事件做成 State 字段,当系统因为配置变更或内存回收触发状态保存恢复时,已经提示过的错误会出现第二次,体验上就变成“横竖屏切换就重复弹 Toast”。

我在项目里的处理办法是:所有持续状态放到StateFlow,所有一次性事件放到MutableSharedFlow,例如:

sealed interface CartEffect { data class ShowToast(val message: String) : CartEffect data object NavigateToOrderDetail : CartEffect }

UI 层收集两侧:state负责渲染,effect负责触发导航和提示。这样既保住了单向数据流的严谨性,又不会污染 State 模型。

3. 为什么值得引入 MVI:三个让我回不去的理由

3.1 可预测性:屏幕上每个像素变化都有唯一归因

用上 MVI 之后最直观的体感,是页面不再会“无缘无故”变化。MVVM 阶段,线上用户反馈“点提交后总价闪了一下”,我得在 ViewModel 里四处找可能修改总价的代码;现在,我只需要把 Logcat 里打印的 State 变化记录拉出来,看看总价变化前后的 State 和触发它的 Intent,问题原因直接就写在日志里。

我平时只记录 Intent 事件名和 Diff 前后的 State,不打印整个页面数据,因为商品列表很长,全量打印会刷爆日志。这也是一个很实用的经验:别为了调试把超大 State 直接打印,否则日志文件会像流水一样失控,正确的做法是比较旧 State 与新 State 的差异,只记录差异字段。

3.2 可测试性:给输入看输出,写单测终于不用造一堆 Mock

这是 MVI 最吸引我的一个点。Reducer 是纯函数,单测可以写成最简单的“给输入,看输出”:

class CartReducerTest { @Test fun `increaseQuantity should update totalPrice`() { val initialState = CartUiState( items = listOf(CartItem(id = 1, name = "可乐", price = 10, quantity = 1)), totalPrice = 10L ) val result = cartReducer(initialState, CartIntent.IncreaseQuantity(1)) assertEquals(2, result.items.first().quantity) assertEquals(20L, result.totalPrice) } }

不需要启动 Android 环境,不需要 Mockito,不需要 mock Repository,一行assertEquals就把核心业务逻辑锁死了。我统计过,迁移到 MVI 之后购物车模块的核心逻辑测试覆盖率从 40% 直接到了 95% 左右,代价只是把业务逻辑从 ViewModel 中拆到纯函数里。这套收益非常直观:如果把状态变化都写死在 ViewModel 内部,你很难穷举所有组合,但 Reducer 本身就是纯函数,数学上天然适合穷举单元测试。

3.3 崩溃恢复不再是噩梦:State 本身就可以恢复

MVI 把页面状态集中到一个不可变对象里,这让进程被系统回收后的恢复变得极其简单。不再需要逐字段手动保存到SavedStateHandle,而是可以整体设计恢复策略:

  1. 如果 State 不大(比如筛选条件、页码、关键 ID),直接序列化保存到SavedStateHandle;
  2. 如果 State 包含大的列表数据,不要无脑整体存,改成只保存 ID 列表和关键条件,恢复后重新请求;
  3. 恢复时把保存的旧 State 作为初始状态灌回 ViewModel,页面几乎不用多余处理就能回到离开时的样子。

一个典型的恢复代码大概长这样:

class CartViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { private val restoredState: CartUiState? = savedStateHandle.get<String>("cart_state")?.let { jsonDecode(it) } private val _state = MutableStateFlow(restoredState ?: CartUiState()) }

当然,这里有个反直觉的点:就算不恢复旧 State,直接回到空状态重新加载,用户也不会觉得有问题,但 MVI 让“精确恢复”这个功能变得成本极低。以前在 MVVM 里你想做到同样效果,得排查哪几个字段需要保存、哪几个是瞬态数据,容易漏。

3.4 团队协作的隐性收益:新人很难把代码写坏

架构的意义不止是技术。MVVM 项目里,每个人对“状态怎么改”的理解不一样,有的喜欢在 UI 层改data,有的直接在 ViewModel 暴露MutableLiveData。这种风格分歧不致命,但在 Code Review 时会浪费大量口水。

MVI 的约束性强到几乎所有新人看一眼就能按套路写:新页面必须定义 State、定义 Intent、写 Reducer,UI 只 dispatch。代码 Review 的关注点也一下子收敛了:你只需要看 Reducer 有没有写成非纯函数、State 有没有塞进一次性事件。这些非常好检查。我甚至可以在团队里放一个模板仓库,新页面从模板复制粘贴,架构风格天然一致。

4. 不是银弹:MVI 的代价和我不推荐的场景

4.1 样板代码确实多,你需要在工程上“分级治理”

不可否认,MVI 的样板代码比 MVVM 多:一个页面至少要有 State、Intent、Reducer、ViewModel 四件套。如果每个页面都无脑套,代码量会明显膨胀,Review 和阅读成本也会上升。我的做法是分级:只有“同时存在多个状态且状态间互相影响”的页面才必须用 MVI,比如购物车、下单页、复杂的表格页;对于登录页、设置页这种流程简单的页面,用StateFlow + sealed class简单处理一下即可,不强制四件套。

这里的判断标准不是页面交互多不多,而是“状态组合空间大不大”。如果只有加载成功/失败三种状态,就没必要上 MVI;如果状态之间存在“加载失败后不能点击提交”“优惠券选择后总价变化”这种交叉,MVI 的价值就翻倍。

4.2 包体积和性能的“虚假担忧”与“真实成本”

很多团队一听“每次都生成新对象”就担心性能和包体积。先说包体积:每个页面多几个类,即便全 App 有 100 个复杂页面,多出来的 dex 体积也在几百 KB 级别,相比图片资源和 so 库几乎可以忽略。再说性能:State 是不可变对象的copy(),如果字段少,创建成本在纳秒级别;但如果一个列表页有几千条数据,每次copy()都复制整个 List 引用,真正的开销主要出现在 UI 层的 diff 和重组,而不是对象创建本身。

真要优化,我一般的做法是把totalPrice这类可以由items推导出来的值,不放在 State 里,而是作为派生状态在 UI 层或者计算属性中生成,这样避免修改列表时忘记同步总价这类低级错误。大列表不要做深度拷贝,直接引用共享不可变列表即可。

4.3 一次性弹窗、无状态页面别硬上

我见过不少团队把设置页里“切换开关”这种纯本地 View 状态也做成 Intent + Reducer,这属实是杀鸡用牛刀。MVI 的优势在于状态复杂、绑定多、异步并发多。如果一个页面只是完成“请求接口 → 展示结果”的线性流程,用 LiveData 就能搞得明明白白,强行上 MVI 反而会让简单逻辑淹没在模板代码中。

我的经验是:页面状态种类大于 3 种,或存在至少一对“状态交叉影响”,才值得动用 MVI;否则直接用简单的 StateFlow 封装就够用了。架构是为了提高效率,不是为了让人人感到“专业”。

4.4 团队切换成本:最麻烦的不是理解,是改变习惯

MVI 本身理解门槛不算高,真正的成本在“旧习惯切换”。MVVM 写得久了,人会本能地想去viewModel.loadData()或者直接改字段,试用期第一周会非常别扭。我的处理办法很简单:选一个相对独立的模块做试点,连续两周所有新代码都强制走模板,并且 Code Review 时坚持“Reducer 必须纯函数”“State 不可变”两条死线,逼大家把套路固定下来。过了适应期,再回头看那段手痒乱改的时代,大多数人都会同意这套约束其实是在保护代码库,而不是限制自由。

5. 我在真实项目里踩过的坑:五条避坑经验

5.1 把一次性事件塞进 State,导致重复弹 Toast

这是我迁移早期的经典翻车。我把“下单成功”做成一个showSuccessToast字段,结果用户在横竖屏切换后,系统把旧 State 恢复回来,Toast 又弹了一次,而且还拦不住。后来才明白:一次性事件走SharedFlow,用represent或navigate这种一次性 Effect 来承载,State 里永远不要存“已经发生过的事”。

5.2 在 Reducer 里做异步操作

有一次为了图方便,我在 Reducer 里直接调用了userRepository.updateAvatar(),希望通过“一个函数干完所有事”。结果单测直接崩了,因为 Reducer 调用仓库意味着测试时必须 mock 整个仓库。更糟的是,异步回调结果无法进入单向数据流,状态变成“从外面飘进来的”。后来明确规范:Reducer 只做同步计算,任何需要异步的 Intent 由 ViewModel 捕获,执行完把结果以新的 Intent 再丢回 Reducer。

5.3 Intent 数量爆炸:不合理的展开方式

购物车服务不断扩展,我一度写出了 40 多个 Intent 的巨型 sealed interface。IntelliJ 提示一个类超过 1000 行,这时候最难的不是写,而是每次when分支都变得冗长。我的解法是“按子模块分拆”:CartIntent.Load、CartIntent.Quantity、CartIntent.Coupon、CartIntent.Checkout各自成为子 sealed interface,Reducer 里用when嵌套或按类型委托到不同的 Reduce 函数,同时保证所有分支都收敛在同一套 State 模型上。这样既保住唯一入口,又不会膨胀成巨石。

5.4 更新字段后忘改关联字段

购物车里items改了,totalPrice却忘了同步更新,这种 Bug 在纯手写时很难避免。后来我把所有“可从其他字段推导的字段”全部从 State 中撤掉,总价在 UI 层用derivedStateOf或 map 函数生成。从此这一类 Bug 在模块里直接绝迹。这也算是一个 MVI 进阶经验:State 里保留独立事实,别存冗余派生数据。

5.5 Flow 收集时机与状态漏更新

StateFlow 默认是“只保留最新值 + 去重”的。如果 Reducer 连续快速触发多次状态变化,UI 收集时可能只看到最后状态,中间状态被合并掉。刚开始我以为这是丢状态,后来才意识到这正是 StateFlow 的设计意图——UI 关心的是最终渲染状态,中间过程并不需要逐帧刷新,比如快速切换标签页时,没必要让 UI 为了中间的loading = true闪一下。真正需要注意的只有一点:如果某些事件必须在中间过程中执行(比如“切换标签后播放提示音”),那就不应该走 State,应该走 Effect 通道。

6. 从 MVVM 渐进迁移到 MVI 的一条真实路线

6.1 选一个状态最复杂的页面做试点

不要一上来就全量重构,风险太大。我当初选的试点页面是购物车,因为它的状态交叉关系足够复杂:Loading、商品列表、优惠券、总价、提交状态、错误提示,六种状态互相牵制,非常适合验证 MVI 的价值。两周后,团队发现这个页面的 Bug 数和联调时间明显下降,后面的模块迁移就顺理成章了。

6.2 建立统一的页面模板,别重复造轮子

我建议团队维护一个MviTemplatePage的参考实现,里面固定包含UiState、Intent、Reducer、ViewModel四个文件的框架,新页面直接复制再改。模板还要搭配一个极简的 BaseViewModel,只做两件事:维护一个MutableStateFlow的 State,和一个MutableSharedFlow的 Effect,外加一个dispatch()入口。这个模板越简单越好,避免引入第三方状态管理库带来的额外心智负担。

6.3 组合多个数据源:用 combine 而不是嵌套 map

购物车页面同时依赖“商品列表”“用户可用优惠券”“配送费规则”三个数据源。一开始我图快,在 ViewModel 里写了好多层嵌套回调,代码一塌糊涂。后来老老实实用combine把这几个StateFlow组合起来:

val uiState: StateFlow<CartUiState> = combine( productListFlow, couponFlow, shippingRuleFlow, ) { products, coupons, shipping -> combineToState(products, coupons, shipping) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CartUiState())

combine的好处是任何一个上游变化都会生成新的 State 并统一走 Reducer 的规则,不会因为手动维护回调而出现漏更新。这里要注意WhileSubscribed的时间和冷启动问题,借助stateIn的时机需要按实际页面需求调试。

6.4 识别“伪 MVI”:一个酷似 MVI 却早已走偏的形态

最后说一个常见陷阱:有的人给 ViewModel 起了个dispatch()方法,也用了sealed class Intent,但 Reducer 内部直接发了网络请求,State 也是可变对象。这种形似 MVI 的写法,本质上还是换了一层皮的 MVVM,该有的问题全都有。

判断一个实现是不是真的 MVI,就看三句话:State 是不是不可变的、Reducer 是不是纯函数、状态变化是不是只有唯一入口。如果三个答案全是“是”,才算真正落地;否则,架构演进不但提升不了效率,反而会让人觉得“架构很麻烦”。


最后分享一个我自己的判断标准。每次犹豫一个页面要不要用 MVI 时,我只需要问两个问题:这个页面同时存在的状态有没有超过三种?这些状态之间会不会互相影响?如果两个答案都是“是”,我一定会用。如果只是“加载、成功、失败”这种线性过程,用 LiveData 就够了。MVI 的价值不在炫技,而在于当项目进入长期维护期后,你能少花一半的时间在“猜状态为什么会变”上面。如果你现在正被状态污染、竞态条件、UI 测试脆弱这些问题困扰,真心建议拿一个复杂页面试一个月,感受一下“所有状态变化都看得见”的踏实感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询