目录
  1. 1. 一、MVI 架构概述
    1. 1.1. 1.1 什么是 MVI
    2. 1.2. 1.2 单向数据流原理
    3. 1.3. 1.3 为什么需要 MVI
  2. 2. 二、MVI 核心组件设计
    1. 2.1. 2.1 State —— 不可变的界面状态
    2. 2.2. 2.2 Intent / Action —— 用户操作意图
    3. 2.3. 2.3 Effect / SideEffect —— 一次性副作用
    4. 2.4. 2.4 Reducer —— 纯函数状态变换
  3. 3. 三、从零实现 MVI —— 不依赖第三方库
    1. 3.1. 3.1 ViewModel 基类设计
    2. 3.2. 3.2 完整的登录示例 —— Reducer 模式
    3. 3.3. 3.3 Fragment / Activity 中的使用
    4. 3.4. 3.4 使用 combine 合并多个 State 流
  4. 4. 四、MVI 与 Jetpack 的集成
    1. 4.1. 4.1 SavedStateHandle 与进程死亡恢复
    2. 4.2. 4.2 与 ViewModel 的集成要点
    3. 4.3. 4.3 使用 sealed class 替代枚举进行状态建模
  5. 5. 五、MVI vs MVVM vs MVP 对比
    1. 5.1. 5.1 状态管理维度
    2. 5.2. 5.2 代码量对比 —— 同一个登录功能
    3. 5.3. 5.3 何时选择 MVI
  6. 6. 六、MVI 的测试策略
    1. 6.1. 6.1 Reducer 单元测试
    2. 6.2. 6.2 ViewModel 测试 —— 使用 Turbine
  7. 7. 七、MVI 实战中的常见问题与最佳实践
    1. 7.1. 7.1 状态爆炸问题
    2. 7.2. 7.2 过度标准化(Over-normalization)
    3. 7.3. 7.3 副作用顺序问题
    4. 7.4. 7.4 Process Death 的完整恢复
  8. 8. 八、MVI 在 Jetpack Compose 中的使用
    1. 8.1. 8.1 Compose 天然适合 MVI
    2. 8.2. 8.2 使用 lifecycle-runtime-compose 安全收集
  9. 9. 九、MVI 架构的演进 —— orbit-mvi / Mavericks
    1. 9.1. 9.1 Orbit MVI
    2. 9.2. 9.2 Airbnb Mavericks
  10. 10. 十、总结
  11. 11. 参考资源
【架构篇】MVI架构

一、MVI 架构概述

1.1 什么是 MVI

MVI(Model-View-Intent)是一种基于单向数据流(Unidirectional Data Flow, UDF)的前端架构模式。它的核心思想源自 Rx 社区和函数式编程:将应用的状态变化抽象为一个纯函数 (State, Action) -> State,所有状态变更都通过一个确定的、可追溯的管道完成。

MVI 的三个角色:

  • Model:不是传统 MVC/MVP 中的数据模型,而是界面状态(UI State)。它是一个不可变(immutable)的数据类,完整描述当前屏幕的所有 UI 状态。
  • View:渲染 Model 的展示层。View 将用户操作转换为 Intent 发送出去,自身不持有任何业务逻辑。
  • Intent:用户的操作意图。注意这里不是 Android 的 Intent 类,而是一个密封类(sealed class),表示用户想要执行的动作。

1.2 单向数据流原理

      Intent (Action)
View ──────────────────> ViewModel/Reducer
^ │
│ │ new State
│ │
└───────────────────────────┘
State (Flow)

数据只沿一个方向流动:

  1. View 发出 Intent(用户点击按钮、输入文本等)
  2. ViewModel 接收 Intent,通过 Reducer 计算新 State
  3. 新 State 推送给 View 进行渲染
  4. View 渲染完成后等待下一个 Intent

这个循环保证了:任何时刻的 UI 状态都是确定的、可追溯的,不会出现多个入口修改状态导致的不一致问题。

