MVI(Model-View-Intent)— 一种基于单向数据流和不可变状态的响应式架构模式。与 MVVM 不同,ViewModel 可以有多个 StateFlow,而 MVI 定义了单一状态(State)、不可变的意图(Intent)和纯 reducer 函数(Reducer)。MVI 保证了屏幕状态在任何时刻的可预测性。该模式由 Mosby 和 Orbit 库在 Android 社区中推广。更多信息 — 见 Arkadii Ivanov 的 MVIKotlin。
要点
MVI(Model-View-Intent)— 一种基于 Redux 和 Cycle.js 原则构建的响应式架构模式。Model — 屏幕的不可变状态,Intent — 用户或系统的意图,View — 订阅状态并发送 Intent。数据在循环中流动:用户与 View 交互 → View 创建 Intent → Intent 由 Reducer 处理 → Reducer 创建新状态 → View 接收新状态并重新渲染。
MVI 与 MVVM 的主要区别 — 单一真实来源(Single Source of Truth)。在 MVVM 中,ViewModel 可以有多个 LiveData/StateFlow(userState、loadingState、errorState),导致不一致:loading=true 和 user=null 同时出现。在 MVI 中,只有一个 sealed class/interface State 描述整个屏幕状态。在任何时刻,屏幕状态都是唯一确定的 — 不可能在数据已加载时得到 loading=true。在 IT Sectr,我们将 MVI 应用于逻辑复杂的屏幕 — 订单表单、多步骤注册、金融屏幕 — 状态的可预测性至关重要。
| 组件 | 在 MVI 中的角色 | 示例 |
|---|---|---|
| Intent | 用户或系统意图 | LoadUser, Refresh, SubmitForm |
| State | 屏幕的不可变状态 | sealed class UserState |
| Reducer | 纯函数:State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | 处理副作用 | 网络请求,写入数据库 |
MVI 循环 由五个步骤组成:1)View 发送 Intent(例如 LoadUser(42));2)Middleware(EffectHandler)执行副作用 — 网络请求;3)结果作为新 Intent 返回系统;4)Reducer 接收当前状态和 Intent,创建新状态;5)View 接收新状态并重新渲染。每一步都可预测且可独立测试。
Android 上的 MVI 通过 sealed 类实现 Intent 和 State,通过 ViewModel 实现 MVI 逻辑,通过 Jetpack Compose 实现响应式展示。ViewModel 从 View 接收 Intent,将副作用委托给 Middleware,执行 Reducer 并通过 StateFlow 发布新状态。Jetpack Compose 在 state 更改时重新绘制 UI — 非常适合 MVI 循环。
// Intent — 用户意图
sealed interface UserIntent {
data class LoadUser(val userId: Int) : UserIntent
data object Refresh : UserIntent
}
// State — 屏幕的单一状态
sealed interface UserState {
data object Idle : UserState
data object Loading : UserState
data class Success(val user: User) : UserState
data class Error(val message: String) : UserState
}
// Reducer — 纯函数
object UserReducer {
fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
is UserIntent.LoadUser -> UserState.Loading
is UserIntent.Refresh -> UserState.Loading
}
}
// 使用 MVI 的 ViewModel
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow<UserState>(UserState.Idle)
val state: StateFlow<UserState> = _state.asStateFlow()
fun process(intent: UserIntent) {
val newState = UserReducer.reduce(_state.value, intent)
_state.value = newState
when (intent) {
is UserIntent.LoadUser -> loadUser(intent.userId)
is UserIntent.Refresh -> loadUser(/* 上一个 ID */)
}
}
private fun loadUser(userId: Int) {
viewModelScope.launch {
repository.getUser(userId)
.onSuccess { user ->
_state.value = UserState.Success(user)
}
.onFailure { e ->
_state.value = UserState.Error(e.message ?: "Error")
}
}
}
}
// View 发送 Intent
fun UserScreen(viewModel: UserViewModel) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) { viewModel.process(UserIntent.LoadUser(42)) }
when (state) {
is UserState.Loading -> CircularProgressIndicator()
is UserState.Success -> UserCard((state as UserState.Success).user)
is UserState.Error -> ErrorView((state as UserState.Error).message)
is UserState.Idle -> Text("Press to load")
}
}
Middleware 与副作用 — 在 MVI 中,纯 Reducer 不能执行网络请求。Middleware(也称为 EffectHandler 或 Bootstrapper)处理 Intent,执行副作用并将新 Intent 发射回循环中。Orbit MVI 和 MVIKotlin 库提供了内置的 Middleware 支持,具有可测试的效应。没有 Middleware,MVI 就会退化为带有额外 Intent 和 State 结构的 MVVM。
Arkadii Ivanov 的 MVIKotlin — 最流行的 Kotlin Multiplatform MVI 库。支持 Android、iOS、web 和 JVM。提供以下组件:Store(ViewModel)、Bootstrapper(初始效应)、Reducer、Middleware。截至 2025 年 10 月,该库在 GitHub 上已收集 2.5K 星标,并用于商业项目,包括俄罗斯大银行的应用程序。在 IT Sectr,我们使用 MVIKotlin 进行具有共享业务逻辑的跨平台 KMP 项目。
iOS 上的 MVI 无需 Combine-ViewModel,通过 Intent → State 循环实现。View 通过闭包(closure)发送 Intent,Reducer — 纯函数,State — 带有不可变字段的 struct。SwiftUI 在 State 更改时重新绘制 View,无需额外的 @Published 属性即可完美融入 MVI 循环。iOS 上的 MVI 在从 Redux(JavaScript)迁移过来的 SwiftUI 开发者社区中尤其受欢迎。
// State — 不可变结构
struct UserState: Equatable {
var user: User?
var isLoading = false
var errorMessage: String?
}
// Intent — 带有意图的枚举
enum UserIntent {
case loadUser(id: Int)
case refresh
case userLoaded(User)
case loadFailed(Error)
}
// Reducer — 纯函数
func userReducer(state: UserState, intent: UserIntent) -> UserState {
var newState = state
switch intent {
case .loadUser, .refresh:
newState.isLoading = true
newState.errorMessage = nil
case .userLoaded(let user):
newState.isLoading = false
newState.user = user
case .loadFailed(let error):
newState.isLoading = false
newState.errorMessage = error.localizedDescription
}
return newState
}
// Store — 拥有状态并管理效应
final class UserStore: ObservableObject {
@Published private(set) var state = UserState()
private let service: UserService
init(service: UserService) {
self.service = service
}
func dispatch(_ intent: UserIntent) {
// 1. Reducer 更新状态
state = userReducer(state: state, intent: intent)
// 2. 副作用(如果需要)
switch intent {
case .loadUser(let id), .refresh:
service.fetchUser(id: id) { [weak self] result in
switch result {
case .success(let user):
self?.dispatch(.userLoaded(user))
case .failure(let error):
self?.dispatch(.loadFailed(error))
}
}
default: break
}
}
}
TCA(The Composable Architecture) — Point-Free 推出的最流行的 iOS MVI 实现,基于 SwiftUI 和 Combine 构建。TCA 提供 Store、Reducer、Effect 和 Environment。截至 2025 年 10 月,GitHub 星标数超过 13K — 这是 iOS 上 MVI 的事实标准。TCA 用于 Starbucks、Airbnb(部分)和许多独立项目。与手写 MVI 不同,TCA 开箱即用地解决了测试、导航和副作用的问题。
iOS 上的 MVI 与 MVVM — TCA/MVI 提供了状态的可预测性,但需要更多模板代码(Reducer、State、Action)。带 @Published 的 MVVM 对于简单屏幕更简单。我们在 IT Sectr 对 80% 的屏幕使用 MVVM,对 20% 的复杂屏幕使用 MVI(TCA)— 金融交易、多步骤表单、拖放界面,这些地方状态错误可能会让用户付出金钱代价。
MVI 和 MVVM 解决相同的任务 — 组织表示层(Presentation layer)— 但采用不同的状态管理方法。MVVM 允许多个响应式来源(LiveData、@Published),这可能导致不一致。MVI 保证每一时刻只有一个状态,使其更严格和可预测,但增加了代码量。
| 标准 | MVVM | MVI |
|---|---|---|
| 状态 | 多个 LiveData/StateFlow | 单个 sealed class State |
| 数据流 | 双向(View → ViewModel, LiveData → View) | 单向(Intent → Reducer → State → View) |
| 副作用 | 直接在 ViewModel 中 | 通过 Middleware/EffectHandler |
| 测试 | ViewModel 的单元测试 | Reducer + Middleware 的单元测试 |
| 模板代码 | 最少 | Reducer + State + Intent + Middleware |
何时选择 MVI — 状态必须严格确定性的屏幕:金融操作、电子商务购物车、每个步骤都有验证的多步骤表单。在这些场景中,状态错误的成本(例如,由于两个 LiveData 竞争导致购物车金额少显示一个商品)高于额外代码的成本。在 MVVM 中,您依赖团队的纪律;在 MVI 中,您依赖架构。
何时 MVVM 足够 — 80% 的标准屏幕:用户列表、个人资料、设置、新闻流。这里单一状态是多余的,而额外的 MVI 结构会减慢开发速度。在 IT Sectr,规则是:如果屏幕有 3 个以上可能的状态和转换(加载 → 数据 → 错误 → 重试 → 加载 → 数据)— MVI。如果屏幕有 1-2 个异步操作 — MVVM。
Sealed State — MVI 的最佳实践。状态定义为 sealed class/interface,包含 Loading、Success(data)、Error(message) 变体。这保证了 View 不会进入不一致状态 — 在 loading=true 时无法显示数据,因为 Loading 和 Success 是不同的类。所有与状态相关的数据都位于 sealed 变体内:Success 包含用户,Error 包含错误消息。
Reducer 必须保持为纯函数 — 没有 API 调用、数据库、SharedPreferences。纯函数接收 State 和 Intent,返回 State。副作用(网络、数据库、导航、Toast)在 Middleware 或调用 Reducer 后的 Store.dispatch 中处理。如果 Reducer 被副作用污染,MVI 将失去可测试性和可预测性 — 您得到的是带有额外结构但没有优势的 MVVM。
常见错误 — 将 State 声明为带有 nullable 字段的 data class 而不是 sealed class:data class UserState(val user: User?, val isLoading: Boolean, val error: String?)。这是 MVVM 的等价物,不是 MVI — View 必须检查组和的有效性。在 sealed 方法中,无效组合(isLoading=true 且 user!=null)在类型级别上是不可能的。第二个错误 — 将业务逻辑放在 Intent 中(Intent.LoadUserBeforeXHours),而不是创建简单的命令式 Intent(Intent.LoadUser),并将业务逻辑放在 Middleware 中。
常见问题
MVI 使用单个不可变的 sealed State 类和通过 Reducer 的单向数据流。MVVM 允许多个 LiveData/StateFlow 进行双向绑定。MVI 在类型级别保证状态一致性 — 不可能同时得到 loading=true 和 user=null。MVVM 依赖开发者的纪律。
主要:MVIKotlin(Arkadii Ivanov,2.5K 星标,Kotlin Multiplatform)、Orbit MVI(BabyJ,1.3K 星标)、Mobius(Spotify,Kotlin/Java)。MVIKotlin — 最流行的 Kotlin 库,Orbit — 最容易学习。三者都支持可测试的 Reducer 和 Middleware。对于 Jetpack Compose,通过 sealed State + Reducer 编写简单的 MVI 而不需要库就足够了。
不需要 — sealed Intent + sealed State + ViewModel + StateFlow 提供了无需依赖的 MVI。库(MVIKotlin、Orbit、TCA)添加了 Middleware、副作用测试和 DI 集成。对于简单项目,库的重量是不合理的。对于有 20 个以上屏幕的复杂项目,库通过结构化的效应处理物有所值。
MVI 通过 TCA(The Composable Architecture)非常适合 iOS — SwiftUI 社区最流行的架构。TCA 实际上是 MVI + Redux + Combine。在 iOS 上,也可以不用 TCA,通过 ObservableObject 和纯 reducer 函数实现 MVI。带有不可变 State 的 SwiftUI 完美契合 MVI 循环。
Reducer 作为纯函数通过单元测试进行测试:给定初始 State,发送 Intent,检查最终 State。Middleware 使用模拟仓库进行测试:检查 LoadUser 后是否调用了 getUser。ViewModel 测试:发送 Intent,检查 StateFlow。MVI 比 MVVM 更容易测试,因为 Reducer 是没有隐藏依赖的纯函数。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。