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 状态管理架构流转图 View(UI层) Intent(意图) Reducer(处理器) State(状态) 副作用 (导航/Toast) 用户操作 分发 生成新状态 渲染 一次性操作 关键特性: • 数据流单向:View → Intent → Reducer → State → View • 状态不可变:每次变更都创建新的State对象 • 密封类保障:Intent和State都用sealed class定义,编译器强制穷尽检查

实战:用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