1.3 为什么需要 MVI

传统的 MVVM 中,一个 ViewModel 可能有多个 MutableLiveDataMutableStateFlow 分别管理不同维度的状态:

// MVVM 的常见写法 —— 多状态源
class LoginViewModel : ViewModel() {
val isLoading = MutableLiveData<Boolean>()
val errorMessage = MutableLiveData<String>()
val loginSuccess = MutableLiveData<Boolean>()
val username = MutableLiveData<String>()
val password = MutableLiveData<String>()
}

这种写法的隐患:

  • 状态不一致:可能出现 isLoading = trueloginSuccess = true 同时为真的非法状态
  • 状态变更分散:排查问题时需要追踪多个 LiveData 的修改点
  • 测试困难:需要验证多个状态字段的组合,而非一个整体状态

MVI 将所有相关状态收敛到一个 data class:

data class LoginState(
val username: String = "",
val password: String = "",
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isLoginSuccess: Boolean = false
)

整个屏幕只有一个 State 对象,单次赋值,从根本上消除状态不一致的可能。


二、MVI 核心组件设计

2.1 State —— 不可变的界面状态

State 必须是 data class 且所有属性有默认值(代表初始状态),使用 val 声明:

data class SearchState(
val query: String = "",
val results: List<SearchResult> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val hasMore: Boolean = false,
val currentPage: Int = 0
) {
// 计算属性:是否显示空状态
val showEmptyState: Boolean
get() = !isLoading && error == null && results.isEmpty() && query.isNotEmpty()
}

State 的不可变性意味着什么?

每次状态变化不是修改原对象,而是创建一个全新的 State 副本:

// 错误:修改原对象
state.isLoading = true // 编译错误,val 不可重新赋值

// 正确:创建新副本
val newState = state.copy(isLoading = true)

使用 copy() 是 Kotlin data class 的内置能力,它会创建一个新对象,仅覆盖指定的字段。这保证了:

  • 状态变化可追踪(diff 工具可以对比前后的 State)
  • 线程安全(不可变对象天然线程安全)
  • 配合 distinctUntilChanged 避免重复渲染

2.2 Intent / Action —— 用户操作意图

Intent 使用 sealed class(或 sealed interface in Kotlin 1.5+):

sealed class SearchIntent {
data class UpdateQuery(val query: String) : SearchIntent()
object Search : SearchIntent()
object LoadMore : SearchIntent()
data class Retry(val page: Int) : SearchIntent()
object ClearResults : SearchIntent()
}

为什么用 sealed class?

  • 穷举检查:when 表达式可以覆盖所有子类,新增 Intent 时编译器强制更新所有使用点
  • 类型安全:每个 Intent 可以携带不同的参数类型
  • 可读性强:所有可能的用户动作一目了然

2.3 Effect / SideEffect —— 一次性副作用

有些操作不应该持久在 State 中,例如显示 Toast、导航到新页面、短暂显示 Snackbar。这些是一次性的副作用(Side Effect / One-shot Event),应该使用单独的 Channel:

sealed class LoginEffect {
data class ShowToast(val message: String) : LoginEffect()
object NavigateToHome : LoginEffect()
data class ShowError(val error: String) : LoginEffect()
}

为什么不用 State 承载副作用?

如果 showToast 存在 State 中:

  • Toast 显示后需要额外的代码将其置空
  • 屏幕旋转 / 配置变更时 State 恢复,Toast 被重新触发显示
  • State 的语义是”当前状态”,而 Toast 是”一次性事件”

使用 ChannelSharedFlow 发送 Effect:

private val _effect = Channel<LoginEffect>(Channel.BUFFERED)
val effect: Flow<LoginEffect> = _effect.receiveAsFlow()

2.4 Reducer —— 纯函数状态变换

Reducer 是 MVI 的核心:(State, Action) -> State。它是一个纯函数,给定当前 State 和 Action,返回新 State。

