欢迎光临
我们一直在努力

MVI 单向数据流:Intent、Model 与可预测状态

文章目录

  • 第 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 的自由度过高会成为隐患:

  • 多函数入口:onFilter()、loadMore()、retry() 各自改 _uiState,竞态难复现。
  • 状态散落:isLoading、items、error 三个 MutableStateFlow,UI 可能看到「loading=true 且 error 非空」的非法组合。
  • 调试困难:Crash 现场只有最后状态,不知道用户按了什么顺序。
  • MVI(Model-View-Intent)在 MVVM 之上加了一层契约:所有用户与系统事件统一为 Intent(或 Action),经单一 reduce 产出唯一 State;View 只读 State、只发 Intent,形成闭环。 在这里插入图片描述

    Java 对比

    维度Java MVP / 早期 MVVMKotlin MVI + UDF
    状态入口 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 工程选型

    赞(0)
    未经允许不得转载:171主机测评 » MVI 单向数据流:Intent、Model 与可预测状态
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址