MVI with Jetpack Compose:让你的应用更简洁和整洁

54 阅读5分钟

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

Jetpack Compose 让构建 Android UI 变得简单。但问题在于:当应用变大时,复杂度会快速飙升。按钮、加载动画、错误提示——代码突然散落得到处都是。

MVI 可以帮你规整这一切。

在本指南中,你将学到:

  • 用通俗语言解释 MVI 是什么
  • 为什么 MVI 与 Jetpack Compose 如此契合
  • 如何一步步构建一个真实示例
  • 你应该避免的错误

让我们开始。

什么是 MVI?

MVI 包含三个部分:

字母含义简单解释
MModel当前屏幕上显示的数据
VView你的 Compose UI 代码
IIntent用户想要做的操作(点击按钮、输入文本等)

MVI 是一种架构模式——一套规定应用内数据如何流动的规则。

💡 第一原则: 数据仅单向流动,绝不反向。

数据如何流动?

可以把它想象成一个循环:

用户触发操作
      ↓
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 展示所需的全部数据。对我们的计数器来说,只需要一个数字。

data class CounterState(
    val count: Int = 0
)

📌 为什么用 data class? 因为 Kotlin 数据类会自动提供 .copy() 函数,这是我们遵守规则 2 的关键。

步骤 2:创建 Intents(用户操作)

列出用户能执行的所有操作。我们使用 sealed interface 来固定操作列表——后续无法随意添加其他操作。

sealed interface CounterIntent {
    data object Increment : CounterIntent
    data object Decrement : CounterIntent
    data object Reset : CounterIntent
}

📌 为什么用 sealed interface 和 data object? 这是现代 Kotlin 写法。老教程会用 sealed classobject,虽然也能用,但 sealed interface 更简洁灵活。

步骤 3:创建 ViewModel(核心大脑)

ViewModel 做三件事:

  1. 维护当前状态
  2. 监听意图
  3. 创建新状态
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 非常简单,只读取状态、发送意图,仅此而已。

@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 最常犯的错:

❌ 一个页面使用多个状态对象 一个页面只能有一个状态。如果页面有 loadingStateerrorStatedataState 等多个独立对象,把它们合并到一个数据类中。

❌ 把逻辑写在 UI 里 如果在 @Composable 函数里写 ifwhen 或数学计算,把它移到 ViewModel。UI 只负责展示。

❌ 直接修改状态 永远用 .copy(),绝不直接修改状态对象内的值。


什么时候该用 MVI?

适合用 MVI 的场景:

  • 页面有大量用户操作
  • 页面展示来自不同来源的数据
  • 希望快速排查修复 Bug
  • 团队在扩大,需要清晰的代码结构

可以不用 MVI 的场景:

  • 页面非常简单(仅展示静态文本)
  • 你在快速开发原型

但即便简单页面,MVI 也能让项目随迭代保持整洁。现在多花一点功夫,后续节省大量时间。


快速总结

概念作用
State (Model)存储 UI 展示的所有数据
Intent描述用户想要做什么
ViewModel接收意图,创建新状态
UI (View)展示状态,发送意图

黄金法则:

UI 只是 State 的镜子,仅此而已。

接下来学什么?

熟练掌握该模式后,你可以探索:

  • 添加副作用(如网络请求、页面导航)
  • StateFlow 替代 mutableStateOf 获得更强控制
  • 处理一次性事件(如显示 Snackbar)

但现在,先打好基础。实现计数器应用,再尝试给它加更多功能。

编码愉快!🚀