

📰 科技要闻
• 🔥 Monzo案例:Google官方博客披露,Monzo银行App通过简单R8更新将多项性能指标提升高达35%,背后是模块化构建与代码优化的架构红利
• 📦 Compose Multiplatform v1.11.10-alpha + Navigation3 alpha发布:navigation3模块正式进入CMP生态,组件化路由迎来跨平台新标准
• 🔑 Hilt/Dagger 2.59.2发布:修复多个DI框架Bug,依赖注入稳定性增强,是模块化架构的核心基础设施
• 🛢️ SQLDelight 2.3.2发布:多平台数据库层更稳定,配合Repository模式构建Clean Architecture数据层
• 📱 Android 64位要求倒计时:Google官方提醒开发者为Wear OS及Android应用完成64位适配,涉及模块ABI构建策略
• 📰 Android Weekly #720:本期聚焦白标App一键发布实践,多渠道多产品线的模块化构建架构方案
有一件事让我觉得挺魔幻:2026年了,Android架构圈还在争MVI和MVVM哪个更好。
不是说这个问题已经有定论——而是争来争去,双方都在用正确的论据,支撑完全不同的结论。说"MVI才是正道"的有理,说"MVVM够用别过度设计"的也没错。
根本原因是:这两种架构解决的不是同一个问题,用同一把尺子量当然量不出结果。
这篇文章想把这把尺子拿出来,说清楚各自适合什么场景——以及为什么很多人"选了MVI,但写成了披MVI外衣的MVVM"。
从一个问题开始:状态是什么?
MVVM和MVI的核心分歧,其实就在于"状态"的定义和管理方式。
先说MVVM里最常见的写法——多个独立Flow:
class OrderViewModel : ViewModel() {
val isLoading = MutableStateFlow(false)
val orders = MutableStateFlow<List<Order>>(emptyList())
val errorMessage = MutableStateFlow<String?>(null)
val selectedOrder = MutableStateFlow<Order?>(null)fun loadOrders() { ... }
fun selectOrder(order: Order) { ... }
fun retryLoad() { ... }
}看起来很清晰,但有个问题藏得很深:isLoading=true 同时 errorMessage != null,这是合法状态吗?
就像路口的红绿灯——正常情况只能亮一个颜色,如果红灯和绿灯同时亮了,司机该怎么办?MVVM里这四个Flow就是四盏灯,没人保证它们不会同时亮,全靠约定和自律。代码库规模一大,团队一多,约定就开始失效。
MVI的思路是把这些零散字段合并成一个UiState——用sealed class从结构上保证只有一盏灯会亮:
sealed class OrderUiState {
object Loading : OrderUiState()
data class Success(
val orders: List<Order>,
val selectedOrder: Order? = null
) : OrderUiState()
data class Error(val message: String) : OrderUiState()
}// ViewModel
class OrderViewModel : ViewModel() {
private val _uiState = MutableStateFlow<OrderUiState>(OrderUiState.Loading)
val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()private val _effect = Channel<OrderEffect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()
}这样"加载中但同时有错误"从语言层面就被消灭了——sealed class的每个子类代表一个合法状态,Kotlin编译器不允许同时处于两个子类。红绿灯的电路从硬件层面就互斥了,不依赖司机素质。
这是MVI最核心的价值:状态合法性由类型系统保证,而不是靠开发者自律。
MVI完整三角:State + Intent + Effect
MVI里除了State,还有两个概念经常被说混:Intent和Effect。
Intent(用户意图):UI层发出的所有动作,统一封装成sealed class,替代ViewModel里的一堆public函数。好处是所有"UI能做什么"一眼可见,不用翻遍ViewModel找方法名。
Effect(副作用):一次性事件,比如Toast、导航跳转、弹Dialog。这里有个很容易踩的坑——这些不能放进State。
为什么?想象一张纸质优惠券:用完即作废,不能因为你转了个身又用一次。导航跳转就是这种一次性优惠券——屏幕旋转重订阅后State会重新emit,但页面不应该再跳一次。Effect通道(Channel)保证每个事件只被消费一次,State做不到这一点。
下面是Intent + Effect + ViewModel的完整写法:
// Intent:UI的所有动作
sealed class OrderIntent {
object LoadOrders : OrderIntent()
data class SelectOrder(val orderId: String) : OrderIntent()
data class DeleteOrder(val orderId: String) : OrderIntent()
object Retry : OrderIntent()
}// Effect:一次性副作用
sealed class OrderEffect {
data class NavigateToDetail(val orderId: String) : OrderEffect()
data class ShowToast(val message: String) : OrderEffect()
object ShowDeleteConfirmDialog : OrderEffect()
}// ViewModel
class OrderViewModel : ViewModel() {
private val _uiState = MutableStateFlow(OrderUiState.Loading)
val uiState = _uiState.asStateFlow()private val _effect = Channel<OrderEffect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()fun onIntent(intent: OrderIntent) {
when (intent) {
is OrderIntent.LoadOrders -> loadOrders()
is OrderIntent.SelectOrder -> selectOrder(intent.orderId)
is OrderIntent.DeleteOrder -> confirmDelete(intent.orderId)
OrderIntent.Retry -> loadOrders()
}
}
}// Compose UI
@Composable
fun OrderScreen(viewModel: OrderViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()// Effect用LaunchedEffect消费,不会重复触发
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is OrderEffect.NavigateToDetail -> navController.navigate("detail/${effect.orderId}")
is OrderEffect.ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
OrderEffect.ShowDeleteConfirmDialog -> showDialog()
}
}
}when (val state = uiState) {
is OrderUiState.Loading -> LoadingScreen()
is OrderUiState.Success -> OrderList(
orders = state.orders,
onOrderClick = { viewModel.onIntent(OrderIntent.SelectOrder(it.id)) }
)
is OrderUiState.Error -> ErrorScreen(
message = state.message,
onRetry = { viewModel.onIntent(OrderIntent.Retry) }
)
}
}这套结构清晰,UI层只负责两件事:渲染State、发送Intent。所有业务逻辑收归ViewModel,Effect负责一次性事件,三者各司其职。
MVVM不是落后——它只是不同的权衡
说完MVI的优势,来说说很多人不愿意说的:MVVM在很多场景下完全够用,甚至更合适。
考虑一个设置页面:几个开关、几个输入框,每个字段独立更新,没有状态联动,没有复杂流程。如果强行套MVI:
sealed class SettingsIntent {
data class UpdateNotification(val enabled: Boolean) : SettingsIntent()
data class UpdateDarkMode(val enabled: Boolean) : SettingsIntent()
data class UpdateLanguage(val lang: String) : SettingsIntent()
// ... 20个字段就是20个子类
}data class SettingsUiState(
val notificationEnabled: Boolean = true,
val darkModeEnabled: Boolean = false,
val language: String = "zh"
// ... 20个字段
)这有什么意义?每次更新一个字段,都要复制整个State对象(copy()),Intent类膨胀成一堆无意义的包装。这就好比搬家公司每次帮你拿一颗葡萄,都要出一辆大卡车——形式正确,但荒诞。
这种场景下,MVVM更直接:
class SettingsViewModel : ViewModel() {
val notificationEnabled = MutableStateFlow(true)
val darkModeEnabled = MutableStateFlow(false)
val language = MutableStateFlow("zh")fun toggleNotification() {
notificationEnabled.update { !it }
}
}简单,直接,没有多余抽象。
我的判断:用sealed class的UiState只在"状态之间有互斥关系"时才有意义。如果你的页面状态本质上就是一堆独立字段的集合,MVVM + data class同样OK,不需要强行套MVI的sealed结构。
真正的选型依据:复杂度分级
与其争论哪个更好,不如建立一套实用的选型框架:
维度 | 用MVVM | 用MVI |
|---|---|---|
页面状态复杂度 | 字段独立,无互斥 | 多状态互斥/联动 |
用户交互密度 | 简单表单/列表 | 游戏/复杂交互流 |
团队规模 | 2-3人,快速迭代 | 多人协作,需要约束 |
是否需要时间旅行调试 | 否 | 是(MVI天然支持) |
单元测试覆盖要求 | 一般 | 高(MVI更易测试) |
实际项目里,这两种架构往往是混用的:主流程页面(订单、支付、购物车)用MVI,配置/设置类页面用MVVM。这不是妥协,这是成熟的工程判断。
最常见的MVI陷阱:把MVVM重命名了一遍
见过太多项目,声称用了MVI,但ViewModel长这样:
// ❌ 这不是MVI,这是改了名字的MVVM
class FakeViewModel : ViewModel() {
data class UiState(
val isLoading: Boolean = false, // 多个布尔值并存
val data: List<Item> = emptyList(),
val hasError: Boolean = false, // isLoading 和 hasError 能同时为 true
val errorMessage: String = ""
)private val _state = MutableStateFlow(UiState())
val state = _state.asStateFlow()// Intent只是函数的别名,没有任何约束
fun sendIntent(intent: MyIntent) {
when (intent) {
is MyIntent.Load -> _state.update { it.copy(isLoading = true) }
// ...
}
}
}问题显而易见:isLoading=true和hasError=true可以同时存在,状态依然不合法。换了个壳子,本质还是MVVM的多流问题。
真正的MVI要求:
• UiState用sealed class,互斥状态在类型层面保证
• 一次性事件(导航、Toast)通过Effect通道传递,不进State
• ViewModel的公开API只有StateFlow和EffectFlow,没有暴露可变状态
• UI只订阅,不直接修改ViewModel的任何可变属性
2026年的新变量:Navigation3 + Hilt如何影响架构选型
说完MVI vs MVVM,聊聊2026年的新变量——这些工具链的变化其实对架构选型有实质影响。
Navigation3进入Compose Multiplatform
Navigation3最大的变化是引入了NavDisplay和基于Entry的导航模型,ViewModel的生命周期与导航Entry绑定,而不是Fragment。这对MVI的影响:Effect里的导航动作现在有了更清晰的生命周期归属,不再需要担心重复触发的问题。
// Navigation3 + MVI的Effect导航
@Composable
fun OrderScreen(
backStack: NavBackStack,
viewModel: OrderViewModel = hiltViewModel()
) {
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is OrderEffect.NavigateToDetail -> {
// Navigation3:直接push到backStack,生命周期清晰
backStack.add(OrderDetailEntry(effect.orderId))
}
}
}
}
}Hilt 2.59.2与模块化的关系
Hilt的稳定迭代让模块化架构的DI变得更可靠。在多模块项目里,每个feature模块独立提供@Module,app模块通过@HiltAndroidApp汇总。这套结构与MVI的单向依赖方向高度吻合——feature模块只依赖domain层,不依赖其他feature,Hilt负责把各层的实现注入进去。
// feature:order 模块
@HiltViewModel
class OrderViewModel @Inject constructor(
private val getOrdersUseCase: GetOrdersUseCase, // domain层
private val deleteOrderUseCase: DeleteOrderUseCase
) : ViewModel() {
// 无需关心数据层实现,Hilt负责注入
}Monzo案例的架构启示
Google官方博客披露的Monzo案例很有意思:他们通过R8优化将性能指标提升35%,背后是模块化构建带来的代码可优化性。架构不只影响开发效率,还直接影响运行时性能——模块边界清晰的代码,R8能更激进地做内联和消除死代码。这是选MVI/MVVM之外,更应该投入精力的方向。
一个完整的现代Android架构全貌
把前面讲的串起来,2026年推荐的Android架构是这样的:
📱 UI层(Compose) 只做渲染State + 发送Intent,零业务逻辑
↕
🧠 ViewModel(MVI) UiState + Intent + Effect,持有UseCase
↕
⚙️ Domain层(UseCase) 纯Kotlin,无Android依赖,业务规则所在地
↕
🗄️ Data层(Repository + DataSource) Room/SQLDelight + Retrofit/OkHttp + 缓存策略
↕
🔌 DI(Hilt) 贯穿各层,模块间依赖由Hilt管理
模块化分包 → feature:home / feature:order / feature:user / core:network / core:db
依赖方向永远是单向的:UI → ViewModel → Domain → Data。任何层都不向上依赖。
测试:MVI架构真正的护城河
MVI的测试优势经常被忽视,但这才是它最值钱的地方。
因为ViewModel的输入(Intent)和输出(State/Effect)都是确定的数据,测试几乎不需要Mock——就像数学函数,给定输入,输出可预测:
@Test
fun `加载订单列表 - 成功场景`() = runTest {
// Given
val fakeOrders = listOf(Order("1", "iPhone"), Order("2", "iPad"))
val fakeUseCase = FakeGetOrdersUseCase(Result.success(fakeOrders))
val viewModel = OrderViewModel(fakeUseCase)// When
viewModel.onIntent(OrderIntent.LoadOrders)// Then
val state = viewModel.uiState.value
assertIs<OrderUiState.Success>(state)
assertEquals(2, state.orders.size)
}@Test
fun `加载失败 - 应该emit Error状态而不是Loading`() = runTest {
val fakeUseCase = FakeGetOrdersUseCase(Result.failure(NetworkException()))
val viewModel = OrderViewModel(fakeUseCase)viewModel.onIntent(OrderIntent.LoadOrders)val state = viewModel.uiState.value
assertIs<OrderUiState.Error>(state)
// 确保不是加载中(MVVM里这个检查很麻烦,MVI天然保证)
assertIsNot<OrderUiState.Loading>(state)
}注意第二个测试的最后一行——assertIsNot<Loading>。在MVVM里,你需要同时收集三个独立Flow来确认这一点,时序还容易出问题。MVI用sealed class,一个断言搞定,因为Error和Loading在结构上就是互斥的。
给个明确的判断
如果你要我给一个明确答案:
新项目,Compose + 多人协作,选MVI。不是因为它更时髦,而是它对状态的强约束在团队规模变大后会持续产生收益。今天多写一点样板代码,日后少踩一堆状态不一致的Bug。
老项目迁移,或者小团队快速原型,MVVM够用。别为了跟上潮流把运行良好的MVVM代码全部重写,技术债不是靠换架构消除的。
不管选哪个,先把依赖方向理清楚。很多架构问题的根源不是MVVM还是MVI,而是UI层直接调Repository,ViewModel里塞了一堆Android Framework代码,Domain层根本不存在。
有意思的下一步方向:Orbit MVI这类轻量级MVI框架值得关注,它把样板代码减少了大半,同时保留了MVI的核心约束。另外随着Compose Multiplatform进入稳定期,ViewModel的共享策略(KMP下ViewModel vs 纯Kotlin状态管理)会是一个越来越绕不开的话题——那又是另一篇文章了。
本文作者关注 Android 架构与工程实践,欢迎在评论区聊聊你们项目的选型经历。