fun searchReducer(state: SearchState, intent: SearchIntent): SearchState {
return when (intent) {
is SearchIntent.UpdateQuery -> state.copy(
query = intent.query,
results = if (intent.query.isEmpty()) emptyList() else state.results
)
SearchIntent.Search -> state.copy(
isLoading = true,
error = null,
currentPage = 1
)
SearchIntent.LoadMore -> state.copy(
isLoading = true,
currentPage = state.currentPage + 1
)
is SearchIntent.Retry -> state.copy(
isLoading = true,
error = null
)
SearchIntent.ClearResults -> state.copy(
results = emptyList(),
query = "",
currentPage = 0
)
}
}

纯函数的意义:

  • 输入相同,输出必然相同(无副作用)
  • 可独立单元测试,不需要 mock 任何东西
  • 状态变化的逻辑集中在一个地方,易于审查和维护

三、从零实现 MVI —— 不依赖第三方库

3.1 ViewModel 基类设计

以下是一个完整的 MVI ViewModel 基类实现,使用 Kotlin Flow 和 Channel:

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

/**
* MVI ViewModel 基类
* @param S State 类型
* @param I Intent/Action 类型
* @param E Effect/SideEffect 类型
*/
abstract class MviViewModel<S, I, E>(
initialState: S
) : ViewModel() {

// 状态存储:MutableStateFlow
private val _state = MutableStateFlow(initialState)
val state: StateFlow<S> = _state.asStateFlow()

// 副作用通道:Channel 保证一次性消费
private val _effect = Channel<E>(Channel.BUFFERED)
val effect: Flow<E> = _effect.receiveAsFlow()

// 获取当前状态(线程安全)
protected fun currentState(): S = _state.value

/**
* 更新状态 —— 原子操作
*/
protected fun setState(reducer: (S) -> S) {
_state.update { current ->
reducer(current)
}
}

/**
* 发送副作用事件
*/
protected fun sendEffect(effect: E) {
viewModelScope.launch {
_effect.send(effect)
}
}

/**
* 接收 Intent 的入口 —— 子类必须实现
*/
abstract fun onIntent(intent: I)
}

3.2 完整的登录示例 —— Reducer 模式

下面是一个完整的登录功能实现,演示 MVI 的所有核心概念:

// ========== State ==========
data class LoginState(
val username: String = "",
val password: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val isLoginSuccess: Boolean = false,
val usernameError: String? = null,
val passwordError: String? = null,
val isFormValid: Boolean = false
)

// ========== Intent ==========
sealed class LoginIntent {
data class UpdateUsername(val username: String) : LoginIntent()
data class UpdatePassword(val password: String) : LoginIntent()
object Login : LoginIntent()
object DismissError : LoginIntent()
}

// ========== Effect ==========
sealed class LoginEffect {
data class ShowToast(val message: String) : LoginEffect()
object NavigateToHome : LoginEffect()
}

// ========== ViewModel ==========
class LoginViewModel(
private val loginUseCase: LoginUseCase,
private val validateUsernameUseCase: ValidateUsernameUseCase,
private val validatePasswordUseCase: ValidatePasswordUseCase
) : MviViewModel<LoginState, LoginIntent, LoginEffect>(LoginState()) {

override fun onIntent(intent: LoginIntent) {
when (intent) {
is LoginIntent.UpdateUsername -> handleUpdateUsername(intent.username)
is LoginIntent.UpdatePassword -> handleUpdatePassword(intent.password)
LoginIntent.Login -> handleLogin()
LoginIntent.DismissError -> handleDismissError()
}
}

private fun handleUpdateUsername(username: String) {
val usernameError = validateUsernameUseCase(username)
setState { current ->
current.copy(
username = username,
usernameError = usernameError,
isFormValid = usernameError == null && current.passwordError == null
&& username.isNotEmpty() && current.password.isNotEmpty()
)
}
}

private fun handleUpdatePassword(password: String) {
val passwordError = validatePasswordUseCase(password)
setState { current ->
current.copy(
password = password,
passwordError = passwordError,
isFormValid = passwordError == null && current.usernameError == null
&& password.isNotEmpty() && current.username.isNotEmpty()
)
}
}

private fun handleLogin() {
// 防止重复提交
if (currentState().isLoading) return

setState { it.copy(isLoading = true, error = null) }

viewModelScope.launch {
loginUseCase(currentState().username, currentState().password)
.onSuccess {
setState { it.copy(isLoading = false, isLoginSuccess = true) }
sendEffect(LoginEffect.ShowToast("登录成功"))
sendEffect(LoginEffect.NavigateToHome)
}
.onFailure { e ->
setState { it.copy(
isLoading = false,
error = e.message ?: "登录失败"
)}
sendEffect(LoginEffect.ShowToast(e.message ?: "登录失败"))
}
}
}

private fun handleDismissError() {
setState { it.copy(error = null) }
}
}

