MVI 的 "单向数据流" 和 "状态驱动 UI",跟奶茶店从 "顾客点单→前台处理→制作→反馈" 的流程几乎一模一样,用这个故事讲,小白也能秒懂。
一、先搞懂:MVI 到底是什么?(奶茶店故事版)
MVI 全称Model-View-Intent ,核心是 "单向数据流 " 和 "UI 完全由状态驱动"。我们用 "奶茶店" 对应 MVI 的 4 个核心角色:
MVI 组件 | 奶茶店角色 | 核心职责 |
---|---|---|
View | 顾客 | 1. 提出需求(比如 "要一杯三分糖珍珠奶茶");2. 接收结果(比如 "订单已制作完成")并做出反应(拿到奶茶) |
Intent | 顾客的 "点单需求" | 封装 View 的所有用户行为(点单、加配料、取消订单),是 View 发给 ViewModel 的 "指令" |
ViewModel | 奶茶店前台 | 1. 接收顾客的需求(Intent);2. 协调制作间(数据层)处理;3. 把 "订单状态"(比如 "制作中""已完成")实时告诉顾客 |
Model | 订单数据 + 订单状态 | 1. 状态(State) :订单当前的情况(初始、加载中、成功、失败);2. 数据(Data) :订单详情(奶茶类型、配料、价格) |
核心逻辑一句话:顾客(View)只提需求(Intent),不关心制作;前台(ViewModel)只处理和同步状态,不做具体制作;制作间(Repository)只做奶茶,不跟顾客直接沟通------ 全程单向流动,不会乱。
二、代码实现:手把手写一个 "奶茶订单 APP"(XML 版)
我们就实现一个简单功能:用户输入奶茶类型 + 配料,点击 "提交订单",APP 显示 "加载中→订单成功 / 失败",点击 "取消订单" 则重置状态。
1. 第一步:定义 "顾客的需求"------Intent(密封类)
顾客的需求是固定的(提交订单、取消订单),所以用密封类(Sealed Class)定义所有可能的 Intent,避免乱传无效需求。
kotlin
// 对应"顾客的所有可能需求"
sealed class OrderIntent {
// 提交订单:携带奶茶类型和配料(需求的参数)
data class PlaceOrder(val milkTeaType: String, val topping: String) : OrderIntent()
// 取消订单:无参数
object CancelOrder : OrderIntent()
}
2. 第二步:定义 "订单状态"------State(密封类)
订单的状态也是固定的(初始、加载中、成功、失败),用密封类定义,这样 View 能 "精准响应每一种状态"(比如 "加载中" 显示进度条,"成功" 显示订单详情)。
kotlin
// 对应"订单的所有可能状态"
sealed class OrderState {
// 初始状态(刚打开APP,还没点单)
object Initial : OrderState()
// 加载中(提交订单后,制作间正在做)
object Loading : OrderState()
// 成功(订单完成,携带订单数据)
data class Success(val orderData: OrderData) : OrderState()
// 失败(比如没珍珠了,携带错误信息)
data class Error(val errorMsg: String) : OrderState()
}
// 对应"订单详情数据"(Model的一部分)
data class OrderData(
val orderId: String, // 订单号
val milkTeaType: String, // 奶茶类型
val topping: String, // 配料
val status: String // 订单状态描述
)
3. 第三步:实现 "奶茶前台"------ViewModel
ViewModel 是 MVI 的核心,负责 "接收 Intent→调用数据层→更新 State"。这里用StateFlow(状态流)来同步 State,因为它能 "实时向 View 推送最新状态"。
kotlin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID
class MilkTeaViewModel : ViewModel() {
// 1. 私有可变StateFlow(前台自己管理状态,不允许外部直接改)
private val _orderState = MutableStateFlow<OrderState>(OrderState.Initial)
// 2. 对外暴露不可变StateFlow(View只能"观察",不能"修改")
val orderState: StateFlow<OrderState> = _orderState.asStateFlow()
// 3. 接收View发送的Intent(处理顾客需求的入口)
fun handleIntent(intent: OrderIntent) {
when (intent) {
// 处理"提交订单"需求
is OrderIntent.PlaceOrder -> placeOrder(intent.milkTeaType, intent.topping)
// 处理"取消订单"需求
OrderIntent.CancelOrder -> cancelOrder()
}
}
// 4. 调用"制作间"(Repository)处理订单
private fun placeOrder(milkTeaType: String, topping: String) {
viewModelScope.launch {
// 第一步:先发送"加载中"状态(告诉顾客:正在做,稍等)
_orderState.value = OrderState.Loading
try {
// 第二步:调用制作间做奶茶(模拟网络/本地数据请求,延迟2秒)
val orderData = MilkTeaRepository.submitOrder(milkTeaType, topping)
// 第三步:发送"成功"状态(告诉顾客:做好了,拿奶茶)
_orderState.value = OrderState.Success(orderData)
} catch (e: Exception) {
// 第四步:如果失败,发送"错误"状态(告诉顾客:没珍珠了)
_orderState.value = OrderState.Error(e.message ?: "订单提交失败,请重试")
}
}
}
// 5. 取消订单(重置为初始状态)
private fun cancelOrder() {
_orderState.value = OrderState.Initial
}
}
// 模拟"奶茶制作间"(Repository:数据层,负责实际的业务逻辑)
object MilkTeaRepository {
// 提交订单(模拟异步请求)
suspend fun submitOrder(milkTeaType: String, topping: String): OrderData {
delay(2000) // 模拟制作时间(2秒)
// 模拟偶尔失败(比如配料没了)
if (topping == "芋圆") {
throw Exception("抱歉,芋圆卖完了,换个配料吧~")
}
// 制作成功,返回订单数据(订单号用UUID随机生成)
return OrderData(
orderId = UUID.randomUUID().toString().substring(0, 8),
milkTeaType = milkTeaType,
topping = topping,
status = "制作完成,请到取餐口领取"
)
}
}
4. 第四步:实现 "顾客界面"------View(Activity+XML)
View 的职责只有两个:发送 Intent (点击按钮)和观察 State 并更新 UI(根据状态显示进度条 / 订单 / 错误信息)。
(1)XML 布局(res/layout/activity_milk_tea.xml)
xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="20dp">
<!-- 1. 输入奶茶类型 -->
<EditText
android:id="@+id/etMilkTeaType"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="输入奶茶类型(如:珍珠奶茶)"
android:inputType="text"/>
<!-- 2. 输入配料 -->
<EditText
android:id="@+id/etTopping"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="输入配料(如:珍珠/椰果,试下芋圆会失败)"
android:inputType="text"
android:layout_marginTop="10dp"/>
<!-- 3. 按钮:提交/取消订单 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="20dp">
<Button
android:id="@+id/btnPlaceOrder"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="提交订单"
android:layout_marginEnd="10dp"/>
<Button
android:id="@+id/btnCancelOrder"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="取消订单"/>
</LinearLayout>
<!-- 4. 加载中进度条(初始隐藏) -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="20dp"
android:visibility="gone"/>
<!-- 5. 显示订单结果(状态/详情/错误) -->
<TextView
android:id="@+id/tvOrderResult"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:textSize="16sp"/>
</LinearLayout>
(2)Activity 代码(MilkTeaActivity.kt)
kotlin
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
class MilkTeaActivity : AppCompatActivity() {
// 1. 绑定控件
private lateinit var etMilkTeaType: EditText
private lateinit var etTopping: EditText
private lateinit var btnPlaceOrder: Button
private lateinit var btnCancelOrder: Button
private lateinit var progressBar: ProgressBar
private lateinit var tvOrderResult: TextView
// 2. 初始化ViewModel
private lateinit var viewModel: MilkTeaViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_milk_tea)
// 3. 绑定控件
initViews()
// 4. 初始化ViewModel
viewModel = ViewModelProvider(this)[MilkTeaViewModel::class.java]
// 5. 发送Intent(按钮点击事件)
initClickListeners()
// 6. 观察State,更新UI(核心:View只被动接收状态)
observeOrderState()
}
private fun initViews() {
etMilkTeaType = findViewById(R.id.etMilkTeaType)
etTopping = findViewById(R.id.etTopping)
btnPlaceOrder = findViewById(R.id.btnPlaceOrder)
btnCancelOrder = findViewById(R.id.btnCancelOrder)
progressBar = findViewById(R.id.progressBar)
tvOrderResult = findViewById(R.id.tvOrderResult)
}
private fun initClickListeners() {
// 提交订单:发送PlaceOrder Intent(携带输入的参数)
btnPlaceOrder.setOnClickListener {
val milkTeaType = etMilkTeaType.text.toString()
val topping = etTopping.text.toString()
if (milkTeaType.isBlank() || topping.isBlank()) {
tvOrderResult.text = "请输入奶茶类型和配料!"
return@setOnClickListener
}
viewModel.handleIntent(OrderIntent.PlaceOrder(milkTeaType, topping))
}
// 取消订单:发送CancelOrder Intent
btnCancelOrder.setOnClickListener {
viewModel.handleIntent(OrderIntent.CancelOrder)
}
}
private fun observeOrderState() {
// 用lifecycleScope观察StateFlow(自动绑定生命周期,避免内存泄漏)
lifecycleScope.launch {
viewModel.orderState.collect { state ->
// 根据不同状态更新UI(View的核心工作)
when (state) {
OrderState.Initial -> {
// 初始状态:隐藏进度条,清空结果,启用按钮
progressBar.visibility = View.GONE
tvOrderResult.text = ""
btnPlaceOrder.isEnabled = true
}
OrderState.Loading -> {
// 加载中:显示进度条,清空结果,禁用按钮(防止重复点击)
progressBar.visibility = View.VISIBLE
tvOrderResult.text = ""
btnPlaceOrder.isEnabled = false
}
is OrderState.Success -> {
// 成功:隐藏进度条,显示订单详情,启用按钮
progressBar.visibility = View.GONE
val order = state.orderData
tvOrderResult.text = "订单成功!\n" +
"订单号:${order.orderId}\n" +
"奶茶:${order.milkTeaType}\n" +
"配料:${order.topping}\n" +
"状态:${order.status}"
btnPlaceOrder.isEnabled = true
}
is OrderState.Error -> {
// 失败:隐藏进度条,显示错误信息,启用按钮
progressBar.visibility = View.GONE
tvOrderResult.text = "订单失败:${state.errorMsg}"
btnPlaceOrder.isEnabled = true
}
}
}
}
}
}
三、时序图:看清楚 MVI 的 "单向数据流"
用时序图能直观看到 "数据从 View 出发,最终回到 View" 的完整流程,全程单向,没有反向依赖。

