ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android架构演进:从MVVM到MVI的实战解析与状态管理之道

Android架构演进:从MVVM到MVI的实战解析与状态管理之道 我们做Android开发的朋友这几年听到MVI的频率应该不低。我最早接触这个缩写时第一反应是“怎么又是新轮子”毕竟从MVC到MVP再到MVVM光是架构模式的争论就能养活半个技术社区。但真正在几个中大型项目里落地MVI并跑完整个迭代周期后我的看法有了实质变化MVI不是一个为了炫技而生的理论模型它恰好解决了MVVM在复杂业务场景下最让人头疼的那几个问题。这篇内容会从原理到实践把“为什么要用MVI”这件事讲透。我尽量用实际项目里会遇到的场景来说话而不是堆概念。无论你是刚入行不久、正在纠结怎么组织UI层代码还是已经用MVVM写了两三年、总感觉状态管理乱糟糟的这篇文章应该都能给你一些参考。1. 先搞清楚一件事MVI到底解决的是哪类痛苦在聊MVI之前得先回顾一下架构模式到底在解决什么问题。我们写UI代码本质上就是在做一件事把业务状态映射成界面呈现再把用户操作反馈回业务逻辑。说起来简单但一旦页面功能变多、接口请求变频繁、UI交互变复杂这个双向过程就会迅速失控。MVVM最典型的落地方式是ViewModel暴露若干个LiveData或StateFlow页面在onCreate里对每个数据流分别做观察。初期很爽一个事件对应一个数据流逻辑清晰。但项目迭代到半年以后你就会遇到几个特别实际的麻烦。第一个麻烦是状态一致性问题。比如一个订单详情页有订单状态、支付倒计时、用户地址、优惠券信息这几个数据在接口回调后各自独立更新。用户操作触发了一个刷新动作页面上可能出现“支付按钮已经变成灰色了但倒计时还在跑”“优惠券已经不可用了但界面还显示可以用”这种互相矛盾的中间态。原因很简单多个数据流各自为政没有任何机制保证它们在同一时间点上彼此兼容。第二个麻烦是事件回调的割裂感。MVVM里页面接收的是多个零散的回调比如onOrderLoaded、onCountdownTick、onCouponInvalid。当这些回调都往同一个UI逻辑里灌的时候你就不得不在Activity或者Compose里写大量的when分支判断当前到底该展示什么。UI层越来越像一个状态分发中心ViewModel反而沦为了接口转发层。第三个麻烦是无法回溯。用户反馈了一个bug说“我点了支付按钮没反应”。你想排查到底是状态没更新、还是更新了但UI没刷、还是刷了但被别的逻辑覆盖了几乎无从下手。因为状态散落在不同的数据流里你没法截取任何一个时间点上的完整快照。MVI就是冲着这些问题来的。它把所有状态收敛成一个不可变的快照把所有用户操作统一成Intent用明确的Reducer逻辑保证状态只会以可预测的方式发生变化。它不只是一套理论更像是对“状态管理混乱”这个顽疾的一次系统性反击。2. MVI核心原理一条不会绕圈的单向数据流MVI的全称是Model-View-Intent看名字和MVC、MVP很像容易让人误以为只是换了个马甲。实际理解这套模式需要抓住三个关键词不可变状态、单向数据流、Intent驱动的状态更新。2.1 不可变状态State到底意味着什么MVI里的Model指的是完整的UI状态而不是传统的实体数据模型。它通常被定义成一个不可变的数据类包含当前页面需要渲染的全部信息。data class OrderUiState( val isLoading: Boolean false, val order: Order? null, val coupon: Coupon? null, val countdownSeconds: Int 0, val errorMessage: String? null )关键在于“不可变”。你不能写state.countdownSeconds 30因为Kotlin data class默认是val你根本改不了。你只能通过copy()创建一个新的state实例。这看起来是限制其实是保护。试想一下如果状态可以被随意修改那么A协程刚改了isLoadingB协程随后又把order置空UI收到的就是一个混血状态的中间版本。而不可变性天然杜绝了这类问题——每一次状态变化都是完整替换不存在“改了一半被人看到”的可能性。2.2 这里的Intent不是Android里的IntentMVI里的Intent是用户意图不是startActivity用的那个东西。它是对用户操作的行为化抽象比如“刷新订单”“点击支付”“填写地址”。一个订单详情页的Intent可能是这样的sealed class OrderIntent { object LoadOrder : OrderIntent() data class FillAddress(val address: String) : OrderIntent() data class ClickPay(val orderId: String) : OrderIntent() }不要用Android系统的Intent来理解它。MVI的Intent是纯粹的业务动作甚至可以没有任何数据只表达“用户做了某件事”这个语义。ViewModel收到Intent后决定要不要触发网络请求、要不要更新本地状态然后产出一个新的State。2.3 Reducer整个架构里唯一允许改状态的地方Reducer是MVI的灵魂函数。它是一个纯函数接收旧的state和一个Intent返回新的state。所谓纯函数指的是同样的输入永远得到同样的输出不执行网络请求、不写数据库、不搞随机数。fun reduce(oldState: OrderUiState, intent: OrderIntent): OrderUiState { return when (intent) { is OrderIntent.LoadOrder - oldState.copy(isLoading true, errorMessage null) is OrderIntent.FillAddress - oldState.copy(address intent.address) is OrderIntent.ClickPay - oldState.copy(payButtonEnabled false) } }Reducer只负责“状态怎么变”不负责“网络请求怎么发”。实际开发中网络请求的发出、成功失败回调的接收都被放到ViewModel或Repository层处理收到结果后再封装成一个state变更动作丢给Reducer。这样就形成了一个清晰的分工UI层负责发IntentViewModel负责接收Intent、发起业务请求Reducer负责根据Intent和业务结果计算新状态UI层观察新状态并渲染2.4 数据流的环形运动把整个流程串起来看它是一条单行线UI发Intent → ViewModel处理 → Reducer产新状态 → UI渲染。新状态会覆盖旧状态UI再次持有完整数据用户的下一步操作又产生新Intent如此循环。这个过程有点像工厂流水线。工人UI只能把零件Intent放到传送带上机器ViewModel加工完以后成品新State才会回到工人手里。工人永远不需要关心机器内部是怎么运转的他只关心当前传回来的成品是否合格。这条单向环保证了无论多少个功能模块在协同工作状态流永远只有一个时间轴。这一点对调试来说价值极大因为你在任何时刻都能回答“当前界面为什么长这样”这个问题——答案就在当前唯一的状态快照里。3. MVI和MVVM、MVP放一起比差距都在细节里很多团队在引入MVI时最大的顾虑是“我们MVVM不也活着吗为啥要折腾”我承认中小型项目里MVVM完全够用MVI的优势未必能完全发挥。但项目规模上来以后架构对比的结果会非常明显。3.1 状态管理从多流观察到单状态收敛MVVM的经典写法是每个业务数据对应一个LiveData。界面越复杂LiveData越多ViewModel的职责就越碎。最极端的时候我见过一个页面有十几个LiveData互相之间还有依赖关系比如订单加载完成后才能请求优惠券。这种依赖关系一旦写进Activity的生命周期回调里整个页面的可读性会断崖式下降。MVI把所有的LiveData合并成一个StateFlowOrderUiState。UI只观察这一个数据流每次收到新状态就整体刷新。这带来的心智负担降低是立竿见影的——你不需要再记忆“当前哪些数据是新的、哪些是旧的”因为状态本身就是完整的。3.2 事件处理从回调满天飞到Intent加ReducerMVVM里一个页面可能有好几个事件回调比如showLoading()、showError()、updateList()。这些回调本质上都是状态变化但它们以“方法调用”的形式暴露给UI导致UI层经常要写这种代码viewModel.orderLiveData.observe(this) { order - updateHeader(order) updatePaymentButton(order.payable) startCountdown(order.expireAt) }这段逻辑表面上是“更新Header”实际还干了好几件别的事情。等到需求变更、需要增加新逻辑时你只能在这段回调里继续堆代码直到再也堆不动。MVI的做法是UI只分析新State然后在render(state)方法里统一决定展示什么。所有业务逻辑都在Reducer或ViewModel里完成UI层变成纯粹的渲染器看代码清晰得多。3.3 可测试性纯函数天然好测同样是测试订单页逻辑MVVM里你测试ViewModel的公共方法需要mock Repository的返回值才能验证LiveData的内容。MVI里测试目标可以变成Reducer它是一个纯函数传一个state加一个intent进去断言返回的state是否符合预期不需要任何mock。Test fun clickPay marks payButton disabled() { val oldState OrderUiState(order sampleOrder) val newState reducer(oldState, OrderIntent.ClickPay(sampleOrder.id)) assertFalse(newState.payButtonEnabled) }这测试写起来有多爽写过的人都知道。没有协程调度器、没有网络依赖、没有Android环境纯JVM测试跑完秒级反馈。3.4 MVI不适用的场景别强行降维打击话说回来MVI也不是银弹。如果是极其简单的页面比如一个只有静态展示、没有用户交互的“关于页面”用MVI等于杀鸡用牛刀光状态定义和Intent定义就比整个页面代码还长。还有就是极高性能要求的场景。整体状态更新意味着每来一个新状态UI都要做一次全量刷新虽然现代UI框架尤其是Compose有diff机制兜底但如果是超长列表、高频刷新MVI的状态全量替换仍然可能成为性能瓶颈。这种时候用分页的局部状态管理反而更直接。所以我的建议是MVI适合业务复杂度中等以上的页面比如表单流、订单流、带有多个互相关联数据模块的业务页。简单页面该用传统的setState、该用局部ViewModel千万别含糊。4. 手把手落地从零写一个订单页的MVI实现原理聊多了容易飘我直接用一个订单详情页的完整代码走一遍MVI落地流程。这是一个典型的复杂页面有加载态、有倒计时、有支付按钮的禁用启用、有地址填写、有异常处理。4.1 设计State把页面所有可能展示的内容放进一个类State设计的核心准则是UI需要知道什么State就包含什么。不需要UI展示的数据不应该出现在State里。data class OrderDetailState( val loading: Boolean true, val order: Order? null, val address: String , val countdown: Int 0, val payEnabled: Boolean false, val showErrorDialog: Boolean false, val submitting: Boolean false )我刻意加了showErrorDialog而不是errorMessage因为UI只关心“要不要弹窗”具体文案可以由ViewModel在恢复时临时获取。如果你把errorMessage直接放进State那么当用户关闭弹窗时你还得专门发一个Intent把它清空。如果直接放一个布尔值配合一次性事件处理逻辑会清爽很多。4.2 定义Intent覆盖页面所有交互动作sealed class OrderDetailIntent { object LoadOrder : OrderDetailIntent() data class RefreshAddress(val newAddress: String) : OrderDetailIntent() object ClickPay : OrderDetailIntent() object DismissErrorDialog : OrderDetailIntent() }注意ClickPay这个Intent在正常情况下只表达动作本身真正的网络支付请求放到ViewModel里处理。Reducer只负责把payEnabled置为false、把submitting置为true。4.3 实现Reducer纯状态运算object OrderDetailReducer { fun reduce(state: OrderDetailState, intent: OrderDetailIntent): OrderDetailState { return when (intent) { is OrderDetailIntent.LoadOrder - state.copy(loading true, showErrorDialog false) is OrderDetailIntent.RefreshAddress - state.copy(address intent.newAddress) is OrderDetailIntent.ClickPay - state.copy(submitting true, payEnabled false) is OrderDetailIntent.DismissErrorDialog - state.copy(showErrorDialog false) } } }Reducer里的逻辑必须保持纯粹。你不应该在Reducer里执行orderRepository.fetch()因为Reducer会被单元测试直接调用一旦混入副作用测试就没法写了。4.4 写ViewModel把Intent转化为StateViewModel是MVI里唯一负责业务逻辑的地方。它接收UI发来的Intent必要时调用Repository拿到业务结果后要么直接通过Reducer更新State要么把结果包装成新Intent再交给Reducer。class OrderDetailViewModel( private val repo: OrderRepository ) : ViewModel() { private val _state MutableStateFlow(OrderDetailState()) val state: StateFlowOrderDetailState _state.asStateFlow() fun dispatch(intent: OrderDetailIntent) { when (intent) { is OrderDetailIntent.LoadOrder - loadOrder() is OrderDetailIntent.RefreshAddress - { _state.update { OrderDetailReducer.reduce(it, intent) } } is OrderDetailIntent.ClickPay - submitPay() is OrderDetailIntent.DismissErrorDialog - { _state.update { OrderDetailReducer.reduce(it, intent) } } } } private fun loadOrder() { _state.update { OrderDetailReducer.reduce(it, OrderDetailIntent.LoadOrder) } viewModelScope.launch { try { val order repo.fetchOrder() _state.update { current - current.copy(order order, loading false, payEnabled order.isPayable) } } catch (e: Exception) { _state.update { it.copy(loading false, showErrorDialog true) } } } } private fun submitPay() { viewModelScope.launch { try { repo.pay() // 支付成功后的页面跳转用副作用/一次性事件来处理 _event.emit(OrderDetailEvent.PaySuccess) } catch (e: Exception) { _state.update { it.copy(submitting false, payEnabled true) } } } } }有些细节值得反复咀嚼。一是_state.update里直接写current.copy而不是绕过Reducer这一点我特意保留因为很多网络请求回调后的状态更新天然不适合放进Reducer。如果硬要套Reducer反而要把成功结果包装成一个Intent.LoadResult在Reducer里做state.copy(order result)最后代码就绕了一圈。所以我的实践经验是Reducer擅长处理用户主动发起的Intent而异步结果回调后的状态变更直接在ViewModel里用copy处理更顺手。你依然可以用Reducer但没必要为了形式主义牺牲可读性。二是注意showErrorDialog这种“用完即焚”的状态。如果你不在DismissErrorDialog或重新加载时把它置为false下次弹窗永远弹不出来。所以State里尽量不要放这类一次性标记真要放就必须在State设计的评审阶段就确认它的生命周期。4.5 UI层观察State发送IntentUI层在MVI里的地位被压到极点所有操作只剩两件事——把用户动作转化成Intent发出去、观察新的State并渲染。以Jetpack Compose为例Composable fun OrderDetailScreen(viewModel: OrderDetailViewModel) { val state by viewModel.state.collectAsState() val lifecycleOwner LocalLifecycleOwner.current val context LocalContext.current LaunchedEffect(Unit) { viewModel.dispatch(OrderDetailIntent.LoadOrder) } // 渲染State when { state.loading - LoadingView() state.order ! null - OrderContent( state state, onAddressChange { address - viewModel.dispatch(OrderDetailIntent.RefreshAddress(address)) }, onPayClick { viewModel.dispatch(OrderDetailIntent.ClickPay) } ) state.showErrorDialog - ErrorDialog(onRetry { viewModel.dispatch(OrderDetailIntent.DismissErrorDialog) viewModel.dispatch(OrderDetailIntent.LoadOrder) }) } }UI层不再需要关心“订单加载成功后要不要自动把地址填进去”“支付按钮能不能点”这些业务判断只需根据State直接渲染。这带来的最大好处是UI层面几乎不会因为新增需求而大改结构因为所有业务规则都已经收敛到State和Reducer里了。4.6 一次性事件导航、Toast、Snackbar的正确姿势MVI的State是“跨时间存在的”但有些事件在消费一次后就不应该再出现比如“支付成功跳转”“复制订单号Toast”。如果把这些放进State会面临两个问题一是可能会被重复渲染二是配置变更比如旋转屏幕后事件重新展示用户会看到两次Toast。我们项目中统一用的方案是单独维护一个Channel事件流private val _event ChannelOrderDetailEvent(Channel.BUFFERED) val event _event.receiveAsFlow() // UI层 lifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.event.collect { event - when (event) { is OrderDetailEvent.PaySuccess - { Toast.makeText(context, 支付成功, Toast.LENGTH_SHORT).show() } } } } }这不算“破坏单向数据流”因为事件流是VM向外通知的补充通道不影响State这个核心数据源。用Channel的好处是它天然不支持粘性事件事件被接收一次后即被消费不会因为配置变更而被重复触发。5. 落地MVI之后的真实体感调试、测试、迭代的变化这一节不讲代码讲团队实际跑两个迭代后的体验变化我认为这才是MVI真正的价值增量。5.1 状态可回溯带来的调试效率提升之前用MVVM排查bug最常见的山穷水尽场景是用户说“我用优惠券付款时金额不对”。你想复现问题就得重现他的操作链路还要祈祷接口响应时序一致否则根本复现不了。用MVI之后可以这样排查让用户在复现问题时摇一摇把当前OrderDetailState的完整dump发过来。因为State是纯数据类可以直接序列化为JSON。你能看到每一个字段的真实值比如coupon有没有被应用、countdown是不是还在走、payEnabled是不是被错误地置为true。问题基本一眼就能定位到是Reducer算错了还是接口返回值不符合预期。这个能力在跨团队协作时尤其好使。测试提bug时会直接附上一份状态快照前端、后端、产品一起看这份快照责任边界非常清楚不用再互相扯皮。5.2 新成员上手速度知道规则比知道代码更重要团队来了个新人给他分配MVI项目时培训成本比MVVM低不少。因为MVI的协作规则是固定的一环UI发Intent、VM处理、Reducer改State、UI渲染。新人只需要理解这一条链路就能在任何一个页面里快速找到自己要改的位置。相比之下MVVM的老项目经常因为少了状态收敛这一环每个页面的写法都不一样。有的页面直接Repository回调updateUI有的页面借LiveData中转有的页面在Adapter里硬塞逻辑。新人光是熟悉不同页面的惯例就得花掉一两个星期。5.3 业务需求变更时的可控性订单页上线之后产品又提了个新需求“用户地址不合法时支付按钮要置灰并且显示提示文案。”在MVI里这个改动非常直观——在Reducer的RefreshAddress分支加一个判断is OrderDetailIntent.RefreshAddress - state.copy( address intent.newAddress, payEnabled intent.newAddress.isNotBlank() state.order?.isPayable true )你不需要改动UI层不需要修改接口加一行判断就完事。这种“改一点动全局”的高效只有在状态被收敛成一整块时才可能出现。如果还是在多个LiveData各管各的状态你会发现这个需求至少要触碰三处UI绑定逻辑。6. MVI落地过程中的常见坑和我的应对方法任何架构模式落地过程中都会遇到一些文档里没写清楚的坑。MVI这几条是我被现实反复教育过的写出来给大家提个醒。6.1 不要把所有状态都塞进一个类里有人会走向另一个极端为了追求“单一数据源”把整个页面的所有状态包括弹窗宽度、滚动位置、输入框高度之类的东西全部塞进一个State。结果每敲一个字都要copy整个对象性能差是一方面代码可读性也直线下降。我的经验是State粒度应该与UI渲染的逻辑单元对齐。如果你的页面有四个独立区域每个区域之间没有状态依赖完全可以定义成四组State然后用data class组合不需要强行扁平化成一个毫无层次的巨型对象。判断粒度合适与否的标准很简单改A区域会不会导致B区域收到无意义的新状态如果会那就说明粒度太粗该拆分。6.2 避免Reducer里堆叠业务逻辑Reducer必须是纯函数但并不是说所有纯逻辑都该放Reducer。有些团队把支付流程里的券校验、价格计算、倒计时更新都塞进Reducer结果Reducer变成了一坨几百行的“神函数”。我更推荐的做法是Reducer只负责“基于旧的UI状态和Intent产出新的UI状态”这种轻量工作。涉及复杂的业务校验、计算放到ViewModel或专门的UseCase里去完成最后把计算结果通过Intent或直接在ViewModel里更新State。很多人担心这种妥协会破坏MVI的纯粹性但实际项目的可维护性比主义的纯粹性重要得多。6.3 加载数据时先发一次Loading Intent很多新手写MVI的异步加载时会在loadOrder()里直接_state.update { it.copy(loading true) }这样写没问题但它绕开了Reducer的意图。更符合MVI思路的设计是你先把LoadOrderIntent发给Reducer让Reducer把loading置为true然后再发起异步请求。这样无论从哪里触发加载初次进入、下拉刷新、点击重试状态变化都走同一条路径行为一致性好得多。6.4 配置变更时别让状态流断掉ViewModel在配置变更后会被重建StateFlow依赖Kotlin的协程本身不太怕这个但如果你在onCleared里取消协程配置变更时ViewModel还没有被清掉所以协程会继续跑。真正需要小心的是保存状态恢复。如果页面支持进程被杀死后恢复比如应用切后台过久你需要把核心业务状态通过SavedStateHandle保存起来在ViewModel初始化时恢复。否则用户切后台回来整个页面会重新进入LoadOrder流程体验和性能都会打折。MVI的State设计为不可变类天然方便跨进程序列化这份优势不用白不用。6.5 团队规范远比技术选型重要最后说一个不是技术问题但影响最大的点。MVI能发挥价值的前提是整个团队的代码规范统一。一个人全程用Reducer另一个人在ViewModel里直接改State还有一个人把网络回调放在Activity里最后这个项目的MVI就名存实亡。我们团队的做法是在代码评审里明确规定三件事——所有状态变更必须记录在State上网络请求和业务逻辑只能在ViewModel层UI层不允许出现任何业务计算。这三条红线守住了MVI的好处才能兑现。7. 我的最终选择MVI和MVVM并存行文至此有人可能会问所以你现在所有的项目都全面切MVI了答案是否定的。我们的实际情况是团队内的存量页面大部分还是MVVM架构跑得也还不错贸然重写风险远大于收益。但所有新开发的复杂业务页面从需求一开始就走MVI简单页面则沿用轻量的ViewModel加StateFlow写法不强行套Intent和Reducer。如果你问我MVI值不值得全面落地我的答案是先把它的核心思想——状态收敛、不可变数据、单向数据流、UI层渲染纯粹化——引入到你现有项目里哪怕只是把一个MVVM页面的多个LiveData收敛成单个StateFlow你也会立刻感受到变化。等这套理念被团队消化吸收后再决定要不要全量切换MVI。最后分享一个我踩了多次坑后的心得架构模式是团队协作的契约不是束缚个人的教条。MVI给了一个很好的框架但具体某个Intent该不该单独定义、某个状态要不要进State、异步结果该走Reducer还是ViewModel这些边界需要每个团队用自己的项目经验去打磨。架构的价值不在于“标准”而在于“让混乱变得可控”。这一点MVI确实做到了。
返回列表