3.3 Fragment / Activity 中的使用

class LoginFragment : Fragment() {

private val viewModel: LoginViewModel by viewModels()
private var binding: FragmentLoginBinding? = null

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentLoginBinding.inflate(inflater, container, false)
return binding!!.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

// 1. 观察 State
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state ->
renderState(state)
}
}
}

// 2. 观察 Effect(一次性事件)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.effect.collect { effect ->
handleEffect(effect)
}
}
}

// 3. 发送 Intent
binding?.apply {
etUsername.doAfterTextChanged { text ->
viewModel.onIntent(LoginIntent.UpdateUsername(text ?: ""))
}
etPassword.doAfterTextChanged { text ->
viewModel.onIntent(LoginIntent.UpdatePassword(text ?: ""))
}
btnLogin.setOnClickListener {
viewModel.onIntent(LoginIntent.Login)
}
}
}

private fun renderState(state: LoginState) {
binding?.apply {
// 加载状态
progressBar.isVisible = state.isLoading
btnLogin.isEnabled = !state.isLoading && state.isFormValid

// 错误状态
tvError.isVisible = state.error != null
tvError.text = state.error

// 表单验证
tilUsername.error = state.usernameError
tilPassword.error = state.passwordError
}
}

private fun handleEffect(effect: LoginEffect) {
when (effect) {
is LoginEffect.ShowToast -> Toast.makeText(requireContext(), effect.message, Toast.LENGTH_SHORT).show()
LoginEffect.NavigateToHome -> {
findNavController().navigate(R.id.action_login_to_home)
}
}
}

override fun onDestroyView() {
super.onDestroyView()
binding = null // 避免内存泄漏
}
}

3.4 使用 combine 合并多个 State 流

当需要合并多个子 State 流时,使用 combine

class ProductListViewModel(
private val productRepository: ProductRepository,
private val filterRepository: FilterRepository
) : ViewModel() {

private val _intent = MutableSharedFlow<ProductListIntent>()
private val currentCategory = MutableStateFlow<Category?>(null)
private val currentSort = MutableStateFlow(SortOrder.DEFAULT)

// 将 Intent 流与 State 流组合
val state: StateFlow<ProductListState> = combine(
currentCategory,
currentSort,
_intent
) { category, sort, intent ->
// 根据组合的输入计算新 State
reduceState(category, sort, intent)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ProductListState()
)

fun onIntent(intent: ProductListIntent) {
viewModelScope.launch {
_intent.emit(intent)
}
}
}

四、MVI 与 Jetpack 的集成

4.1 SavedStateHandle 与进程死亡恢复

Android 的进程死亡(Process Death)是常见的场景。使用 SavedStateHandle 可以在进程重建后恢复 State:

