你的应用在不断变大,代码变得杂乱无章。让我们来解决这个问题。

Jetpack Compose 让构建 Android UI 变得简单。但问题在于:当应用变大时,复杂度会快速飙升。按钮、加载动画、错误提示------代码突然散落得到处都是。
MVI 可以帮你规整这一切。
在本指南中,你将学到:
- 用通俗语言解释 MVI 是什么
- 为什么 MVI 与 Jetpack Compose 如此契合
- 如何一步步构建一个真实示例
- 你应该避免的错误
让我们开始。
什么是 MVI?
MVI 包含三个部分:
| 字母 | 含义 | 简单解释 |
|---|---|---|
| M | Model | 当前屏幕上显示的数据 |
| V | View | 你的 Compose UI 代码 |
| I | Intent | 用户想要做的操作(点击按钮、输入文本等) |
MVI 是一种架构模式------一套规定应用内数据如何流动的规则。
💡 第一原则: 数据仅单向流动,绝不反向。
数据如何流动?
可以把它想象成一个循环:
markdown
用户触发操作
↓
UI 发送 Intent
↓
ViewModel 接收 Intent
↓
ViewModel 创建新 State
↓
UI 展示新 State
↓
(用户再次触发操作......)
就是这样。应用中的每一次交互都遵循这个循环。
必须遵守的三条规则
编写任何代码前,请记住这些:
规则 1:一个页面 = 一个状态
每个页面仅有一个状态对象。不是两个,不是五个,仅此一个。
规则 2:绝不直接修改状态
不要直接编辑状态,而是创建一个包含新值的副本 。(下文会展示如何使用 .copy())
规则 3:UI 不做逻辑判断
UI 只有一个任务:按状态展示内容。它不做计算,不做决策,只负责展示与发送意图。
为什么 MVI 与 Compose 如此契合
Jetpack Compose 本身就会监听状态变化,状态改变时,页面会自动更新。
MVI 为这套系统提供清晰的结构:
- ✅ 你始终知道数据在哪里
- ✅ 你始终知道数据如何变化
- ✅ 你能更快定位 Bug
二者堪称完美搭配。
动手实践:计数器应用
我们将做一个简单页面,展示一个数字,用户可以:
- 给数字加 1
- 给数字减 1
- 将数字重置为 0
应用虽小,但能教会你完整的 MVI 模式。
步骤 1:创建 State(Model)
State 包含 UI 展示所需的全部数据。对我们的计数器来说,只需要一个数字。
kotlin
data class CounterState(
val count: Int = 0
)
📌 为什么用 data class? 因为 Kotlin 数据类会自动提供
.copy()函数,这是我们遵守规则 2 的关键。
步骤 2:创建 Intents(用户操作)
列出用户能执行的所有操作。我们使用 sealed interface 来固定操作列表------后续无法随意添加其他操作。
kotlin
sealed interface CounterIntent {
data object Increment : CounterIntent
data object Decrement : CounterIntent
data object Reset : CounterIntent
}
📌 为什么用 sealed interface 和 data object? 这是现代 Kotlin 写法。老教程会用
sealed class和object,虽然也能用,但sealed interface更简洁灵活。
步骤 3:创建 ViewModel(核心大脑)
ViewModel 做三件事:
- 维护当前状态
- 监听意图
- 创建新状态
kotlin
class CounterViewModel : ViewModel() {
// 私有------UI 无法直接修改
private val _state = mutableStateOf(CounterState())
// 公开------UI 仅可读
val state: State<CounterState> = _state
// 在此处理所有用户操作
fun onIntent(intent: CounterIntent) {
when (intent) {
is CounterIntent.Increment -> {
_state.value = _state.value.copy(
count = _state.value.count + 1
)
}
is CounterIntent.Decrement -> {
_state.value = _state.value.copy(
count = _state.value.count - 1
)
}
is CounterIntent.Reset -> {
_state.value = CounterState() // 恢复默认
}
}
}
}
📌 注意
.copy()的用法。 我们绝不会写_state.value.count = 5,始终创建新副本。这让状态安全且可预测。
步骤 4:创建 UI(View)
UI 非常简单,只读取状态、发送意图,仅此而已。
kotlin
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val state by viewModel.state
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Count: ${state.count}",
fontSize = 24.sp
)
Row {
Button(onClick = { viewModel.onIntent(CounterIntent.Decrement) }) {
Text("-")
}
Button(onClick = { viewModel.onIntent(CounterIntent.Increment) }) {
Text("+")
}
}
Button(onClick = { viewModel.onIntent(CounterIntent.Reset) }) {
Text("Reset")
}
}
}
📌 看代码多简洁。 UI 零逻辑,只读取
state.count并调用onIntent(),仅此而已。
常见错误(务必避开)
开发者使用 MVI 最常犯的错:
❌ 一个页面使用多个状态对象 一个页面只能有一个 状态。如果页面有 loadingState、errorState、dataState 等多个独立对象,把它们合并到一个数据类中。
❌ 把逻辑写在 UI 里 如果在 @Composable 函数里写 if、when 或数学计算,把它移到 ViewModel。UI 只负责展示。
❌ 直接修改状态 永远用 .copy(),绝不直接修改状态对象内的值。
什么时候该用 MVI?
适合用 MVI 的场景:
- 页面有大量用户操作
- 页面展示来自不同来源的数据
- 希望快速排查修复 Bug
- 团队在扩大,需要清晰的代码结构
可以不用 MVI 的场景:
- 页面非常简单(仅展示静态文本)
- 你在快速开发原型
但即便简单页面,MVI 也能让项目随迭代保持整洁。现在多花一点功夫,后续节省大量时间。
快速总结
| 概念 | 作用 |
|---|---|
| State (Model) | 存储 UI 展示的所有数据 |
| Intent | 描述用户想要做什么 |
| ViewModel | 接收意图,创建新状态 |
| UI (View) | 展示状态,发送意图 |
黄金法则:
UI 只是 State 的镜子,仅此而已。
接下来学什么?
熟练掌握该模式后,你可以探索:
- 添加副作用(如网络请求、页面导航)
- 用
StateFlow替代mutableStateOf获得更强控制 - 处理一次性事件(如显示 Snackbar)
但现在,先打好基础。实现计数器应用,再尝试给它加更多功能。
编码愉快!🚀
要不要我帮你把这篇精简成一页速记版,方便直接复制到项目里当参考?