实战:构建一个完整的MVI架构计数器应用
好了,终于到了动手环节。前面我们聊了那么多密封类、内联类的理论,说实话,不写点真东西,总觉得心里不踏实。这一章,我们就用MVI架构,从零搭一个计数器应用。
你可能会想:「计数器?太简单了吧?」嗯,我当年也这么想。但真正动手时才发现,MVI的闭环设计、状态管理、副作用处理,每一个环节都有讲究。说白了,计数器虽小,五脏俱全。
项目结构概览
我个人习惯把MVI分成三层:View(界面层)、Intent(意图层)、State(状态层)。再加一个Effect处理副作用。这样结构清晰,后期维护也省心。
核心思想:用户操作 → Intent → Reducer → State → UI渲染。单向数据流,没有乱七八糟的双向绑定。
先看整体架构图:
第一步:定义状态与意图
先写密封类。为什么用密封类?因为状态是有限的、确定的。你想想看,计数器无非就几种状态:正常显示、加载中、出错。用密封类可以穷举所有情况,编译器还能帮你检查。
// 状态密封类
sealed class CounterState {
object Idle : CounterState()
data class Counting(val count: Int) : CounterState()
data class Error(val message: String) : CounterState()
}
// 意图密封类
sealed class CounterIntent {
object Increment : CounterIntent()
object Decrement : CounterIntent()
object Reset : CounterIntent()
data class SetValue(val value: Int) : CounterIntent()
}
这里有个细节:Idle用object,因为它是单例;Counting用data class,因为需要携带计数值。我在项目中见过有人把所有状态都写成data class,其实没必要——没有数据的状态,用object更省内存。
第二步:Reducer——核心逻辑
Reducer就是一个纯函数。给它当前状态和意图,它返回新状态。没有副作用,没有网络请求,干干净净。
fun counterReducer(state: CounterState, intent: CounterIntent): CounterState {
return when (intent) {
is CounterIntent.Increment -> {
val current = (state as? CounterState.Counting)?.count ?: 0
CounterState.Counting(current + 1)
}
is CounterIntent.Decrement -> {
val current = (state as? CounterState.Counting)?.count ?: 0
if (current <= 0) {
CounterState.Error("计数不能为负")
} else {
CounterState.Counting(current - 1)
}
}
is CounterIntent.Reset -> CounterState.Counting(0)
is CounterIntent.SetValue -> {
if (intent.value < 0) {
CounterState.Error("不能设为负数")
} else {
CounterState.Counting(intent.value)
}
}
}
}
注意看Decrement的处理。我曾经在项目里没做边界检查,结果用户狂点减号,计数变成负数,UI直接崩了。从那以后,我养成了「任何状态变更都要做合法性校验」的习惯。
小技巧:Reducer里不要写任何异步代码。如果你需要网络请求或数据库操作,请交给Effect层处理。
第三步:ViewModel——状态持有者
ViewModel负责持有状态、接收意图、触发Reducer、处理副作用。这里我们用StateFlow来暴露状态,用Channel来发送一次性事件(比如Toast提示)。
class CounterViewModel : ViewModel() {
private val _state = MutableStateFlow<CounterState>(CounterState.Idle)
val state: StateFlow<CounterState> = _state.asStateFlow()
private val _effect = Channel<CounterEffect>(Channel.BUFFERED)
val effect: Flow<CounterEffect> = _effect.receiveAsFlow()
fun processIntent(intent: CounterIntent) {
val newState = counterReducer(_state.value, intent)
_state.value = newState
// 处理副作用
if (newState is CounterState.Error) {
viewModelScope.launch {
_effect.send(CounterEffect.ShowToast(newState.message))
}
}
}
}
sealed class CounterEffect {
data class ShowToast(val message: String) : CounterEffect()
}
这里有个坑:Channel的容量设置。我一开始用Channel.RENDEZVOUS,结果快速点击时事件丢失了。后来改成Channel.BUFFERED才解决。你想想看,用户连续点击时,每个点击都应该触发对应的反馈,不能丢。
第四步:UI层——Compose实现
UI层就简单了。观察状态,渲染界面。用户点击时发送意图。
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
val coroutineScope = rememberCoroutineScope()
// 处理副作用
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is CounterEffect.ShowToast -> {
Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
}
}
}
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (state) {
is CounterState.Idle -> {
Text("准备就绪", fontSize = 24.sp)
Button(onClick = { viewModel.processIntent(CounterIntent.Reset) }) {
Text("开始计数")
}
}
is CounterState.Counting -> {
val count = (state as CounterState.Counting).count
Text("计数: $count", fontSize = 48.sp)
Row {
Button(onClick = { viewModel.processIntent(CounterIntent.Increment) }) {
Text("+")
}
Spacer(modifier = Modifier.width(16.dp))
Button(onClick = { viewModel.processIntent(CounterIntent.Decrement) }) {
Text("-")
}
}
Button(onClick = { viewModel.processIntent(CounterIntent.Reset) }) {
Text("重置")
}
}
is CounterState.Error -> {
val error = (state as CounterState.Error).message
Text("出错了: $error", color = Color.Red)
Button(onClick = { viewModel.processIntent(CounterIntent.Reset) }) {
Text("重置")
}
}
}
}
}
看到when表达式了吗?因为CounterState是密封类,编译器知道所有分支都覆盖了。如果你漏掉一个分支,代码根本编译不过。这就是密封类在MVI中的最大价值——编译期安全。
第五步:内联类的妙用
最后,我们给计数器加一个内联类,用来包装计数值。为什么?因为原始Int太容易混淆了。万一有人把「步长」和「计数值」搞混了呢?
@JvmInline
value class Count(val value: Int) {
init {
require(value >= 0) { "计数不能为负" }
}
operator fun plus(other: Int): Count = Count(value + other)
operator fun minus(other: Int): Count = Count(value - other)
}
// 在State中使用
data class Counting(val count: Count) : CounterState()
内联类在编译时会被替换成原始类型,所以没有额外内存开销。但它提供了类型安全——你不能把一个StepSize赋值给Count。我在重构一个老项目时,就因为「值类型混乱」导致过一个线上bug,后来用内联类彻底解决了。
注意:内联类的init块会在每次构造时执行。如果你在循环中大量创建内联类,可能会有性能损耗。不过对于计数器这种场景,完全不用担心。
完整流程回顾
我们来走一遍完整流程:
- 用户点击「+」按钮
- UI层发送
CounterIntent.Increment - ViewModel调用
counterReducer,生成新状态Counting(Count(1)) - 状态通过
StateFlow下发到UI层 - Compose重组,显示「计数: 1」
整个过程是单向的、可预测的。没有数据突变,没有隐式状态修改。你想想看,如果以后要加「撤销」功能,只需要保存历史状态列表,然后回退就行了——因为每个状态都是不可变的。
| 组件 | 职责 | 关键点 |
|---|---|---|
| CounterState | 定义所有可能的状态 | 密封类,穷举所有分支 |
| CounterIntent | 定义用户的所有操作 | 密封类,每个操作一个子类 |
| counterReducer | 纯函数,状态转换 | 无副作用,可测试 |
| CounterViewModel | 持有状态,处理副作用 | StateFlow + Channel |
| CounterEffect | 一次性事件(Toast等) | 密封类,与状态分离 |
| Count | 类型安全的计数值 | 内联类,零开销 |
说实话,这个计数器虽然简单,但MVI的核心思想全在里面了。你把这个吃透了,以后写任何复杂页面——购物车、表单、聊天列表——都可以套用这个模式。区别只是状态更复杂、意图更多、Reducer逻辑更重,但骨架是一样的。
嗯,动手试试吧。把代码敲一遍,然后试着加一个「步长设置」功能。你会发现,MVI的扩展性真的很好——加一个新意图,加一个状态分支,改一下Reducer,完事。不会牵一发而动全身。
公众号:蓝海资料掘金营,微信deep3321