28. 状态管理架构演进:MVI模式中的密封类应用
聊到Android状态管理,MVI模式这几年确实火了一把。我个人最早接触MVI是在2018年,当时团队正被MVP的接口爆炸搞得焦头烂额。说实话,第一次看到MVI的架构图时,我第一反应是:这不就是Redux的Android版吗?
但真正用起来才发现,MVI的核心魅力在于——状态是唯一的,变化是可预测的。而密封类,恰恰是实现这种可预测性的最佳工具。
从MVC到MVI:状态管理的进化之路
我们先快速回顾一下状态管理的演进。你想想看,早期的MVC模式里,Activity既是Controller又是View,状态散落在各个回调里。到了MVP,Presenter开始管理状态,但接口定义让人头疼。MVVM用LiveData或Flow解决了部分问题,但状态变更的路径还是不清晰。
MVI的出现,说白了就是要把状态变更这件事彻底规范化。它引入了三个核心概念:
- Model:应用状态的快照,是一个不可变的数据类
- Intent:用户操作或系统事件的抽象,用密封类表示
- View:纯UI渲染层,只负责把状态映射成界面
我在项目中遇到过最典型的场景:一个复杂的表单页面,有加载、校验、提交、错误回显等多个状态。用MVVM时,每个状态都要单独处理,代码越写越乱。换成MVI后,一个密封类就把所有状态定义清楚了。
密封类:MVI的基石
为什么MVI离不开密封类?因为密封类天然适合表达有限状态集合。来看一个实际例子:
// 定义UI状态
sealed class LoginState {
object Idle : LoginState()
object Loading : LoginState()
data class Success(val token: String) : LoginState()
data class Error(val message: String) : LoginState()
}
// 定义用户意图
sealed class LoginIntent {
object LoginClicked : LoginIntent()
data class UsernameChanged(val text: String) : LoginIntent()
data class PasswordChanged(val text: String) : LoginIntent()
}
这段代码里,LoginState和LoginIntent都是密封类。好处是什么?编译器帮你做穷尽检查。当你用when表达式处理状态时,Kotlin会强制你覆盖所有分支:
fun render(state: LoginState) {
when (state) {
is LoginState.Idle -> showLoginForm()
is LoginState.Loading -> showLoading()
is LoginState.Success -> navigateToHome(state.token)
is LoginState.Error -> showError(state.message)
}
// 不需要else分支,编译器保证所有情况已处理
}
嗯,这里要注意:千万不要在when里加else分支。加了else,编译器就不帮你检查了。我曾经在一个项目里看到同事加了else,结果新增了一个状态,编译通过但运行时直接走错了分支。排查了半天才发现是else的问题。
单向数据流:状态变更的闭环
MVI的核心机制是单向数据流。用户操作产生Intent,经过Reducer处理生成新状态,View根据新状态重新渲染。这个闭环用密封类实现起来非常自然:
class LoginViewModel : ViewModel() {
private val _state = MutableStateFlow<LoginState>(LoginState.Idle)
val state: StateFlow<LoginState> = _state.asStateFlow()
fun process(intent: LoginIntent) {
when (intent) {
is LoginIntent.LoginClicked -> handleLogin()
is LoginIntent.UsernameChanged -> updateUsername(intent.text)
is LoginIntent.PasswordChanged -> updatePassword(intent.text)
}
}
private fun handleLogin() {
_state.value = LoginState.Loading
viewModelScope.launch {
try {
val token = loginRepository.login(username, password)
_state.value = LoginState.Success(token)
} catch (e: Exception) {
_state.value = LoginState.Error(e.message ?: "登录失败")
}
}
}
}
你看,整个流程是线性的:用户点击 → Loading状态 → 网络请求 → Success或Error。没有回调嵌套,没有状态覆盖问题。每个时刻的状态都是确定的。
核心原则:状态只能通过Reducer函数变更,View不能直接修改状态。这保证了数据流的可追溯性。
内联类:优化状态中的值类型
在MVI中,状态类经常包含一些基础类型字段。比如用户ID、Token等。直接用String或Int会有类型安全问题——你可能会把用户名和密码传反。内联类可以解决这个问题:
@JvmInline
value class UserId(val value: String)
@JvmInline
value class Token(val value: String)
data class UserState(
val userId: UserId,
val token: Token,
val isLoggedIn: Boolean
)
内联类在编译时不会产生额外的对象开销,但提供了类型安全。我在重构一个老项目时,把所有的String类型ID换成了内联类,编译期就发现了好几处传参错误。这种错误在运行时很难排查,编译器能帮你抓住,何乐而不为?
MVI的避坑指南
用了几年MVI,我总结了一些实战经验:
- 状态不要太大:一个页面的状态类字段不要超过10个。太多了就拆成子状态。
- Intent要细粒度:每个用户操作对应一个Intent,不要合并。比如"点击登录"和"输入用户名"是两个不同的Intent。
- 副作用要单独处理:导航、弹Toast等一次性操作,不要放在状态里。可以用Channel或SharedFlow单独处理。
- 状态合并要谨慎:多个数据源的状态合并时,用combine操作符,不要手动赋值。
我曾经踩过的坑:在MVI中使用mutable状态对象。有一次我图方便,在data class里用了MutableList,结果两个并发请求同时修改列表,导致UI显示错乱。记住:状态必须不可变,每次变更都创建新对象。
MVI vs MVVM:什么时候选MVI?
不是所有场景都适合MVI。我个人的选择标准是这样的:
| 场景 | 推荐模式 | 原因 |
|---|---|---|
| 简单表单(3-5个字段) | MVVM | 状态简单,MVI反而增加复杂度 |
| 复杂交互页面 | MVI | 状态多,需要清晰的变更路径 |
| 多人协作的大型项目 | MVI | 密封类强制规范,减少沟通成本 |
| 快速原型开发 | MVVM | MVI的模板代码较多,影响迭代速度 |
说白了,MVI是用代码量换可维护性。如果你的页面状态超过5个,或者有复杂的异步操作,MVI的优势就很明显了。
MVI状态流转图
下面这张图展示了MVI模式下状态的完整生命周期:
实战:用MVI重构一个计数器
最后,我们用一个完整的例子来串联所有知识点。这是一个简单的计数器,但展示了MVI的完整流程:
// 1. 定义状态
sealed class CounterState {
object Initial : CounterState()
data class Counting(val value: Int) : CounterState()
object MaxReached : CounterState()
}
// 2. 定义意图
sealed class CounterIntent {
object Increment : CounterIntent()
object Decrement : CounterIntent()
object Reset : CounterIntent()
}
// 3. ViewModel处理逻辑
class CounterViewModel : ViewModel() {
private val _state = MutableStateFlow<CounterState>(CounterState.Initial)
val state: StateFlow<CounterState> = _state.asStateFlow()
fun process(intent: CounterIntent) {
val currentState = _state.value
val currentValue = (currentState as? CounterState.Counting)?.value ?: 0
_state.value = when (intent) {
is CounterIntent.Increment -> {
if (currentValue >= 10) CounterState.MaxReached
else CounterState.Counting(currentValue + 1)
}
is CounterIntent.Decrement -> {
if (currentValue <= 0) CounterState.Initial
else CounterState.Counting(currentValue - 1)
}
is CounterIntent.Reset -> CounterState.Initial
}
}
}
// 4. Composable渲染
@Composable
fun CounterScreen(viewModel: CounterViewModel) {
val state by viewModel.state.collectAsState()
when (state) {
is CounterState.Initial -> Text("点击开始计数")
is CounterState.Counting -> Text("当前值: ${(state as CounterState.Counting).value}")
is CounterState.MaxReached -> Text("已达到最大值10")
}
Button(onClick = { viewModel.process(CounterIntent.Increment) }) {
Text("+1")
}
}
这个例子虽然简单,但MVI的精髓都在里面了。你想想看,如果以后要加一个"步长设置"的功能,只需要在CounterState里加一个step字段,在CounterIntent里加一个SetStep意图,其他地方基本不用动。这就是密封类带来的扩展性。
个人建议:刚开始用MVI时,不要追求完美。先从一个页面开始,把密封类定义好,把数据流跑通。用熟了再推广到其他页面。我见过太多团队一上来就想全量重构,结果搞到一半就放弃了。
MVI模式加上密封类,本质上是在用类型系统来管理复杂度。Kotlin的密封类给了我们一个强大的工具,让状态变更变得可预测、可追溯。下次你遇到状态管理混乱的问题,不妨试试MVI——也许你会发现,原来状态管理可以这么清晰。
公众号:蓝海资料掘金营,微信deep3321