一、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)
数据只沿一个方向流动:
View 发出 Intent(用户点击按钮、输入文本等)
ViewModel 接收 Intent,通过 Reducer 计算新 State
新 State 推送给 View 进行渲染
View 渲染完成后等待下一个 Intent
这个循环保证了:任何时刻的 UI 状态都是确定的、可追溯的,不会出现多个入口修改状态导致的不一致问题。
1.3 为什么需要 MVI 传统的 MVVM 中,一个 ViewModel 可能有多个 MutableLiveData 或 MutableStateFlow 分别管理不同维度的状态:
class LoginViewModel : ViewModel () { val isLoading = MutableLiveData<Boolean >() val errorMessage = MutableLiveData<String>() val loginSuccess = MutableLiveData<Boolean >() val username = MutableLiveData<String>() val password = MutableLiveData<String>() }
这种写法的隐患:
状态不一致 :可能出现 isLoading = true 且 loginSuccess = 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 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 是”一次性事件”
使用 Channel 或 SharedFlow 发送 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.ViewModelimport androidx.lifecycle.viewModelScopeimport kotlinx.coroutines.channels.Channelimport kotlinx.coroutines.flow.*import kotlinx.coroutines.launchabstract class MviViewModel <S, I, E >( initialState: S ) : ViewModel() { private val _state = MutableStateFlow(initialState) val state: StateFlow<S> = _state.asStateFlow() 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) } } abstract fun onIntent (intent: I ) }
3.2 完整的登录示例 —— Reducer 模式 下面是一个完整的登录功能实现,演示 MVI 的所有核心概念:
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 ) sealed class LoginIntent { data class UpdateUsername (val username: String) : LoginIntent() data class UpdatePassword (val password: String) : LoginIntent() object Login : LoginIntent() object DismissError : LoginIntent() } sealed class LoginEffect { data class ShowToast (val message: String) : LoginEffect() object NavigateToHome : LoginEffect() } 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) viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.state.collect { state -> renderState(state) } } } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.effect.collect { effect -> handleEffect(effect) } } } 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) val state: StateFlow<ProductListState> = combine( currentCategory, currentSort, _intent ) { category, sort, intent -> 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() { 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["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 版本 :
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 () } }
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 { } }
MVI 版本 :
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 viewModel.state.test { viewModel.effect.test { assertEquals(LoginState(), awaitItem()) viewModel.onIntent(LoginIntent.UpdateUsername("testuser" )) viewModel.onIntent(LoginIntent.UpdatePassword("password123" )) viewModel.onIntent(LoginIntent.Login) val loadingState = awaitItem() assertTrue(loadingState.isLoading) 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 循环:
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 容量和消费顺序需要注意:
private val _effect = Channel<LoginEffect>(Channel.UNLIMITED)val effect: Flow<LoginEffect> = _effect.receiveAsFlow()viewModel.effect.collect { effect -> when (effect) { is LoginEffect.ShowToast -> { 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() LaunchedEffect(Unit ) { viewModel.effect.collect { effect -> when (effect) { is LoginEffect.ShowToast -> { } LoginEffect.NavigateToHome -> { } } } } 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,会根据生命周期自动暂停/恢复收集:
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 解决了多状态源带来的不一致问题。它的核心优势:
状态可预测 :任何时刻的 UI 状态是 State 的快照,可以完整还原
测试友好 :Reducer 是纯函数,不需要 mock;Effect 可以通过 Turbine 验证
线程安全 :不可变 State 和数据流管道天然保证线程安全
与 Compose 完美配合 :声明式 UI + 单向数据流,消除歧义
引入 MVI 的注意事项:
不要在简单场景过度使用(静态展示页不需要 MVI)
注意状态粒度(不要一个 State 装下所有东西)
合理使用 Effect 处理一次性事件
配合 SavedStateHandle 处理进程死亡
MVI 不是银弹,但在复杂交互、多状态流转的场景下,它提供的确定性和可维护性是 MVVM 和 MVP 难以达到的。
参考资源