DataSource (模拟数据)Repository (制作间)ViewModelView (Activity)DataSource (模拟数据)Repository (制作间)ViewModelView (Activity)用户输入后点击按钮,发送需求接收Intent后,先同步"加载中"状态状态更新后,View自动响应观察orderState(StateFlow)发送初始状态Initial发送OrderIntent.PlaceOrder(珍珠奶茶, 珍珠)发送状态Loading显示进度条,禁用按钮调用submitOrder(珍珠奶茶, 珍珠)模拟请求(延迟2秒)返回订单数据返回OrderData发送状态Success(OrderData)隐藏进度条,显示订单详情发送OrderIntent.CancelOrder发送状态Initial清空结果,恢复初始界面
四、MVI 的核心优势(为什么要学它?)
- 调试超简单:单向数据流就像 "奶茶订单流程",出问题时能顺着 "View→ViewModel→Repository" 一步步查,比如 "订单没显示",可能是 ViewModel 没发 Success 状态,也可能是 Repository 抛了异常。
- 避免状态不一致 - 单一数据源 UI 绝对统一:UI 完全由 State 驱动,只要 State 相同,UI 就一定相同。比如 "Loading 状态" 无论在什么场景下,都是显示进度条 + 禁用按钮,不会出现 "有的地方 Loading 显示进度条,有的地方显示文字" 的混乱。
- 解耦彻底:View 只做 "发送 Intent + 更新 UI",ViewModel 只做 "处理 Intent + 同步 State",Repository 只做 "业务逻辑",各司其职,改一个地方不会影响其他地方(比如改制作流程,不用动前台和顾客界面)。
- 线程安全 - 不可变对象天然线程安全
- 易于测试 -
Intent → State
的映射关系清晰