Administrator
发布于 2026-08-25 / 32 阅读
0
0

系列文章《Android 工程师的 100 篇进阶笔记》第 64 篇:MVI 架构实战:Orbit 与单向数据流

上一篇讲了离线优先架构,回到 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 状态都在一个对象里

具体对比:

维度

MVVM

MVI

状态数

多个 StateFlow

单一 State

更新方式

各处修改字段

Reducer 集中处理

可预测性

中(一处改别处可能不同步)

高(确定性函数:同样的输入同样的输出)

测试

单元测试可能很复杂

一个 reducer 测试

学习曲线

低

中(要理解 Intent/Action/State 三个概念)

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 有顾虑:

顾虑

实情

"所有状态一个对象,每次小改动都整个 rebuild"

Compose 增量渲染没问题,UI 实际只重渲染改的部分

"很多 boilerplate"

真有大量模板代码。Orbit 算轻量,但相比 LiveData 确实代码多

"小项目过度设计"

完全同意,CRUD 应用不需要 MVI

"新人难上手"

代码规范统一后,新人只要看懂 one intent 就能上手

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,深入底层性能分析。


评论