class SearchViewModel(
private val savedStateHandle: SavedStateHandle,
private val searchRepository: SearchRepository
) : ViewModel() {

// 从 SavedStateHandle 恢复初始 State
private val initialQuery: String = savedStateHandle.get<String>("query") ?: ""

private val _state = MutableStateFlow(SearchState(query = initialQuery))
val state: StateFlow<SearchState> = _state.asStateFlow()

fun onIntent(intent: SearchIntent) {
when (intent) {
is SearchIntent.UpdateQuery -> {
// 同步持久化到 SavedStateHandle
savedStateHandle["query"] = intent.query
_state.update { it.copy(query = intent.query) }
}
// ...
}
}
}

更优雅的方式是使用 Kotlin delegate:

class SearchViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {

private var query by savedStateHandle.saveable<String>("query") { "" }

private val _state = MutableStateFlow(SearchState(query = query))
// ...
}

4.2 与 ViewModel 的集成要点

  • StateFlow + WhileSubscribed(5000) 是推荐的组合:当没有观察者时暂停上游流,5秒延迟避免配置变更时重建流
  • viewModelScope 是所有协程的作用域,ViewModel 清除时自动取消
  • 使用 _state.update {} 保证原子更新,避免竞态条件

4.3 使用 sealed class 替代枚举进行状态建模

在 MVI 中,推荐用 sealed class 对整个屏幕状态建模,而非用多个布尔值:

sealed class UiState<out T> {
object Idle : UiState<Nothing>()
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String, val throwable: Throwable? = null) : UiState<Nothing>()
}

data class SearchScreenState(
val uiState: UiState<List<SearchItem>> = UiState.Idle,
val query: String = "",
val recentSearches: List<String> = emptyList()
)

这样避免了 isLoading == false && error == null && data == null 需要同时判断三个标志的情况。


五、MVI vs MVVM vs MVP 对比

5.1 状态管理维度

维度 MVP MVVM MVI
状态来源 Presenter 主动调用 View 方法 ViewModel 暴露多个 LiveData/Flow 单一 StateFlow
状态一致性 由 Presenter 保证(手动) 多状态源可能不一致 单一 State 天然一致
状态可追溯性 困难,需要追踪方法调用 中等,多个订阅点 简单:每次 State 变化都是一次完整快照
测试难度 View 接口需 mock ViewModel 相对好测 Reducer 纯函数,最好测
学习成本 中高

5.2 代码量对比 —— 同一个登录功能

MVP 版本

// Contract
interface LoginContract {
interface View {
fun showLoading()
fun hideLoading()
fun showError(msg: String)
fun navigateToHome()
fun setLoginEnabled(enabled: Boolean)
fun showUsernameError(error: String?)
fun showPasswordError(error: String?)
}
interface Presenter {
fun onUsernameChanged(username: String)
fun onPasswordChanged(password: String)
fun onLoginClick()
fun attach(view: View)
fun detach()
}
}
// 需要至少 3 个文件:Contract, View 实现, Presenter 实现

MVVM 版本

class LoginViewModel : ViewModel() {
val isLoading = MutableLiveData<Boolean>()
val errorMessage = MutableLiveData<String>()
val loginSuccess = MutableLiveData<Boolean>()
val usernameError = MutableLiveData<String?>()
val passwordError = MutableLiveData<String?>()
val isLoginEnabled = MediatorLiveData<Boolean>().apply {
// 依赖多个 LiveData,逻辑分散
}
}
// 1 个 ViewModel 文件,但状态分散

MVI 版本

// State: 1 个 data class
// Intent: 1 个 sealed class
// Effect: 1 个 sealed class
// ViewModel: 处理 Intent -> 计算 State -> 发送 Effect
// 所有状态变化都在 Reducer 中,一目了然

5.3 何时选择 MVI

适合 MVI 的场景:

  • 屏幕状态复杂的业务页面(表单、搜索、多步骤流程)
  • 需要严格状态管理的金融/支付类应用
  • 团队对函数式编程有基本理解
  • 需要 Time Travel Debugging / 状态回放

不太适合 MVI 的场景:

  • 简单的静态展示页面(过度架构)
  • 团队成员大量且流动快(学习曲线)
  • 需要快速迭代的 MVP 产品

