很多团队在做架构决策的时候依据是业界都在用这个。MVVM 火了几年于是全部门迁 MVVMMVI 开始被 Google 在 Now in Android 项目里推于是又开始讨论要不要迁 MVI。但这个决策逻辑本身就有问题。架构不是潮流是权衡。两种模式都有清晰的适用场景和明显的局限如果你现在还是靠感觉在选这篇文章值得认真读一下。我们从一个具体问题出发同样用 Jetpack Compose Kotlin FlowMVVM 和 MVI 写出来的代码差在哪里又各自在什么情况下会让你后悔先把概念说清楚避免对着空气争论MVVMModel-View-ViewModel在 Android 里已经是默认选项。ViewModel 持有状态View 订阅状态变化数据流可以是双向的——UI 事件直接更新 ViewModel 里某个具体的 StateFlowViewModel 也可以主动 push 新状态给 View。MVIModel-View-Intent在概念上强调三点•单向数据流Unidirectional Data FlowView 只能发送 Intent用户意图ViewModel 处理后输出新 StateView 渲染 State•状态不可变Immutable State每次状态变更都产生新的 State 对象而不是在原来的对象上修改字段•单一状态源Single Source of Truth整个屏幕只有一个 UiState所有 UI 细节都封装在里面看起来差别不大对吧实际上差别在于约束程度。MVVM 是模式框架MVI 是规范约束。理解这点后面的所有对比才有意义。用代码说话同一个登录页两种写法先看 MVVM 的典型写法// MVVM ViewModel class LoginViewModel : ViewModel() { val username MutableStateFlow() val password MutableStateFlow() val isLoading MutableStateFlow(false) val errorMessage MutableStateFlow(null) val navigateToHome MutableSharedFlow() fun onLoginClick() { viewModelScope.launch { isLoading.value true errorMessage.value null try { authRepository.login(username.value, password.value) navigateToHome.emit(Unit) } catch (e: Exception) { errorMessage.value e.message ?: 登录失败 } finally { isLoading.value false } } } } // Compose UI Composable fun LoginScreen(viewModel: LoginViewModel hiltViewModel()) { val username by viewModel.username.collectAsState() val isLoading by viewModel.isLoading.collectAsState() val error by viewModel.errorMessage.collectAsState() // 处理一次性事件 LaunchedEffect(Unit) { viewModel.navigateToHome.collect { navController.navigate(home) } } TextField(value username, onValueChange { viewModel.username.value it }) Button(onClick viewModel::onLoginClick, enabled !isLoading) { Text(登录) } error?.let { Text(it, color Color.Red) } }再看 MVI 的写法// MVI — 状态定义 data class LoginUiState( val username: String , val password: String , val isLoading: Boolean false, val errorMessage: String? null ) // MVI — Intent 定义密封类穷举所有用户意图 sealed class LoginIntent { data class UpdateUsername(val value: String) : LoginIntent() data class UpdatePassword(val value: String) : LoginIntent() object ClickLogin : LoginIntent() } // MVI — 一次性事件副作用 sealed class LoginEffect { object NavigateToHome : LoginEffect() } // MVI ViewModel class LoginViewModel : ViewModel() { private val _uiState MutableStateFlow(LoginUiState()) val uiState: StateFlow _uiState.asStateFlow() private val _effect Channel(Channel.BUFFERED) val effect _effect.receiveAsFlow() fun handleIntent(intent: LoginIntent) { when (intent) { is LoginIntent.UpdateUsername - _uiState.update { it.copy(username intent.value) } is LoginIntent.UpdatePassword - _uiState.update { it.copy(password intent.value) } is LoginIntent.ClickLogin - performLogin() } } private fun performLogin() { viewModelScope.launch { _uiState.update { it.copy(isLoading true, errorMessage null) } try { authRepository.login( _uiState.value.username, _uiState.value.password ) _effect.send(LoginEffect.NavigateToHome) } catch (e: Exception) { _uiState.update { it.copy(errorMessage e.message ?: 登录失败) } } finally { _uiState.update { it.copy(isLoading false) } } } } } // Compose UI Composable fun LoginScreen(viewModel: LoginViewModel hiltViewModel()) { val uiState by viewModel.uiState.collectAsState() LaunchedEffect(Unit) { viewModel.effect.collect { effect - when (effect) { is LoginEffect.NavigateToHome - navController.navigate(home) } } } TextField( value uiState.username, onValueChange { viewModel.handleIntent(LoginIntent.UpdateUsername(it)) } ) Button( onClick { viewModel.handleIntent(LoginIntent.ClickLogin) }, enabled !uiState.isLoading ) { Text(登录) } uiState.errorMessage?.let { Text(it, color Color.Red) } }代码行数差不多但结构差异很明显MVI 的 ViewModel 对外只暴露两个东西——uiState和effectView 只能通过handleIntent发起动作。这个约束在简单页面看不出优势但在复杂页面上效果很明显。MVI 真正解决了什么问题我在维护一个电商详情页的时候深刻体会到这一点。这个页面大概有这些状态商品信息、SKU 选择、库存状态、加购动画、收藏状态、优惠券列表、评价摘要、推荐商品……用 MVVM 写的初版ViewModel 里大概有 12 个 StateFlow分散在各处。问题开始出现• 某些状态之间有依赖关系比如 SKU 变了价格和库存都要跟着变多个 StateFlow 的更新顺序不确定出现过短暂的价格已更新但库存还是旧的的界面闪烁• 复现 Bug 时你不知道当时的完整状态是什么只能看到崩溃时的某几个字段• 写单元测试时你要 mock 和 observe 12 个不同的 Flow测试代码比业务代码还复杂换成 MVI 之后所有状态合并成一个ProductDetailUiStatedata class每次用copy()产生新状态。这带来几个直接好处•状态快照任何时刻的 UI 状态都是一个完整的、可序列化的对象调试和日志分析变得容易•原子更新SKU 变更时一次copy(sku newSku, price newPrice, stock newStock)原子性地更新所有相关字段不会出现中间状态•测试简单发送一个 Intent断言输出的 UiState逻辑清晰所以 MVI 最大的价值不是单向数据流这个概念本身——MVVM 加上一些规范也能实现单向流。MVI 的价值是通过结构性约束让你在状态复杂的页面上不容易犯错。MVVM 什么时候反而更合适说完 MVI 的优势得公平地说 MVVM 没有被替代的理由。第一表单类页面MVI 的 copy() 有性能开销。用户每打一个字就触发一次_uiState.update { it.copy(username newValue) }每次都创建一个新的LoginUiState对象。对于简单 data classJVM 的对象分配很便宜GC 能搞定。但如果 UiState 里有大列表比如一个含 200 条搜索结果的状态每次键盘输入都深拷贝这个列表性能会有明显影响。这里有个技巧——可以把高频更新的局部状态单独抽出来用独立的 StateFlow 管理其余聚合进 UiState。但这已经是在 MVI 里混入 MVVM 的做法了。第二已有 MVVM 代码库的迁移成本是真实存在的。把 12 个 StateFlow 合并成 1 个 UiState 不是简单的重构涉及大量 View 侧的订阅逻辑重写。如果项目稳定、Bug 不多迁移带来的风险可能大于收益。第三团队经验很重要。MVI 的 sealed class Intent 对新人来说有一定学习曲线。如果团队大量是初级开发者强行推 MVI 可能导致 Intent 类设计混乱比如把本该是内部逻辑的操作也暴露成 Intent反而比随意的 MVVM 更难维护。Now in Android 怎么做的官方实践参考Google 的 Now in AndroidNiA项目是目前最权威的 Android 架构参考实现在 Issue #123 的更新中他们明确采用了 MVI 风格的状态管理。来看他们的具体实现思路// NiA 风格多个 Flow 合并为单一 UiState使用 combine class ForYouViewModel Inject constructor( private val userDataRepository: UserDataRepository, newsRepository: NewsRepository, ) : ViewModel() { val uiState: StateFlow combine( userDataRepository.userData, newsRepository.getNewsResources(), ) { userData, newsResources - ForYouUiState.Success( feedState NewsFeedUiState.Success( feed newsResources.map { newsResource - UserNewsResource(newsResource, userData) } ) ) } .stateIn( scope viewModelScope, started SharingStarted.WhileSubscribed(5_000), initialValue ForYouUiState.Loading ) fun updateTopicSelection(topicId: String, isChecked: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, isChecked) } } } // 状态定义用密封接口区分加载中/成功/错误 sealed interface ForYouUiState { data object Loading : ForYouUiState data class Success(val feedState: NewsFeedUiState) : ForYouUiState }NiA 的做法有几点值得学习• 用combine把多个数据源合并成单一 UiState而不是让 View 同时订阅多个 Flow•SharingStarted.WhileSubscribed(5_000)是处理配置变更的标准姿势页面切换后 5 秒内回来不会重新请求数据• 事件方法如updateTopicSelection没有严格封装成 sealed class Intent更接近 MVVM 风格这说明 Google 自己也不是纯粹的 MVI 原教旨主义者。他们选择了一个实用的中间路线单一 UiState 多方法触发 80% 的 MVI 优势 更低的样板代码。一次性事件Side Effect两种模式共同的痛点不管用 MVVM 还是 MVI一次性事件都是个让人头疼的问题。典型场景登录成功后跳转页面、接口报错后弹 Toast、文件下载完成后震动。这类事件的特点是只触发一次配置变更旋转屏幕后不应该重放。常见的错误做法是用StateFlowEvent?消费后置 null。这个方式在多个 collector 存在时会有竞争问题也不够优雅。目前相对靠谱的方案// 方案一Channel推荐适合单一消费者 private val _effect Channel(Channel.BUFFERED) val effect _effect.receiveAsFlow() // 方案二SharedFlow适合多消费者但要注意 replay private val _effect MutableSharedFlow( replay 0, extraBufferCapacity 1, onBufferOverflow BufferOverflow.DROP_OLDEST ) val effect: SharedFlow _effect.asSharedFlow() // View 侧消费关键在 Lifecycle.State.STARTED 下收集 LaunchedEffect(Unit) { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.effect.collect { effect - when (effect) { is UiEffect.ShowToast - Toast.makeText(context, effect.msg, Toast.LENGTH_SHORT).show() is UiEffect.Navigate - navController.navigate(effect.route) } } } }注意用Channel时如果 View 在 STOPPED 状态比如被其他页面覆盖导致没有活跃的 collector事件会积压在 buffer 里View 恢复后再消费。这在大多数场景是合理行为但如果你不希望积压比如某些动画效果要换成DROP_OLDEST的 SharedFlow。与模块化架构的结合ViewModel 的边界在哪里聊完单页面的状态管理往大了说在模块化项目里MVI/MVVM 怎么和模块边界配合以 NiA 的模块结构为参考一个典型的 feature 模块的分层是这样的:feature:home↓UI 层 → Composable / Fragment无业务逻辑状态层 → ViewModelUiState Intent 处理↓:core:domain可选↓:core:data↓数据源 → Repository → Remote/Local DataSource几个实际踩过坑的原则•ViewModel 不跨 feature 模块共享。如果两个页面需要共享状态应该把状态提升到共同的数据层:core:data的 Repository而不是共享 ViewModel。共享 ViewModel 是一种反模式会让模块间产生隐式依赖。•UiState 只属于 feature 模块。把 UiState 定义在:core:ui或者公共模块是个常见错误会导致底层模块反向依赖上层业务逻辑。•domain 层不是必须的。NiA 里:core:domain是可选的只在业务逻辑复杂、多个 feature 复用同一业务规则时才引入 UseCase。如果你的 ViewModel 直接调用 Repository 就能解决问题不需要强行插入 UseCase 层。•Hilt 的 HiltViewModel 和模块化配合良好但每个模块需要自己处理依赖图。Hilt 2.59 修复了多模块场景下组件层次混乱的 Bug如果你之前遇到过MissingBinding异常升级可以解决部分问题。给你的判断2026年选什么说到这里给个明确的判断。新项目选 MVI 风格的单一 UiState。不需要完全教条化地封装 sealed class Intent除非团队规模较大、需要强约束但至少把状态合并成单一 StateFlow副作用用 Channel/SharedFlow 分离。成本低收益在项目规模扩大后会明显体现。已有 MVVM 项目不要为了 MVI 而迁移。除非你在这个项目上遇到了明显的状态管理问题状态不一致、测试困难、多人协作冲突否则迁移成本大于收益。可以在新加的 feature 模块里用 MVI 风格老代码保持不动。团队对 Compose 掌握度不高时先把 Compose 学好再谈架构。架构问题在 View 系统里很突出在 Compose 里因为组合函数本身的单向特性即使不严格遵循 MVI代码也不容易乱。别在 Compose 还没搞清楚的时候过度设计架构。最重要的一点MVVM 和 MVI 都是指导思想不是必须死守的教条。Google 官方项目本身也是两者的混合。你的架构决策应该基于团队规模、项目复杂度和当前的技术债水位而不是哪个更时髦。下一步值得探索的方向有一个方向最近值得关注Compose Hot Reload 对架构的影响。Android Weekly #722 重点推荐的这篇文章提出了一个有意思的问题——当 UI 可以热重载时ViewModel 的生命周期管理逻辑需要相应调整。如果 Composable 在热重载后重新初始化但 ViewModel 保留了旧状态会不会出现状态不一致这是 2026 年 Compose 架构演进里值得深入研究的新问题。另外Kotlin Coroutines 1.10 对 Flow 的一些改进以及 Compose Multiplatform 逐步稳定都在推动 ViewModel 层的代码向平台无关方向演进。如果你有跨平台需求现在开始在架构上为 CMP 留好接缝成本比事后迁移低很多。架构设计没有终点但每个阶段都有值得做出的最优解。本文代码示例基于 Kotlin 2.x Jetpack Compose 1.7 Hilt 2.59适用于 2026 年主流 Android 开发环境。