MVI — 移动应用中 Model-View-Intent 模式的本质

作者: IT Sectr 发布日期: 2026-02-16 阅读时间: 10 分钟

MVI(Model-View-Intent)— 一种基于单向数据流和不可变状态的响应式架构模式。与 MVVM 不同,ViewModel 可以有多个 StateFlow,而 MVI 定义了单一状态(State)、不可变的意图(Intent)和纯 reducer 函数(Reducer)。MVI 保证了屏幕状态在任何时刻的可预测性。该模式由 Mosby 和 Orbit 库在 Android 社区中推广。更多信息 — 见 Arkadii Ivanov 的 MVIKotlin

要点

  • MVI — Model(状态)、View(展示)、Intent(用户意图)— 响应式循环
  • Unidirectional data flow — 数据沿一个方向流动:Intent → Reducer → State → View
  • Immutable State — 屏幕状态 — 不可变对象,每次更改时重新创建
  • Reducer — 纯函数,接收当前状态和 Intent,返回新状态
  • Side effects — 副作用(网络、数据库)与 Reducer 分开处理,通过 Middleware

什么是 MVI:Model-View-Intent 模式的本质

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 → Statefun reduce(state, intent) -> state
Middleware处理副作用网络请求,写入数据库

MVI 循环 由五个步骤组成:1)View 发送 Intent(例如 LoadUser(42));2)Middleware(EffectHandler)执行副作用 — 网络请求;3)结果作为新 Intent 返回系统;4)Reducer 接收当前状态和 Intent,创建新状态;5)View 接收新状态并重新渲染。每一步都可预测且可独立测试。

Android 中的 MVI:Kotlin 中的 Intent、Reducer、State

Android 上的 MVI 通过 sealed 类实现 Intent 和 State,通过 ViewModel 实现 MVI 逻辑,通过 Jetpack Compose 实现响应式展示。ViewModel 从 View 接收 Intent,将副作用委托给 Middleware,执行 Reducer 并通过 StateFlow 发布新状态。Jetpack Compose 在 state 更改时重新绘制 UI — 非常适合 MVI 循环。

kotlin
// 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:Swift 中的单向数据流

iOS 上的 MVI 无需 Combine-ViewModel,通过 Intent → State 循环实现。View 通过闭包(closure)发送 Intent,Reducer — 纯函数,State — 带有不可变字段的 struct。SwiftUI 在 State 更改时重新绘制 View,无需额外的 @Published 属性即可完美融入 MVI 循环。iOS 上的 MVI 在从 Redux(JavaScript)迁移过来的 SwiftUI 开发者社区中尤其受欢迎。

swift
// 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 的比较:何时选择 MVI

MVI 和 MVVM 解决相同的任务 — 组织表示层(Presentation layer)— 但采用不同的状态管理方法。MVVM 允许多个响应式来源(LiveData、@Published),这可能导致不一致。MVI 保证每一时刻只有一个状态,使其更严格和可预测,但增加了代码量。

标准MVVMMVI
状态多个 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。

MVI 最佳实践与常见错误

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 和 MVVM 的主要区别是什么?

MVI 使用单个不可变的 sealed State 类和通过 Reducer 的单向数据流。MVVM 允许多个 LiveData/StateFlow 进行双向绑定。MVI 在类型级别保证状态一致性 — 不可能同时得到 loading=true 和 user=null。MVVM 依赖开发者的纪律。

有哪些适用于 Android 的 MVI 库?

主要: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 而不需要库就足够了。

MVI 是否需要单独的库?

不需要 — sealed Intent + sealed State + ViewModel + StateFlow 提供了无需依赖的 MVI。库(MVIKotlin、Orbit、TCA)添加了 Middleware、副作用测试和 DI 集成。对于简单项目,库的重量是不合理的。对于有 20 个以上屏幕的复杂项目,库通过结构化的效应处理物有所值。

MVI 适合 iOS 还是只是 Android 的模式?

MVI 通过 TCA(The Composable Architecture)非常适合 iOS — SwiftUI 社区最流行的架构。TCA 实际上是 MVI + Redux + Combine。在 iOS 上,也可以不用 TCA,通过 ObservableObject 和纯 reducer 函数实现 MVI。带有不可变 State 的 SwiftUI 完美契合 MVI 循环。

如何测试 MVI?

Reducer 作为纯函数通过单元测试进行测试:给定初始 State,发送 Intent,检查最终 State。Middleware 使用模拟仓库进行测试:检查 LoadUser 后是否调用了 getUser。ViewModel 测试:发送 Intent,检查 StateFlow。MVI 比 MVVM 更容易测试,因为 Reducer 是没有隐藏依赖的纯函数。

总结

  • MVI(Model-View-Intent)— 具有单向流和单一状态的响应式模式
  • Sealed State — 在类型级别保证一致性,排除无效组合
  • Reducer — 纯函数 State + Intent → State,无需模拟对象即可测试
  • Middleware — 用于副作用(网络、数据库、导航)的独立层
  • MVI vs MVVM — MVI 更严格和可预测,MVVM 更简单和快速
  • Android — 复杂屏幕使用 MVIKotlin 或 Orbit;简单屏幕使用 MVVM
  • iOS — TCA(The Composable Architecture)— SwiftUI 上的 MVI 标准

我们将开发一款交钥匙移动应用程序

IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。

讨论项目

另请阅读