六、MVI 的测试策略

6.1 Reducer 单元测试

Reducer 是纯函数,测试最简单,不需要任何 mock:

class SearchReducerTest {

@Test
fun `updateQuery should update query and clear results when empty`() {
val initialState = SearchState(
query = "old",
results = listOf(SearchResult("item"))
)
val intent = SearchIntent.UpdateQuery("")

val newState = searchReducer(initialState, intent)

assertEquals("", newState.query)
assertTrue(newState.results.isEmpty())
}

@Test
fun `Search intent should set loading and clear error`() {
val initialState = SearchState(error = "prev error")
val intent = SearchIntent.Search

val newState = searchReducer(initialState, intent)

assertTrue(newState.isLoading)
assertNull(newState.error)
assertEquals(1, newState.currentPage)
}

@Test
fun `reducer is pure - same input gives same output`() {
val state = SearchState(query = "kotlin")
val intent = SearchIntent.Search

val result1 = searchReducer(state, intent)
val result2 = searchReducer(state, intent)

assertEquals(result1, result2)
assertNotSame(result1, result2) // 每次返回新对象
}
}

6.2 ViewModel 测试 —— 使用 Turbine

Turbine 是 Cash App 推出的 Kotlin Flow 测试库,非常适合测试 MVI 的 ViewModel:

@OptIn(ExperimentalCoroutinesApi::class)
class LoginViewModelTest {

private val loginUseCase: LoginUseCase = mockk()
private val validateUsernameUseCase: ValidateUsernameUseCase = mockk()
private val validatePasswordUseCase: ValidatePasswordUseCase = mockk()
private lateinit var viewModel: LoginViewModel

@Before
fun setup() {
viewModel = LoginViewModel(
loginUseCase = loginUseCase,
validateUsernameUseCase = validateUsernameUseCase,
validatePasswordUseCase = validatePasswordUseCase
)
}

@Test
fun `login success emits NavigateToHome effect`() = runTest {
coEvery { loginUseCase.invoke(any(), any()) } returns Result.success(Unit)
every { validateUsernameUseCase.invoke(any()) } returns null
every { validatePasswordUseCase.invoke(any()) } returns null

// 订阅 State 和 Effect
viewModel.state.test {
viewModel.effect.test {
// 初始状态
assertEquals(LoginState(), awaitItem())

// 发送登录 Intent
viewModel.onIntent(LoginIntent.UpdateUsername("testuser"))
viewModel.onIntent(LoginIntent.UpdatePassword("password123"))
viewModel.onIntent(LoginIntent.Login)

// 验证 loading 状态
val loadingState = awaitItem()
assertTrue(loadingState.isLoading)

// 验证 Effect
assertEquals(LoginEffect.ShowToast("登录成功"), awaitItem())
assertEquals(LoginEffect.NavigateToHome, awaitItem())

cancelAndConsumeRemainingEvents()
}
cancelAndConsumeRemainingEvents()
}
}

@Test
fun `login failure emits error state`() = runTest {
val errorMsg = "网络连接失败"
coEvery { loginUseCase.invoke(any(), any()) } returns Result.failure(RuntimeException(errorMsg))
every { validateUsernameUseCase.invoke(any()) } returns null
every { validatePasswordUseCase.invoke(any()) } returns null

viewModel.state.test {
assertEquals(LoginState(), awaitItem())

viewModel.onIntent(LoginIntent.UpdateUsername("test"))
viewModel.onIntent(LoginIntent.UpdatePassword("pass"))
viewModel.onIntent(LoginIntent.Login)

// 跳过中间状态直到最终状态
var finalState = awaitItem()
while (finalState.isLoading) {
finalState = awaitItem()
}

assertEquals(errorMsg, finalState.error)
assertFalse(finalState.isLoginSuccess)
cancelAndConsumeRemainingEvents()
}
}

@Test
fun `form validation - empty username shows error`() = runTest {
every { validateUsernameUseCase.invoke("") } returns "用户名不能为空"
every { validatePasswordUseCase.invoke(any()) } returns null

viewModel.state.test {
awaitItem() // 初始状态

viewModel.onIntent(LoginIntent.UpdateUsername(""))
viewModel.onIntent(LoginIntent.UpdatePassword("pass"))

val state = awaitItem()
assertEquals("用户名不能为空", state.usernameError)
assertFalse(state.isFormValid)

cancelAndConsumeRemainingEvents()
}
}
}

