23、架构模式:MVI 模式在 Compose 中的应用,单向数据流实践,Reducer 与 Store
聊到 Compose 的架构,绕不开 MVI。说实话,我最早接触 MVI 是在做前端 React 项目时,那时候叫 Redux。后来转到 Android 开发,发现这个模式在 Compose 里简直如鱼得水。为什么?因为 Compose 本身就是声明式 UI,天然适合单向数据流。
今天我们就来拆解 MVI 的三个核心概念:单向数据流、Reducer 和 Store。我会结合我在实际项目中的踩坑经验,帮你避开那些常见的坑。
什么是 MVI?
MVI 全称是 Model-View-Intent。你可能会问,这和 MVP、MVVM 有什么区别?
简单说:
- Model:代表应用的状态(State),是一个不可变的数据对象。
- View:负责渲染 UI,根据 State 显示内容。
- Intent:不是 Android 的 Intent,而是用户的操作意图。比如点击按钮、输入文字,都算 Intent。
核心思想就一句话:数据单向流动,状态集中管理。
单向数据流示意图:
用户操作 → Intent → Reducer → 新 State → View 更新
整个过程是闭环的,没有反向修改数据的路径。
下面我用一张 SVG 图来展示这个流程,你一看就明白。
你看,数据从用户操作出发,经过 Reducer 处理,生成新 State,然后 View 自动刷新。整个过程没有回头路,这就是单向数据流的精髓。
为什么 Compose 适合 MVI?
Compose 的核心理念是「状态驱动 UI」。你想想看,Compose 函数本身就是根据 State 来渲染界面的。State 变了,UI 自动重组。这和 MVI 的「State → View」完全吻合。
我在项目中遇到过一个问题:用 MVVM 时,ViewModel 里经常出现多个 LiveData,互相依赖,调试起来很头疼。后来切换到 MVI,所有状态集中到一个 State 对象里,问题一下子就清晰了。
我的建议:如果你的 Compose 项目有 5 个以上的页面,或者涉及复杂的状态交互,MVI 会是一个很好的选择。它让状态变化变得可预测、可追踪。
核心概念拆解
1. State(状态)
State 就是应用在某一时刻的快照。它必须是不可变的(immutable)。为什么?因为只有不可变,才能保证每次状态变化都是全新的对象,Compose 才能准确判断是否需要重组。
// 定义一个 State 数据类
data class CounterState(
val count: Int = 0,
val isLoading: Boolean = false,
val errorMessage: String? = null
)
注意:所有字段都用 val,不要用 var。如果你需要修改,就创建一个新的对象。
2. Intent(意图)
Intent 是用户操作的抽象。它不是一个具体的类,而是一个 sealed class 或接口,用来描述所有可能的用户行为。
sealed class CounterIntent {
object Increment : CounterIntent()
object Decrement : CounterIntent()
data class SetValue(val value: Int) : CounterIntent()
}
这样做的好处是:所有操作一目了然。你打开这个文件,就知道这个页面能做什么。
3. Reducer(归约器)
Reducer 是一个纯函数。它接收当前的 State 和一个 Intent,然后返回一个新的 State。纯函数的意思是:同样的输入,永远得到同样的输出,没有副作用。
fun counterReducer(state: CounterState, intent: CounterIntent): CounterState {
return when (intent) {
is CounterIntent.Increment -> state.copy(count = state.count + 1)
is CounterIntent.Decrement -> state.copy(count = state.count - 1)
is CounterIntent.SetValue -> state.copy(count = intent.value)
}
}
这里用了 Kotlin 的 copy() 方法,它基于原对象创建一个新对象,只修改指定字段。这就是不可变性的典型用法。
注意:Reducer 里绝对不能做网络请求、数据库读写等操作。它只负责计算新状态。我曾经见过有人把网络请求写在 Reducer 里,结果状态混乱,调试了整整一天。
4. Store(存储)
Store 是 MVI 的核心容器。它持有当前的 State,并负责分发 Intent 给 Reducer,然后更新 State。在 Compose 中,我们通常用 ViewModel 来实现 Store。
class CounterViewModel : ViewModel() {
// 使用 MutableStateFlow 来持有状态
private val _state = MutableStateFlow(CounterState())
val state: StateFlow<CounterState> = _state.asStateFlow()
// 分发 Intent
fun dispatch(intent: CounterIntent) {
val currentState = _state.value
val newState = counterReducer(currentState, intent)
_state.value = newState
}
}
在 Compose 中,我们这样使用:
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "计数: ${state.count}", fontSize = 32.sp)
Spacer(modifier = Modifier.height(16.dp))
Row {
Button(onClick = { viewModel.dispatch(CounterIntent.Decrement) }) {
Text("-")
}
Spacer(modifier = Modifier.width(16.dp))
Button(onClick = { viewModel.dispatch(CounterIntent.Increment) }) {
Text("+")
}
}
}
}
你看,整个流程非常清晰:用户点击按钮 → 发送 Intent → ViewModel 调用 Reducer → 更新 State → Compose 自动重组 UI。
实际项目中的经验
我在做一个电商 App 时,购物车页面用了 MVI。一开始觉得有点啰嗦,但后来发现调试 bug 时太爽了。为什么?因为每个状态变化都有迹可循。我只需要打印出每次 State 的变化,就能定位问题。
举个例子,用户添加商品到购物车:
sealed class CartIntent {
data class AddItem(val productId: String, val quantity: Int) : CartIntent()
data class RemoveItem(val productId: String) : CartIntent()
object Checkout : CartIntent()
}
data class CartState(
val items: List<CartItem> = emptyList(),
val totalPrice: Double = 0.0,
val isCheckingOut: Boolean = false
)
fun cartReducer(state: CartState, intent: CartIntent): CartState {
return when (intent) {
is CartIntent.AddItem -> {
// 添加商品逻辑
val newItems = state.items + CartItem(intent.productId, intent.quantity)
state.copy(items = newItems, totalPrice = calculateTotal(newItems))
}
is CartIntent.RemoveItem -> {
val newItems = state.items.filter { it.productId != intent.productId }
state.copy(items = newItems, totalPrice = calculateTotal(newItems))
}
is CartIntent.Checkout -> {
state.copy(isCheckingOut = true)
}
}
}
这里有个细节:calculateTotal 是一个纯函数,只依赖传入的列表计算总价。这样测试起来非常方便。
避坑指南:我曾经把网络请求放在 Reducer 里,结果导致状态更新和网络回调顺序错乱。正确的做法是:在 ViewModel 中处理副作用(网络请求、数据库等),然后通过新的 Intent 将结果送回 Reducer。
副作用处理
MVI 中,副作用(Side Effect)是指那些不在 Reducer 中处理的操作,比如网络请求、导航、弹 Toast 等。我习惯用 Kotlin 协程来处理:
class CartViewModel : ViewModel() {
private val _state = MutableStateFlow(CartState())
val state: StateFlow<CartState> = _state.value
fun dispatch(intent: CartIntent) {
when (intent) {
is CartIntent.Checkout -> {
// 先更新状态为加载中
_state.value = cartReducer(_state.value, intent)
// 发起网络请求
viewModelScope.launch {
try {
val result = apiService.checkout(_state.value.items)
// 请求成功后,发送新的 Intent
_state.value = cartReducer(_state.value, CartIntent.CheckoutSuccess(result))
} catch (e: Exception) {
_state.value = cartReducer(_state.value, CartIntent.CheckoutError(e.message))
}
}
}
else -> {
_state.value = cartReducer(_state.value, intent)
}
}
}
}
这样,副作用被隔离在 ViewModel 中,Reducer 始终保持纯净。测试时,你只需要测试 Reducer 的逻辑,不需要 mock 网络请求。
总结
MVI 模式在 Compose 中的优势很明显:
- 可预测性:状态变化路径唯一,调试方便
- 可测试性:Reducer 是纯函数,单元测试简单
- 可维护性:所有操作集中管理,代码结构清晰
当然,MVI 也不是银弹。如果你的页面逻辑非常简单,比如只有一个开关按钮,用 MVI 反而显得冗余。我个人的经验是:当页面有 3 个以上交互点,或者状态之间有依赖关系时,MVI 的价值就体现出来了。
嗯,关于 MVI 在 Compose 中的应用,今天就聊到这里。你可以在自己的项目中试试看,从一个小页面开始,慢慢体会单向数据流带来的好处。