Android状态管理升级:MVI如何用单向数据流根治MVVM的混乱
发布时间:2026/9/28 5:47:26来源:尧图网络
先说个真实项目里的故事。前年我负责一个电商App的购物车模块用的是当时很主流的 MVVMViewModel 里放了六个 LiveDataUI 层通过调用submitOrder()、applyCoupon()这类方法直接操作数据。功能倒是上得飞快结果上线两个月后出现了一个让我头疼了很久的 Bug——用户快速连点“提交订单”下单成功页偶尔会展示上一次订单的优惠券信息问题随机、复现率极低。排查了一周多最后定位到是两次网络请求回包顺序颠倒后ViewModel 中间的临时字段被污染了。那次之后我开始认真研究 MVI架构并且把自己负责的模块全部迁了过去。今天这篇我不打算讲理论黑话只说说这个架构到底解决了哪些真实问题为什么它能从根上降低状态混乱的概率以及它有哪些代价、哪些坑。适合正在用 MVP/MVVM、对状态管理感到头疼的团队参考。1. MVI和MVVM到底差在哪一条完整数据流的对比实战1.1 它其实不是“新架构”而是状态管理思路的一次回归MVI 全称 Model-View-Intent如果往前追溯这套思路和前端 Redux、Elm 的那一套说白了是同源单向数据流、不可变状态、统一的动作入口。在 Android 上它把原来 MBVVM 里“ViewModel 提供一堆方法、UI 随意调用”的自由交互方式收束成了一条闭环UI 层把用户动作转换成Intent发送给 ViewModelViewModel 把Intent和当前不可变的State交给Reducer函数计算出一个新的StateUI 只负责渲染State唯一的外部动作就是把Intent发出去。画成文字就是View → Intent → ViewModel → Reducer → State → View。MVVM 最让我难受的一点是交互“姿势”太随意了。同一个页面的状态可能被 ViewModel 里五个不同的方法修改有些方法直接改MutableLiveData有些方法还带回调。这种自由在页面简单的时候无所谓一旦页面复杂起来会出现两个致命问题状态更新点极度分散改动不可追踪。你看到 Loading 消失了却很难说清楚是哪次调用把它关掉的。MVI 的做法是故意制造一点“不方便”你想改数据可以但只能通过 Intent 来触发而且最终一切状态变化都收敛到一个 Reducer 函数里。这个函数永远不被允许对状态做“直接修改”只负责根据旧状态生成新状态。凡是经历过线上疑难 Bug 的工程师都会觉得这种设计非常“香”。1.2 用购物车页面把两种架构摆在桌子上对比拿最常见的“购物车改数量”场景。MVVM 的写法一般是// MVVMView 层直接调用 ViewModel 的方法 fun onAddButtonClick(itemId: Long) { viewModel.increaseQuantity(itemId) }ViewModel 内部可能这样实现class CartViewModel : ViewModel() { private val _itemsLiveData MutableLiveDataListCartItem(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、Effect2.1 State把页面外观建模成一个不可变快照MVI 中最核心的概念就是 State。它把“页面此刻应该展示成什么样”完整地建模成一个数据类例如data class CartUiState( val items: ListCartItem 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定义它代表“用户或系统想做的一件事情”。页面加载、点击加号、网络请求成功、失败全部可以建模成 Intentsealed 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 或者写LogProfile Review 应该直接打回。为什么这么严格因为一旦 Reducer 不纯它就会变成“又一段不受控制的状态修改代码”整个 MVI 的预测性就垮了。正确的副作用处理方式我在项目里总结为“ViewModel 负责指挥Reducer 负责计算”class CartViewModel : ViewModel() { private val _state MutableStateFlow(CartUiState()) val state: StateFlowCartUiState _state.asStateFlow() private val _effect MutableSharedFlowCartEffect() val effect: SharedFlowCartEffect _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一次性事件要走独立通道别硬塞进 StateState 是“持续存在”的比如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而是可以整体设计恢复策略如果 State 不大比如筛选条件、页码、关键 ID直接序列化保存到SavedStateHandle如果 State 包含大的列表数据不要无脑整体存改成只保存 ID 列表和关键条件恢复后重新请求恢复时把保存的旧 State 作为初始状态灌回 ViewModel页面几乎不用多余处理就能回到离开时的样子。一个典型的恢复代码大概长这样class CartViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { private val restoredState: CartUiState? savedStateHandle.getString(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、写 ReducerUI 只 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 interfaceReducer 里用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: StateFlowCartUiState 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 测试脆弱这些问题困扰真心建议拿一个复杂页面试一个月感受一下“所有状态变化都看得见”的踏实感。
网站建设高端定制企业官网