七、MVI 实战中的常见问题与最佳实践

7.1 状态爆炸问题

当屏幕状态非常复杂时(例如 20+ 个属性),每次 copy() 都会创建新对象,可能带来性能开销。解决方案:

方案一:状态分组

将大 State 拆分为多个子 State:

data class CheckoutScreenState(
val cartState: CartState = CartState(),
val paymentState: PaymentState = PaymentState(),
val shippingState: ShippingState = ShippingState()
)

data class CartState(
val items: List<CartItem> = emptyList(),
val totalPrice: BigDecimal = BigDecimal.ZERO
)

data class PaymentState(
val method: PaymentMethod? = null,
val isProcessing: Boolean = false
)

每个子 State 独立管理,由各自的 Reducer 处理。

方案二:使用 @Stable 或 @Immutable 注解

当使用 Compose 时,为 State 类添加注解帮助 Compose 编译器跳过不必要的重组:

@Immutable
data class SearchState(
val query: String = "",
val results: List<SearchResult> = emptyList(),
val isLoading: Boolean = false
)

7.2 过度标准化(Over-normalization)

不要把所有状态都放到顶层 State 中。遵循以下原则:

  • 放入 State:直接影响 UI 渲染的属性(滚动位置、选中项、输入文本)
  • 不放 State:临时的动画中间态、View 自身的局部状态

例如 RecyclerView 的滚动位置,应该作为 View 层自身的状态,由 onSaveInstanceState / onRestoreInstanceState 恢复,不需要经过 MVI 循环:

// View 层自行处理
private var scrollPosition = 0

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt("scroll_pos",
(binding.recyclerView.layoutManager as LinearLayoutManager)
.findFirstVisibleItemPosition())
}

7.3 副作用顺序问题

当多个 Effect 连续发送时,Channel 的 Buffer 容量和消费顺序需要注意:

// 确保 Effect 按顺序处理
private val _effect = Channel<LoginEffect>(Channel.UNLIMITED)
val effect: Flow<LoginEffect> = _effect.receiveAsFlow()

// View 层使用 collect 保证顺序
viewModel.effect.collect { effect ->
when (effect) {
is LoginEffect.ShowToast -> {
// 等待 snackbar 消失后再处理导航
snackbar.dismiss()
delay(300)
}
LoginEffect.NavigateToHome -> { /* 导航 */ }
}
}

7.4 Process Death 的完整恢复

class CheckoutViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {

companion object {
private const val KEY_STATE = "checkout_state"
}

private val _state = MutableStateFlow(
savedStateHandle.get<CheckoutState>(KEY_STATE) ?: CheckoutState()
)
val state: StateFlow<CheckoutState> = _state.asStateFlow()

fun onIntent(intent: CheckoutIntent) {
// 每次状态变更后持久化
setState { current ->
val newState = reduceState(current, intent)
savedStateHandle[KEY_STATE] = newState
newState
}
}
}

八、MVI 在 Jetpack Compose 中的使用

8.1 Compose 天然适合 MVI

Compose 的声明式 UI 与 MVI 的单向数据流完美契合:

@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()

// Effect 处理
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is LoginEffect.ShowToast -> { /* ... */ }
LoginEffect.NavigateToHome -> { /* navController.navigate(...) */ }
}
}
}

// 根据 State 渲染 UI
LoginContent(
state = state,
onIntent = viewModel::onIntent
)
}

