文章目录
- 第 1 章 MVI 与 MVVM:不是换名,是状态契约
-
-
- Java 对比
- 原理补充
- 踩坑
- 动手练
-
- 第 2 章 Intent 与 Action:用户意图的类型安全入口
-
-
- Java 对比
- Android 实际场景
- 原理补充
- 踩坑
- 动手练
-
- 第 3 章 Reduce 到 State:纯函数状态机
-
-
- UiState:单对象描述整屏
- Reduce 示例
- Java 对比
- 原理补充
- 踩坑
- 动手练
-
- 第 4 章 单向数据流:View 只读、只发 Intent
-
-
- Fragment 收集(View 系统)
- Compose 收集
- 与 Room 组合:数据库也是下行 State 源
- 原理补充
- 踩坑
- 动手练
-
- 第 5 章 副作用通道:导航、Toast 与一次性事件
-
-
- 方案对比
- Effect 定义与发送
- 与 Reduce 的边界
- Java 对比
- 原理补充
- 踩坑
- 动手练
-
- 第 6 章 何时选 MVI:收益、成本与团队落地
-
-
- 适合 MVI 的信号
- 不必强上 MVI 的场景
- 与 MVVM 共存策略
- 测试策略
- 常见错误汇总
- 动手练
-
- 面试速查 · 追问链
-
-
- 追问链 #1:MVVM 和 MVI 本质区别?怎么选? 🔥
- 追问链 #2:`reduce` 里能不能调 Repository? 🔥
- 追问链 #3:一次性导航事件为什么不要放 State? ⭐
- 追问链 #4:MVI 如何单测? ⭐
- 追问链 #5:多模块项目 MVI 放哪一层? 💡
-
- 完整链路一句通
- 相关推荐
第 1 章 MVI 与 MVVM:不是换名,是状态契约
你已经会用 ViewModel + StateFlow 做 MVVM:UI 观察 uiState,用户点按钮调 viewModel.refresh()。这在简单页足够。但当屏幕状态组合变多——加载、刷新、筛选、分页、错误重试、空态——MVVM 的自由度过高会成为隐患:
MVI(Model-View-Intent)在 MVVM 之上加了一层契约:所有用户与系统事件统一为 Intent(或 Action),经单一 reduce 产出唯一 State;View 只读 State、只发 Intent,形成闭环。 
Java 对比
| 状态入口 | Presenter 多方法、回调交叉 | 单一 onIntent(Intent) |
| 状态表达 | 多个字段 + boolean 旗标 | 单一不可变 UiState |
| UI 更新 | view.showX() 命令式 | StateFlow 驱动声明式 UI |
| 可测试性 | Mock View 接口多 | reduce(old, intent) 纯函数单测 |
| 典型库 | Rx + MVP 样板 | StateFlow + Compose / Fragment |
MVI 不是否定 MVVM:ViewModel 仍是载体,Repository 仍在 Data 层。差别在 Presentation 层是否强制 UDF(Unidirectional Data Flow)。
原理补充
Google 架构指南中的 UDF 与 MVI 同构:State 向下、Event 向上、单一数据源(SSOT)。StateFlow 的 value 即 SSOT;MVI 把「Event 向上」具象为类型安全的 sealed interface Intent,避免 fun doA(); fun doB() 无限膨胀。
踩坑
- 把 MVI 理解成「多一个 Intent 数据类」——若 reduce 不纯、仍多处直接 _state.value = …,只是换皮 MVVM。
- 认为 MVI 必须引入 Orbit、Mavericks 等框架;手写 StateFlow + sealed Intent 已是生产级最小实现。
- 简单设置页强行 MVI:Intent/State 类型比业务还复杂,Review 成本倒挂。
动手练
- A:用一句话说明 MVVM 与 MVI 在「状态入口数量」上的核心差异。
- B(起点):下面 ViewModel 有三个入口各自改状态,指出会出现哪种非法组合,并改为单一 onIntent 骨架(无需写完 Repository):
class BadListViewModel : ViewModel() {
private val _loading = MutableStateFlow(false)
private val _error = MutableStateFlow<String?>(null)
private val _items = MutableStateFlow<List<Item>>(emptyList())
fun refresh() { _loading.value = true; _error.value = null /* … */ }
fun onError(msg: String) { _loading.value = false; _error.value = msg }
fun clearError() { _error.value = null } // 可能与 loading 不同步
}
第 2 章 Intent 与 Action:用户意图的类型安全入口
MVI 的第一块砖是 Intent(有些团队叫 Action 或 Event,本文统一用 Intent,与 Orbit、MVI 文献一致)。所有「想让屏幕发生什么」的操作,都必须变成 Intent 实例,从 View 传入 ViewModel。
// 订单列表屏:Intent 枚举用户与系统事件
sealed interface OrderListIntent {
data object Refresh : OrderListIntent
data class FilterChanged(val status: OrderStatus?) : OrderListIntent
data object LoadMore : OrderListIntent
data object Retry : OrderListIntent
data class ItemClicked(val id: String) : OrderListIntent
}
ViewModel 暴露唯一入口:
class OrderListViewModel(
private val repository: OrderRepository,
) : ViewModel() {
private val _state = MutableStateFlow(OrderListUiState())
val state: StateFlow<OrderListUiState> = _state.asStateFlow()
fun onIntent(intent: OrderListIntent) {
when (intent) {
OrderListIntent.Refresh -> refresh()
is OrderListIntent.FilterChanged -> updateFilter(intent.status)
OrderListIntent.LoadMore -> loadMore()
OrderListIntent.Retry -> refresh()
is OrderListIntent.ItemClicked -> emitNavigation(intent.id)
}
}
}
Compose 侧:
@Composable
fun OrderListScreen(viewModel: OrderListViewModel = hiltViewModel()) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
OrderListContent(
state = uiState,
onIntent = viewModel::onIntent,
)
}
// 下拉刷新
SwipeRefresh(onRefresh = { onIntent(OrderListIntent.Refresh) }) { /* … */ }
Java 对比
// Java:接口回调分散,类型不安全
interface OrderListView {
void onRefreshClicked();
void onFilterSelected(OrderStatus status);
void onItemClicked(String id);
}
// Kotlin:sealed 穷尽 when,编译期检查新增 Intent 是否处理
when (intent) {
OrderListIntent.Refresh -> ...
is OrderListIntent.FilterChanged -> ...
// 漏分支编译器警告
}
Android 实际场景
系统事件也建模为 Intent,而不是 ViewModel 里偷偷 init { refresh() } 却无类型记录:
sealed interface OrderListIntent {
// …
data object ScreenStarted : OrderListIntent // 首次进入
data object ScreenStopped : OrderListIntent // 可选:取消 in-flight
}
这样单元测试可以 onIntent(ScreenStarted) 重放首屏加载,与 Espresso 点击路径一致。
Navigation 参数进入时,用 Intent 表达「外部命令」:
data class OpenWithFilter(val status: OrderStatus) : OrderListIntent
// NavHost 回调
LaunchedEffect(filterArg) {
viewModel.onIntent(OrderListIntent.OpenWithFilter(filterArg))
}
原理补充
sealed interface 在 Kotlin 2.x 是表达 Intent 的首选:比 sealed class 更轻,允许 data object 单例事件。Intent 不应携带 Context、View 引用或回调 lambda——保持可序列化、可测试。
踩坑
- Intent 里塞 () -> Unit 回调:破坏单向流,State 无法描述「待执行回调」。
- onIntent 里写 200 行业务:应 when 分发到 private suspend fun,或引入 Actor/Channel 串行处理。
- 与 UI Event(一次性 Toast)混淆:导航/Toast 走副作用通道(第 5 章),不要写进 UiState 永久字段。
动手练
- A:为「登录屏」列出 5 个 LoginIntent(含提交、改字段、忘记密码)。
- C:when (intent) 分支里调用 viewModelScope.launch 时,如何保证快速连点 Refresh 不会启动两个并发请求?(提示:Job 取消或 flatMapLatest)
第 3 章 Reduce 到 State:纯函数状态机
MVI 的核心是 reduce(oldState, intent) -> newState(同步部分)。异步结果(网络返回)作为 内部 Intent 或 Result Intent 再次进入 reduce,保持状态变迁可描述。
UiState:单对象描述整屏
data class OrderListUiState(
val items: List<Order> = emptyList(),
val filter: OrderStatus? = null,
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val error: UiText? = null,
val canLoadMore: Boolean = false,
) {
val isEmpty: Boolean get() = items.isEmpty() && !isRefreshing && error == null
}
复杂屏用 sealed interface 表达互斥态(与 MVVM 相同技巧,MVI 强制单一出口):
sealed interface OrderListUiState {
data object Loading : OrderListUiState
data class Content(
val items: List<Order>,
val filter: OrderStatus?,
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val canLoadMore: Boolean = false,
) : OrderListUiState
data class Error(val message: UiText, val lastContent: List<Order> = emptyList()) : OrderListUiState
}
Reduce 示例
private fun reduce(
state: OrderListUiState,
intent: OrderListIntent,
): OrderListUiState = when (intent) {
OrderListIntent.Refresh ->
when (state) {
is OrderListUiState.Content -> state.copy(isRefreshing = true, error = null)
else -> OrderListUiState.Loading
}
is OrderListIntent.FilterChanged ->
when (state) {
is OrderListUiState.Content -> state.copy(filter = intent.status)
is OrderListUiState.Loading -> state // 或记录 pending filter
is OrderListUiState.Error -> OrderListUiState.Loading
}
is OrderListIntent.Internal.ItemsLoaded -> // 内部 Intent,见下文
OrderListUiState.Content(
items = intent.items,
filter = intent.filter,
isRefreshing = false,
canLoadMore = intent.hasMore,
)
is OrderListIntent.Internal.LoadFailed ->
OrderListUiState.Error(intent.message, lastContent = state.itemsOrEmpty())
else -> state
}
异步加载模式:副作用产生 Result Intent:
private fun refresh() {
viewModelScope.launch {
val snapshot = _state.value
_state.update { reduce(it, OrderListIntent.Refresh) }
val filter = snapshot.filterOrNull()
val result = runCatching { repository.getOrders(filter) }
val intent = result.fold(
onSuccess = { OrderListIntent.Internal.ItemsLoaded(it, filter, hasMore = false) },
onFailure = { OrderListIntent.Internal.LoadFailed(mapError(it)) },
)
_state.update { reduce(it, intent) }
}
}
Internal 嵌套 sealed 区分用户 Intent 与系统 Result,避免 View 误发:
sealed interface OrderListIntent {
// 用户 Intent …
sealed interface Internal : OrderListIntent {
data class ItemsLoaded(
val items: List<Order>,
val filter: OrderStatus?,
val hasMore: Boolean,
) : Internal
data class LoadFailed(val message: UiText) : Internal
}
}
Java 对比
Java 无 sealed/when 穷尽,状态机常落在 enum + switch,新增状态易漏 default。Kotlin sealed interface + when 让 非法状态组合在编译期收敛。
原理补充
MutableStateFlow.update { reduce(it, intent) } 保证读-改-写原子性,优于 value = reduce(value, intent) 的 TOCTOU 竞态。reduce 同步部分应是纯函数:不读 SharedPreferences、不调 repository——副作用放在 launch 块,结果再以 Intent 回注。
踩坑
- reduce 里调 repository.getOrders():无法单测,违背 MVI 分层。
- 用 var/mutableListOf 原地改 UiState 内部列表:破坏不可变,DiffUtil/Compose equals 失效。
- 忘记 copy():data class 字段多时用 Kotlin 插件生成 copy,或拆分子 State。
动手练
- B(起点):reduce 在 Refresh 时未清除 error,导致 UI 同时显示错误 Snackbar 与列表。写出修复后的 reduce 分支。
- A:为 LoginUiState 设计 Idle / Submitting / Success / Error sealed,写出 SubmitClicked 与 LoginResult 两个 Intent 的 reduce 规则(伪代码即可)。
第 4 章 单向数据流:View 只读、只发 Intent
单向数据流(UDF)在 MVI 中的落地规则可以写成团队规范:
1. View 不得直接修改 ViewModel 内任何 Mutable 状态
2. View 不得调用 repository / useCase
3. 所有用户交互 → onIntent(…)
4. 所有渲染数据 ← state.collect(唯一数据源)
5. 一次性副作用 ← effect.collect(独立通道,见第 5 章)
Fragment 收集(View 系统)
@AndroidEntryPoint
class OrderListFragment : Fragment(R.layout.fragment_order_list) {
private val viewModel: OrderListViewModel by viewModels()
private var _binding: FragmentOrderListBinding? = null
private val binding get() = _binding!!
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.swipeRefresh.setOnRefreshListener {
viewModel.onIntent(OrderListIntent.Refresh)
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { collectState() }
launch { collectEffects() }
}
}
viewModel.onIntent(OrderListIntent.ScreenStarted)
}
private suspend fun collectState() {
viewModel.state.collect { state ->
render(state) // 纯渲染,无业务分支调 repo
}
}
private fun render(state: OrderListUiState) {
binding.swipeRefresh.isRefreshing = state.isRefreshing
when (state) {
is OrderListUiState.Loading -> showLoading()
is OrderListUiState.Content -> showContent(state)
is OrderListUiState.Error -> showError(state)
}
}
}
Compose 收集
@Composable
fun OrderListRoute(viewModel: OrderListViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is OrderListEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.text)
is OrderListEffect.NavigateToDetail -> { /* navController */ }
}
}
}
OrderListScreen(
state = state,
onIntent = viewModel::onIntent,
snackbarHostState = snackbarHostState,
)
}
collectAsStateWithLifecycle 避免后台重组/收集;与 repeatOnLifecycle(STARTED) 等价语义。
与 Room 组合:数据库也是下行 State 源
Repository 暴露 Flow<List<OrderEntity>>,ViewModel 将其合并进 UiState,仍经 Intent 触发刷新:
class OrderListViewModel @Inject constructor(
private val repository: OrderRepository,
) : ViewModel() {
init {
viewModelScope.launch {
repository.observeOrders()
.collect { entities ->
val intent = OrderListIntent.Internal.CacheUpdated(entities)
_state.update { reduce(it, intent) }
}
}
}
}
用户 Refresh Intent → 调 repository.sync() → Room 发射新 Flow → CacheUpdated Intent → reduce。UI 始终只盯 state。
原理补充
StateFlow 是热流,订阅者立即获得当前值;适合「屏幕状态快照」。MVI 的 Model 在 Android 里通常就是 ViewModel + StateFlow,不必另建 Store 类,除非多屏共享。
踩坑
- 在 collect 里 viewModel.onIntent(…) 形成环:除 LaunchedEffect 初始化外,应用户操作触发。
- render() 里 if (error != null) showSnackbar:错误应走 Effect 或 Error 分支 UI,避免每次重组弹 Toast。
- 旋转屏丢 Intent 队列:待处理 Intent 应在 ViewModel 存活期内处理完毕,不依赖 View 暂存。
动手练
- D:在所在工程找一个 Fragment/Composable 直接调用 viewModel.loadX() 的页面,改为 onIntent + collect 单一数据源,验收:旋转屏后状态一致、无重复请求。
- C:画出你负责页面的 UDF 箭头图(View→Intent→VM→State→View),标出 Repository 插入点。
第 5 章 副作用通道:导航、Toast 与一次性事件
并非所有结果都适合写进 UiState。导航、Snackbar、震动、打开系统设置——这些是 一次性副作用(Side Effect),消费后不应残留于 State,否则旋转屏会重复导航。
方案对比
| SharedFlow / Channel Effect | OrderListEffect.Navigate(id) | 推荐;热流、可缓冲 |
| UiState 内 event: SingleEvent? | 读后手动置 null | 易忘清理,Compose 重组敏感 |
| LiveData Event<T> 包装 | 仅活跃观察者消费 | 遗留项目;新代码用 Flow |
Effect 定义与发送
sealed interface OrderListEffect {
data class ShowMessage(val text: String) : OrderListEffect
data class NavigateToDetail(val orderId: String) : OrderListEffect
}
class OrderListViewModel @Inject constructor(
private val repository: OrderRepository,
) : ViewModel() {
private val _state = MutableStateFlow<OrderListUiState>(OrderListUiState.Loading)
val state = _state.asStateFlow()
private val _effects = Channel<OrderListEffect>(Channel.BUFFERED)
val effects = _effects.receiveAsFlow()
fun onIntent(intent: OrderListIntent) {
when (intent) {
is OrderListIntent.ItemClicked -> {
viewModelScope.launch {
_effects.send(OrderListEffect.NavigateToDetail(intent.id))
}
}
// …
}
}
}
Channel.BUFFERED 避免快速连发丢事件;UI 层单消费者 collect。
与 Reduce 的边界
进 State:要渲染在界面上的持久信息(列表、loading、错误文案)
进 Effect:只发生一次、不渲染的状态(导航、Toast、权限弹窗)
错误文案若要在屏上常驻 → UiState.Error;若仅 Snackbar 提示 → Effect.ShowMessage。
Java 对比
Java MVP 常在 Presenter 里 view.showToast(),Presenter 持有 View 接口,生命周期难绑。MVI Effect 由 View 收集,ViewModel 不持有 View/NavController,与 Jetpack 生命周期对齐。
原理补充
Orbit MVI、Mavericks 内置 sideEffect;手写 Channel 约 20 行。注意 receiveAsFlow() 与 SharedFlow 背压差异:导航类 Effect 用 Channel 更不易丢。
踩坑
- 把 Navigate 写进 UiState 的 navigateTo: String? 且不在消费后清空 → 旋转屏二次导航。
- Effect 里做网络请求:副作用通道只通知 UI,数据仍回 Result Intent 进 State。
- 多个 collect 争用同一 Channel:每屏一个收集协程,在 repeatOnLifecycle 内启动。
动手练
- B(起点):下面用 SingleEvent 字段,旋转后 Toast 重复。改为 Channel Effect:
data class UiState(val items: List<Item>, val toast: String? = null)
// Fragment collect 内
if (state.toast != null) {
Toast.makeText(context, state.toast, LENGTH_SHORT).show()
viewModel.clearToast() // 反模式:UI 驱动清状态
}
- A:列出 3 个应进 Effect 与 3 个应进 State 的字段例子。
第 6 章 何时选 MVI:收益、成本与团队落地
适合 MVI 的信号
| 单屏 ≥4 种互斥/组合 UI 态 | sealed UiState + reduce 防非法组合 |
| 复杂用户路径需回放/埋点 | Intent 序列即审计日志 |
| 多人协作同一 Feature | 单一入口降低 merge 冲突 |
| Compose 主 UI | state + onIntent 与 Composable 参数化一致 |
| 需细粒度单测覆盖状态机 | 纯 reduce 不启协程即可测 |
不必强上 MVI 的场景
| 静态设置页、关于页 | 无状态或单次加载 MVVM |
| 纯 WebView 容器 | 无本地状态机 |
| 原型 / 一次性 Demo | 直接 ViewModel 函数 |
| 团队未统一、库混用 | 先统一 StateFlow + 单 UiState,再引 Intent |
与 MVVM 共存策略
同一 App 可 Feature 级选型:订单列表 MVI,关于页 MVVM。约束是:同一 Feature 内不要混用「多入口改状态」与「纯 Intent」。
// 团队基类:可选,非必须
abstract class MviViewModel<S, I, E> : ViewModel() {
protected abstract fun reduce(state: S, intent: I): S
abstract fun onIntent(intent: I)
// state / effects 模板
}
引入 Orbit (container) 或 Circuit 当 reduce 与副作用样板超过 ~150 行;先手写,再抽象。
测试策略
@Test
fun `Refresh success replaces loading with content`() = runTest {
val vm = OrderListViewModel(FakeRepo(orders = listOf(order)))
vm.onIntent(OrderListIntent.Refresh)
advanceUntilIdle()
assertIs<OrderListUiState.Content>(vm.state.value)
assertEquals(1, (vm.state.value as OrderListUiState.Content).items.size)
}
@Test
fun `reduce refresh sets refreshing on content`() {
val old = OrderListUiState.Content(items = listOf(order), filter = null)
val new = vmReduce(old, OrderListIntent.Refresh)
assertTrue((new as OrderListUiState.Content).isRefreshing)
}
第一层测 reduce(无协程);第二层测 ViewModel + FakeRepository(runTest + advanceUntilIdle)。
常见错误汇总
- God Intent:一个 sealed 里塞 40+ 子类,应按 Feature 拆分。
- God State:所有全局用户信息塞进每个 UiState;共享用 UserSessionRepository 的独立 Flow。
- 跳过 Repository:ViewModel 直接 Retrofit Service——破坏分层,与模式无关的坏味道。
- MVI 仅 Compose:View 系统同样适用;Fragment 用 repeatOnLifecycle 收集即可。
动手练
- D:评估你负责的 3 个页面,填表:屏名 / 状态复杂度 / 推荐 MVVM 或 MVI / 理由(验收:Team Lead 可据此 Review)。
- A:写出你们团队可落地的「MVI 四条规范」(Intent 命名、State 不可变、Effect 清单、测试要求)。
面试速查 · 追问链
追问链 #1:MVVM 和 MVI 本质区别?怎么选? 🔥
标准回答(≤200 字):MVVM 强调 View 观察 ViewModel 状态,入口可多函数;MVI 强制 UDF——Intent 上行、reduce 产出不可变 State 下行,可选 Effect 通道处理一次性副作用。选型看状态复杂度:简单 CRUD 用 MVVM+单 UiState 即可;多态组合、需回放/审计时用 MVI。团队统一比名字重要。
追问 1:Compose 下为什么 MVI 更自然? 答:Composable 是 (state, onEvent) -> Unit,与 state + onIntent 同构;避免在 UI 树里散落 viewModel.foo()。
追问 2:MVI 一定比 MVVM 好吗? 答:否。Intent/reduce 样板在简单屏是负担;先统一 StateFlow 单数据源,再按需引入 Intent。
追问链 #2:reduce 里能不能调 Repository? 🔥
标准回答(≤200 字):不能。同步 reduce(old, intent) 应是纯函数,只做状态演算。异步工作在 viewModelScope.launch:调 Repository 后把结果包装为 Internal Result Intent,再 update { reduce(it, resultIntent) }。这样 reduce 可单测,副作用边界清晰。
追问 1:Room 的 Flow 怎么进 MVI? 答:collect 到 CacheUpdated Internal Intent,再 reduce 合并进 UiState;用户 Refresh 仍走 Intent 触发 sync()。
追问 2:多个异步结果谁先谁后? 答:用 Intent 携带 requestId/版本号,reduce 忽略过期结果;或 flatMapLatest 取消旧请求。
追问链 #3:一次性导航事件为什么不要放 State? ⭐
标准回答(≤200 字):State 是屏幕快照,订阅者(含旋转后新 View)会再次收到相同值。navigateTo 留在 State 会重复导航。应走 Channel/SharedFlow Effect,UI collect 后执行 navController.navigate,事件不持久化。
追问 1:SharedFlow 和 Channel 选哪个? 答:导航/Toast 用 Channel.BUFFERED 不易丢;多订阅者广播用 SharedFlow。
追问 2:错误提示放 State 还是 Effect? 答:屏上常驻错误区→State;仅 Snackbar 一闪→Effect 或 State 的 Error 分支内由 UI 决定展示方式,但勿 toast 字段不清。
追问链 #4:MVI 如何单测? ⭐
标准回答(≤200 字):两层:① 纯 reduce 表驱动测试,无协程;② ViewModel + FakeRepository,runTest + advanceUntilIdle 断言 state.value。Intent 序列 onIntent(A); onIntent(B) 模拟用户路径。Effect 用 turbine 或收集列表断言。
追问 1:要不要测 collect? 答:UI 层用 Compose createComposeRule 测渲染;ViewModel 不测 collect,测 state/effects 输出。
追问 2:与 Orbit/Mavericks 测试差异? 答:库提供 test { expectState } 语法糖;手写 MVI 用 StateFlow.value 同样可测,成本是样板代码。
追问链 #5:多模块项目 MVI 放哪一层? 💡
标准回答(≤200 字):Intent/UiState/Effect 定义在 feature presentation 模块;ViewModel 依赖 domain/data 接口。:core:ui 可提供 MviViewModel 基类或 Compose 工具。feature 间不共享 Intent,通过导航参数或共享 Repository 通信。
追问 1:UseCase 在 MVI 哪一环? 答:ViewModel 的 launch 块调用 UseCase,结果映射为 Internal Intent;UseCase 不知 UI State。
追问 2:全局主题/用户会话怎么不进每个 UiState? 答:独立 UserSessionRepository Flow,Compose CompositionLocal 或顶层 Scaffold 收集;避免 God State。
完整链路一句通
用户操作 → onIntent → 同步 reduce 更新 StateFlow → UI collect 渲染;异步经 Repository 回 Result Intent 再 reduce;导航/Toast 走 Effect 通道;简单屏 MVVM+单 UiState 即可,复杂状态机再 完整 MVI。
https://i-blog.csdnimg.cn/direct/8b47a7c327ca4b19809e7c29d67e671a.png
相关推荐
Kotlin 语法与空安全:Android 开发第一课
Kotlin 作用域函数:let/apply 工程选型





