上一篇讲了离线优先架构,回到 App 架构——MVI。MVVM 已经"烂大街",但 MVI 的"单向数据流"解决了 MVVM 一直搞不定的事:状态一致性、可预测、可测试。这一篇用 Orbit 框架做实战示例。
MVI 是什么
MVI = Model-View-Intent,是 MVVM 的"加强版":
传统 MVVM:
UI → ViewModel 调用
UI → 观察 State
状态: 多个 LiveData、多个 Flow
MVI:
UI → Intent(用户意图)
Intent → Reducer → State(统一状态)
State → UI(一次渲染)
核心区别:State 是单一的(Single State),所有 UI 状态都在一个对象里
具体对比:
Orbit 框架
Orbit 是当下最干净的 MVI 实现之一:
// build.gradle.kts
plugins {
id("org.orbit-mvi") version "9.0.0"
}
dependencies {
implementation("org.orbit-mvi:orbit-core:9.0.0")
implementation("org.orbit-mvi:orbit-viewmodel:9.0.0")
implementation("org.orbit-mvi:orbit-test:9.0.0") // 测试
}
核心概念
// 1. State:UI 的完整状态
data class LoginState(
val email: String = "",
val password: String = "",
val loading: Boolean = false,
val error: String? = null,
val loginSuccess: Boolean = false
)
// 2. Intent:用户的意图(点击、输入)
sealed class LoginIntent {
data class EmailChanged(val email: String) : LoginIntent()
data class PasswordChanged(val password: String) : LoginIntent()
object LoginClicked : LoginIntent()
}
// 3. Side Effect:副作用(导航、Toast)
sealed class LoginSideEffect {
object NavigateToHome : LoginSideEffect()
data class ShowToast(val message: String) : LoginSideEffect()
}
ViewModel
class LoginViewModel : ContainerHost<LoginState, LoginSideEffect>, ViewModel() {
override val container = container<LoginState, LoginSideEffect>(LoginState())
fun onIntent(intent: LoginIntent) = intent {
when (intent) {
is LoginIntent.EmailChanged -> {
reduce { state.copy(email = intent.email) }
}
is LoginIntent.PasswordChanged -> {
reduce { state.copy(password = intent.password) }
}
is LoginIntent.LoginClicked -> {
if (state.loading) return@intent
reduce { state.copy(loading = true, error = null) }
val result = loginUseCase(state.email, state.password)
result.onSuccess {
reduce { state.copy(loading = false, loginSuccess = true) }
postSideEffect(LoginSideEffect.NavigateToHome)
}.onFailure { error ->
reduce { state.copy(loading = false, error = error.message) }
postSideEffect(LoginSideEffect.ShowToast("登录失败:${error.message}"))
}
}
}
}
}
UI(Compose)
@Composable
fun LoginScreen(vm: LoginViewModel = viewModel()) {
val state by vm.collectAsState()
LoginContent(
state = state,
onEmailChange = { vm.onIntent(LoginIntent.EmailChanged(it)) },
onPasswordChange = { vm.onIntent(LoginIntent.PasswordChanged(it)) },
onLoginClick = { vm.onIntent(LoginIntent.LoginClicked) }
)
}
@Composable
fun LoginContent(
state: LoginState,
onEmailChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onLoginClick: () -> Unit
) {
Column {
TextField(value = state.email, onValueChange = onEmailChange)
TextField(value = state.password, onValueChange = onPasswordChange)
Button(onClick = onLoginClick, enabled = !state.loading) {
Text(if (state.loading) "登录中…" else "登录")
}
state.error?.let { Text(it, color = Color.Red) }
}
}
测试:MVI 最大优势
Orbit 测试极简,写场景就能跑:
class LoginViewModelTest {
@Test
fun `login success`() = runTest {
LoginViewModel().testIntent(
initialState = LoginState(),
timeout = 5.seconds
) {
containerHost.onIntent(LoginIntent.EmailChanged("user@example.com"))
containerHost.onIntent(LoginIntent.PasswordChanged("password"))
containerHost.onIntent(LoginIntent.LoginClicked)
// 验证状态流转
assertEquals(
listOf(
LoginState(email = "user@example.com"),
LoginState(email = "user@example.com", password = "password"),
LoginState(email = "user@example.com", password = "password", loading = true),
LoginState(email = "user@example.com", password = "password", loginSuccess = true)
),
containerHost.container.stateFlow.value
)
// 验证副作用
assertEquals(
listOf(LoginSideEffect.NavigateToHome),
containerHost.container.sideEffectFlow.first()
)
}
}
}
这一段测试覆盖了:
状态的所有分支变化
SideEffect 是否正确发出
异步流程的时间序列
传统 MVVM 写同样的测试要 stub 5 个 LiveData,远不如 MVI 直观。
MVI 解决的具体痛点
1. 状态一致性
MVVM 状态分散:邮箱、密码、按钮可用性、错误提示各自独立。新需求"邮箱格式不对时禁用按钮"——要在 3 个地方改代码。
MVI:所有状态集中,按钮可用性 = !state.loading && state.email.contains('@'),一处定义。
2. 时序问题
MVVM:
// 用户快速点击 Login 按钮三次
fun onLoginClick() {
viewModelScope.launch {
loading.value = true
loginApi(email, password) // 第一次返回错误,第二次还没返回
}
}
第一次接口失败可能 hide loading,第二次还在加载。MVI reducer 里 if (state.loading) return@intent 一行解决。
3. 可重放性
Orbit 提供 recordedStateFlow,记录状态序列:
containerHost.observedHistory(
LoginIntent.EmailChanged("user"),
LoginIntent.LoginClicked
)
调试、回放、复现 bug 都靠它。
抗辩:MVI 的劣势
很多团队对 MVI 有顾虑:
MVI 适用场景:
状态复杂(多 ViewModel 交互的页面)
需要严格测试(金融、医疗)
业务经常变但要求稳定(电商、订单页面)
MVI 不适用:
简单 CRUD 页面(详情页、列表页)
内部工具 App
几乎无交互的展示页(设置页)
Orbit 进阶
1. 链接其他 Container
Orbit 容器可以注册监听器,父子状态联动:
class ParentViewModel : ContainerHost<ParentState, ParentSE> {
override val container = container<ParentState, ParentSE>(ParentState())
fun observeChild() {
childViewModel.container.stateFlow.collect { childState ->
// 父 ViewModel 整合子状态
}
}
}
Orbit 9 的 OrbitFlow 让父子消息传递更直接。
2. SavedStateHandle
Orbit 提供 savedState() 自动恢复状态:
class MyViewModel(savedStateHandle: SavedStateHandle) : ContainerHost<MyState, MySE>, ViewModel() {
override val container = container<MyState, MySE>(
initialState = MyState(),
savedStateHandle = savedStateHandle
)
}
旋转屏幕状态不丢失。
3. 模块化
每个业务模块有自己的 ViewModel + Reducer:
feature/auth/
├── domain/UseCase.kt
├── presentation/
│ ├── AuthViewModel.kt
│ ├── AuthState.kt
│ ├── AuthIntent.kt
│ └── AuthSideEffect.kt
Reducer 不跨模块,独立测试。
实战案例
订单详情页(电商)
State 几十个字段,每行 UI 关心其中一部分。MVVM 状态对不齐问题严重——比如商品状态、订单状态、用户操作状态、loading 状态交织。
用 MVI:
data class OrderDetailState(
val order: Order? = null,
val loading: Boolean = false,
val error: String? = null,
val showCancelConfirm: Boolean = false,
val showRefundDialog: Boolean = false,
val actionInProgress: ActionType? = null
)
sealed class OrderDetailIntent {
object LoadOrder : OrderDetailIntent()
object RefreshOrder : OrderDetailIntent()
object CancelOrderClicked : OrderDetailIntent()
object ConfirmCancel : OrderDetailIntent()
object DismissCancel : OrderDetailIntent()
// ... 30+ 个 Intent
}
看似多,但每个 Intent 对应一个用户操作,对照清晰。bug 排查时"哪个 state 变化导致的"一目了然。
小结
MVI 不是"永远正确"的架构选择,但它解决了 MVVM 没法解决的几个关键问题。Orbit 让 MVI 在 Kotlin Android 上的代码量不超过 MVVM 太多。建议在复杂业务页面试 MVI,简单页面继续 MVVM,团队按场景选择,不要一刀切。
下一篇聊聊 Systrace 与 Perfetto,深入底层性能分析。