@Composable
fun LoginContent(
state: LoginState,
onIntent: (LoginIntent) -> Unit
) {
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = state.username,
onValueChange = { onIntent(LoginIntent.UpdateUsername(it)) },
isError = state.usernameError != null,
supportingText = { state.usernameError?.let { Text(it) } }
)

OutlinedTextField(
value = state.password,
onValueChange = { onIntent(LoginIntent.UpdatePassword(it)) },
isError = state.passwordError != null,
supportingText = { state.passwordError?.let { Text(it) } }
)

Button(
onClick = { onIntent(LoginIntent.Login) },
enabled = state.isFormValid && !state.isLoading
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
} else {
Text("登录")
}
}

state.error?.let { error ->
Text(error, color = MaterialTheme.colorScheme.error)
}
}
}

8.2 使用 lifecycle-runtime-compose 安全收集

collectAsStateWithLifecycle() 来自 lifecycle-runtime-compose artifact,会根据生命周期自动暂停/恢复收集:

// build.gradle.kts
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")

// 使用
val state by viewModel.state.collectAsStateWithLifecycle()

九、MVI 架构的演进 —— orbit-mvi / Mavericks

虽然可以从零实现 MVI,但在大型项目中,使用成熟的库可以减少模板代码:

9.1 Orbit MVI

Orbit MVI 是轻量级的 MVI 框架:

class LoginViewModel(
private val loginUseCase: LoginUseCase
) : ContainerHost<LoginState, LoginEffect> {

override val container = container<LoginState, LoginEffect>(LoginState())

fun onUsernameChanged(username: String) = intent {
reduce { state.copy(username = username) }
}

fun onLogin() = intent(registerIdling = true) {
reduce { state.copy(isLoading = true) }

loginUseCase(state.username, state.password)
.onSuccess {
reduce { state.copy(isLoading = false, isLoginSuccess = true) }
postSideEffect(LoginEffect.NavigateToHome)
}
.onFailure {
reduce { state.copy(isLoading = false, error = it.message) }
}
}
}

9.2 Airbnb Mavericks

Mavericks 是 Airbnb 的 MVI 框架,内置了 Process Death 恢复和异步状态管理:

data class SearchState(
val query: String = "",
val results: Async<List<SearchResult>> = Uninitialized
) : MavericksState

class SearchViewModel(
initialState: SearchState,
private val searchRepo: SearchRepository
) : MavericksViewModel<SearchState>(initialState) {

fun search(query: String) = withState { state ->
setState { copy(query = query, results = Loading()) }

suspend {
searchRepo.search(query)
}.execute { result ->
copy(results = result)
}
}
}

十、总结

MVI 架构通过单向数据流和不可变 State 解决了多状态源带来的不一致问题。它的核心优势:

  1. 状态可预测:任何时刻的 UI 状态是 State 的快照,可以完整还原
  2. 测试友好:Reducer 是纯函数,不需要 mock;Effect 可以通过 Turbine 验证
  3. 线程安全:不可变 State 和数据流管道天然保证线程安全
  4. 与 Compose 完美配合:声明式 UI + 单向数据流,消除歧义

引入 MVI 的注意事项:

  • 不要在简单场景过度使用(静态展示页不需要 MVI)
  • 注意状态粒度(不要一个 State 装下所有东西)
  • 合理使用 Effect 处理一次性事件
  • 配合 SavedStateHandle 处理进程死亡

MVI 不是银弹,但在复杂交互、多状态流转的场景下,它提供的确定性和可维护性是 MVVM 和 MVP 难以达到的。


参考资源

文章作者: Leo·Cheung
文章链接: http://tufusi.com/2022/04/06/%E9%87%8D%E6%8B%BEAndroid-%E3%80%90%E6%9E%B6%E6%9E%84%E7%AF%87%E3%80%91MVI%E6%9E%B6%E6%9E%84/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 ONE·PIECE
打赏
  • 微信
  • 支付宝

评论