没踩过几十个坑不敢说懂MVI。这个系列前面两篇聊了MVI的理念和为什么值得迁移这篇直接拿真实业务开刀——我的收藏页面。这个页面看起来简单无非就是一个列表加取消收藏但越简单的页面越能暴露架构的成色。列表数据的分页加载、收藏状态的即时切换、取消收藏后的列表联动还有快速点击下的状态一致性每一个点都能让你从会用MVI进阶到用明白MVI。我刻意选收藏页而不是购物车、订单这类超复杂页面是因为收藏页的边界足够清晰适合做MVI的完整落地样板。但它的业务链路又不算短远端列表拉取、本地状态同步、分页加载、单项操作取消收藏对全局数据的影响。这套组合拳打下来你对MVI里Intent、State、Reducer、SideEffect这四个概念的理解会比看十篇理论文章都扎实。这篇的代码我会贴关键的但更多篇幅留给为什么这么做和我踩过的坑。毕竟架构没有标准答案只有适不适合你的业务。1. 先拆需求收藏页面的核心链路与状态边界1.1 这个页面的业务链路到底有多长收藏页面产品口中经常叫我的收藏或者收藏夹。用户进来的路径一般是这样从首页或者我的Tab点进来看到自己收藏过的商品列表可以上下滑动浏览可以左滑或者点击按钮取消收藏。听起来很简单但把链路打开看实际上包含下面这几个关键环节首次进入需要从服务端拉取收藏列表的第一页数据并展示加载状态列表滑到底部需要触发分页加载逻辑拉取下一页数据并追加到列表尾部点击某个商品的取消收藏按钮需要调用取消收藏的接口同时更新列表数据取消收藏成功后列表里要移除这个商品如果此时列表正好是空的还要展示空态。 更隐蔽的需求是如果App有多个入口都能操作收藏状态比如详情页也能取消收藏那么回到收藏页时列表数据必须是一致的不能出现详情页取消了收藏页还挂着这种状态割裂。所以这个页面并不是拉一个接口渲染一个RecyclerView那么简单。它是一个有依赖关系、有数据联动、有异步操作、有瞬时响应的复合业务场景。这种场景如果用传统的MVP或者简单的MVVM来做诚实地讲也能做但状态一旦多起来ViewModel里的MutableLiveData数量就会失控状态同步就得靠开发者的纪律性。而MVI的核心价值就是把这种纪律性变成架构上的强制约束。1.2 MVI在收藏页中的角色分配我习惯把MVI拆成四个角色放到收藏页这个场景里就是Intent意图用户在这个页面做的每一个动作或者系统触发的每一个事件比如页面加载加载更多点击取消收藏取消收藏成功取消收藏失败全部是一次IntentState状态整个页面的UI快照比如正在加载已加载第一页正在加载更多取消收藏中加载失败空态——所有UI都从State派生不直接持有临时标记Reducer归约器一个纯粹的函数输入旧State和Intent输出新State这里面没有任何副作用不调接口、不写数据库只做数据转换SideEffect副作用需要出去的动作比如调用取消收藏接口、弹出Toast、发起网络请求这些不能在State里直接做要通过副作用通道单独处理。这个角色分配的核心思想是单向数据流Unidirectional Data FlowUDF。数据只会沿着UI → Intent → Reducer → State → UI这个方向流转不会出现UI和ViewModel互相持有引用、你改我的状态我改你的状态这种双向纠缠。收藏页这种状态多的页面最怕的就是状态改成了打电话确认模式——改一下状态要确认半天是谁改的。MVI让状态的每一次变更都有迹可循每一个State都对应一个Intent排查问题的时候就变成了一条清晰的链路。2. 搭建骨架Intent、State、Reducer的具体设计2.1 Intent如何定义才能覆盖所有用户操作Intent定义是MVI实践的第一步也是决定后面代码是否好写的一步。在收藏页场景里我建议把Intent定义成sealed class用Kotlin的密封类把所有意图收敛在一个类型层级里。sealed class FavoriteIntent { data object InitialLoad : FavoriteIntent() data object LoadMore : FavoriteIntent() data class ToggleFavorite(val item: FavoriteItem) : FavoriteIntent() data class RemoveFavorite(val item: FavoriteItem, val position: Int) : FavoriteIntent() data class OnFavoriteRemoved(val item: FavoriteItem) : FavoriteIntent() data class OnFavoriteFailed(val item: FavoriteItem, val error: String) : FavoriteIntent() data class OnLoadMoreSuccess(val items: ListFavoriteItem, val hasMore: Boolean) : FavoriteIntent() data class OnLoadMoreFailed(val error: String) : FavoriteIntent() }这里有个细节值得展开ToggleFavorite和RemoveFavorite的区别。在产品设计上收藏页通常只有取消收藏这一个操作但用户点击取消收藏按钮后服务端的响应有成功和失败两种结果而这两种结果对应的UI变化是完全不同的。所以我倾向于把用户点击和用户点击后的结果拆成不同的Intent。为什么这么拆因为Reducer是纯函数它不能在收到RemoveFavorite时自己去调接口再等结果。所以RemoveFavorite进来Redcuer只负责把UI状态从普通变成取消收藏中比如按钮显示loading旋转动画同时通过副作用发出一次网络请求。等网络请求的结果回来再以OnFavoriteRemoved或OnFavoriteFailed的形式重新派发IntentRedcuer再根据结果更新State。这个拆分让Reducer的任务变得很纯粹只管状态怎么变不管状态为什么变。2.2 State建模要覆盖哪些UI分支State是整个页面的数据快照。我见过很多初学者把State定义成一大堆字段堆在data class里比如isLoading、isLoadingMore、isRemoving、errorMessage这些都是Boolean和String。这样定义在逻辑上没错但用起来很容易出现非法组合比如isLoading和isLoadingMore同时为true这在UI上根本无法展示。更推荐的做法是用一个密封类或者枚举级的字段来定义页面的顶层状态把互斥的状态收敛到同一个维度sealed class FavoriteListState { data object Loading : FavoriteListState() data class Success( val items: ListFavoriteItem, val hasMore: Boolean, val loadingMore: Boolean, val removingIds: SetString ) : FavoriteListState() data class Error(val error: String) : FavoriteListState() }然后整个页面State就是一个不可变的data classdata class FavoriteUiState( val listState: FavoriteListState, val lastUpdatedAt: Long 0L )这里removingIds是我特别想强调的一个设计。如果用户快速点击了商品A和商品B的取消收藏按钮在A的请求还没返回时B的请求已经发出去了此时如果只是用一个removingItem字段保存正在取消收藏的那一项那么B开始移除时A的loading状态就丢失了。用SetString来收集所有正在移除的itemId列表的每个item就能根据removingIds.contains(item.id)来判断自己是否要显示loading动画。这个小设计会让你在快速连续操作的场景下少掉很多头发。另外lastUpdatedAt这个字段看着多余实际上在本地数据和远端数据的同步校验中非常有用。比如页面在后台挂了一段时间回到前台时可以对比这个时间戳和服务端返回的更新时间决定是否需要刷新数据。属于那种平时用不上一用就救命的字段。2.3 Reducer的纯函数写法与核心逻辑Reducer是MVI里最无聊但又最不能出错的部分。它的职责只有一个(旧State, Intent) → 新State。我把它拆成几个函数来写每个函数处理一种Intent类型避免一个reduce方法几百行。object FavoriteReducer { fun reduce(oldState: FavoriteUiState, intent: FavoriteIntent): FavoriteUiState { return when (intent) { is FavoriteIntent.InitialLoad - handleInitialLoad(oldState) is FavoriteIntent.LoadMore - handleLoadMore(oldState) is FavoriteIntent.RemoveFavorite - handleRemove(oldState, intent) is FavoriteIntent.OnFavoriteRemoved - handleRemoveSuccess(oldState, intent) is FavoriteIntent.OnFavoriteFailed - handleRemoveFailed(oldState, intent) is FavoriteIntent.OnLoadMoreSuccess - handleLoadMoreSuccess(oldState, intent) is FavoriteIntent.OnLoadMoreFailed - handleLoadMoreFailed(oldState, intent) is FavoriteIntent.ToggleFavorite - handleToggle(oldState, intent) } } }以handleRemove为例它的逻辑非常简单private fun handleRemove(oldState: FavoriteUiState, intent: FavoriteIntent.RemoveFavorite): FavoriteUiState { val current oldState.listState as? FavoriteListState.Success ?: return oldState return oldState.copy( listState current.copy( removingIds current.removingIds intent.item.id ) ) }这函数没有网络调用没有Toast没有数据库写入纯粹地把id加进removingIds集合。这样写的好处是可测试性极强——你不需要Mock任何依赖直接给这个函数传一个State和一个Intent就能断言它返回的State是否包含预期变化。我在项目里给Reducer写了接近50个单元测试每个测试用例就是给定某种State发生某个Intent断言新State跑一遍只要几秒。这里有一个陷阱想提醒一下很多人写着写着就把Reducer写脏了。比如在Reducer里判断如果当前State是Error状态就跳到登录页这个动作看起来像是状态转换但跳到登录页是一个导航副作用不应该出现在Reducer里。正确做法是发出一个NavigationEffect让UI层去响应导航动作。判断标准很简单Reducer的返回值只能被赋值给State不能触发任何UI层的动作。3. 数据层与副作用真正容易出问题的环节3.1 Repository接口的设计思路在MVI架构中数据层依然使用Repository模式来屏蔽数据来源。收藏页的数据来源一般有两个远端服务端和本地数据库。在这个页面我建议走远端优先本地缓存辅助的策略。Repository接口设计成面向场景而不是面向接口实现interface FavoriteRepository { suspend fun fetchFavorites(page: Int, pageSize: Int): PagedResultFavoriteItem suspend fun cancelFavorite(itemId: String): ResultUnit }PagedResult是一个简单的数据类包含items和hasMore两个字段。这里有一个很多项目都会犯的错把分页的判断逻辑交给UI层去数列表长度。比如UI层看到返回的数据不足pageSize就自己推断到头了。这种方法在数据稳定的情况下勉强能用但只要发生过一次刚好一页返回满、下一页返回空的情况你就会明白为什么分页的状态必须由服务端显式返回。我这里的实现是服务端返回结构里带一个hasMore布尔值如果服务端暂时不支持那就在Repository内部做兜底逻辑items.size pageSize时设置hasMore false。这个兜底逻辑放在Repository而不放在页面是为了保证页面层不感知数据来源细节。Repository还有一个容易被忽视的职责——数据一致性。如果取消收藏是本地先删、异步同步到服务端的模式那Repository要在内存中维护一份最新的收藏id集合其他页面查询收藏状态时都通过Repository查询而不是直连数据库。这样就能避免多个页面的收藏状态不一致。但注意这会增加Repository的复杂度如果你的应用只有一个收藏页会操作收藏状态那就没必要做内存缓存本地删除后直接调接口即可。3.2 副作用通道Toast、导航、重试如何优雅处理在MVI里Reducer不能做弹Toast这种动作。但真实的业务又必须要弹。所以需要一个SideEffect通道来承载所有一次性事件。我的做法是用一个Channel或者SharedFlow来发送单次事件sealed class FavoriteSideEffect { data class ShowToast(val message: String) : FavoriteSideEffect() data class ShowSnackbar(val message: String, val retryAction: () - Unit) : FavoriteSideEffect() data object ResetToTop : FavoriteSideEffect() }在中转层收集Intent并调用Reducer后如果判断需要发送副作用就向副作用通道发送private val sideEffect ChannelFavoriteSideEffect(Channel.BUFFERED) val sideEffects: FlowFavoriteSideEffect sideEffect.receiveAsFlow() fun dispatch(intent: FavoriteIntent) { when (intent) { is FavoriteIntent.LoadMore - handleLoadMore() is FavoriteIntent.RemoveFavorite - handleRemoveFavorite(intent.item) else - { val newState FavoriteReducer.reduce(uiState.value, intent) _uiState.value newState } } } private fun handleRemoveFavorite(item: FavoriteItem) { val newState FavoriteReducer.reduce(uiState.value, FavoriteIntent.RemoveFavorite(item)) _uiState.value newState viewModelScope.launch { val result repository.cancelFavorite(item.id) when (result) { is Result.Success - { val updatedState FavoriteReducer.reduce( uiState.value, FavoriteIntent.OnFavoriteRemoved(item) ) _uiState.value updatedState sideEffect.send(FavoriteSideEffect.ShowToast(已取消收藏)) } is Result.Failure - { val updatedState FavoriteReducer.reduce( uiState.value, FavoriteIntent.OnFavoriteFailed(item, result.errorMsg) ) _uiState.value updatedState sideEffect.send(FavoriteSideEffect.ShowToast(result.errorMsg)) } } } }这段代码有几个细节值得细品。RemoveFavorite进来后不是直接走Reducer的通用分支而是走到handleRemoveFavorite这个挂起函数里因为这里需要调用Repository。调完接口拿到结果后又把结果包成OnFavoriteRemoved或OnFavoriteFailed塞回Reducer。这就是MVI里副作用最终要回归Intent流的典型写法。所有的异步操作结果都要转换为新的Intent这样才能保证Reducer是唯一会修改State的地方。这里有一个我在项目里踩过的坑在showToast的时候如果使用的是Channel(BUFFERED)在页面退到后台又切回来时队列里的Toast事件可能会被消费两次或丢失。解决方法是使用Channel(BUFFERED)配合receiveAsFlow并确保只在页面前台时收集SideEffect或者使用ShareIn策略让新订阅者只收到订阅之后的事件。具体取舍取决于你的业务是否允许Toast在页面恢复时重新弹一遍。3.3 分页加载与去重的完整实现分页加载是收藏页最容易出并发问题的地方。用户快速滑到底部短时间内触发了多次LoadMoreIntent如果每次都发一个网络请求就会出现同一个page被请求两次、数据重复追加的问题。我建议在转层加一个锁或者标记位来防止重复请求private var isLoadingMore false private fun handleLoadMore() { val state uiState.value val listState state.listState as? FavoriteListState.Success ?: return if (listState.loadingMore || !listState.hasMore) return if (isLoadingMore) return isLoadingMore true val newState FavoriteReducer.reduce( state, FavoriteIntent.LoadMore ) _uiState.value newState viewModelScope.launch { try { val page listState.items.size / PAGE_SIZE 1 val result repository.fetchFavorites(page, PAGE_SIZE) val updatedState FavoriteReducer.reduce( uiState.value, FavoriteIntent.OnLoadMoreSuccess(result.items, result.hasMore) ) _uiState.value updatedState } catch (e: Exception) { val updatedState FavoriteReducer.reduce( uiState.value, FavoriteIntent.OnLoadMoreFailed(e.message ?: 网络异常) ) _uiState.value updatedState } finally { isLoadingMore false } } }代码里的isLoadingMore是转层的一个标记位它存在的意义是防止同一个时刻有多个加载更多的协程在跑。在Reducer里loadingMore这个字段的职责是通知UI列表底部在转圈而isLoadingMore的职责是通知转层不要重复发请求。两者是不同层面的东西不能混用。分页加载还有一个常见的坑是返回数据去重。服务端在下拉刷新和分页加载的场景下偶尔会返回重复数据尤其是网络超时重试的情况。所以在OnLoadMoreSuccess的Reducer逻辑里我会用distinctBy去重用item.id作为唯一标识。private fun handleLoadMoreSuccess( oldState: FavoriteUiState, intent: FavoriteIntent.OnLoadMoreSuccess ): FavoriteUiState { val current oldState.listState as? FavoriteListState.Success ?: return oldState val newItems (current.items intent.items).distinctBy { it.id } return oldState.copy( listState current.copy( items newItems, loadingMore false, hasMore intent.hasMore ) ) }这行distinctBy看起来平平无奇但在弱网环境下能帮你的用户避免刷着刷着出现两个一模一样的商品的诡异体验。架构的美感往往体现在这种细节兜底上。4. 常见坑与排查从崩溃日志到用户体验4.1 快速点击取消收藏导致的状态错乱收藏页最常见的用户行为之一就是连续快速点击多个取消收藏按钮。如果你的代码只是简单地发一个请求成功后从列表移除那么快速点击时会出现竞态A的请求还没返回B的请求已经发出等A的响应先回来后列表里的item顺序发生变化B对应被移除的位置可能已经不对了。我在项目里遇到过具体场景用户连续取消两个商品页面先是把第一个商品的loading动画关掉然后第二个商品的位置错位紧接着列表闪了一下出现了幽灵item。这个问题的根源在于用position去定位要移除的item。position在列表数据变化后就不再可靠。解决办法有两个维度第一在ViewModel层面用removingIds: SetString来记录正在移除的itemId而不是记录position或者单个item引用。每个列表item通过id判断自己要不要显示loading动画而不是依靠我是不是被点击的那个。这个方案我在2.2里已经提到了它带来的最大价值就是顺序无关性——无论哪个请求先返回只要拿到itemId就能准确地更新对应item的状态。第二在UI层面列表的item点击回调传递过来的应该是整个item对象或至少是itemId而不是position。在Adapter中点击事件回调时从getItem(position)取对象然后把这个对象传给ViewModel。但如果在异步回调时position已经变化拿到手的数据可能已经过期。所以更保险的做法是回调时携带itemId在转层或Reducer内部再根据itemId查找到完整的item对象。当然还有一个更偏产品层面的方案——取消收藏需要二次确认弹窗。这个方案能挡住一部分快速误触但不能完全防住所以技术上还是要做健壮性处理。4.2 协程取消与页面泄漏在转层调用Repository时我用了viewModelScope.launch这个作用域的生命周期和ViewModel一样长。当页面退出时ViewModel会调用onCleared()此时viewModelScope会自动取消所有子协程理论上不会泄漏。但在实际代码里如果Repository内部使用了自定义的协程作用域或者回调线程没有切换回主线程就可能发生泄漏。我排查过的一个案例是Repository内部的网络请求使用了GlobalScope.launch导致ViewModel都销毁了网络回调还在执行然后回调里又访问了ViewModel持有的状态直接崩了一个IllegalStateException。正确做法是Repository的挂起函数不持有任何协程作用域它只提供suspend fun挂起函数的取消完全由调用方ViewModel的viewModelScope来控制。这样协程的层级关系就非常清晰UI层 → ViewModel的viewModelScope → Repository挂起函数任何一层被取消下面所有挂起操作都会被取消。还有一个偏门的坑页面从后台切回前台时如果收藏页面的State还是正在加载中但网络请求实际已经超时了这时候UI会一直转圈。我加了超时时间用withTimeoutOrNull包裹网络请求超时后依然发出OnLoadFailed的IntentUI展示失败状态和重试按钮。虽然重试按钮会再发一次InitialLoad但至少用户不会看到一只转不停的loading。4.3 DiffUtil与列表动效的取舍收藏列表和普通列表有一点不同收藏列表的每一次变更都伴随一个明确的业务动作取消收藏这意味着列表更新时最好有动画效果。但如果直接用notifyDataSetChanged()会丢失动画。我推荐使用ListAdapter配合DiffUtil在State更新后自动计算差异并刷新列表。DiffUtil的比较逻辑要精确到业务字段class FavoriteDiffCallback : DiffUtil.ItemCallbackFavoriteItem() { override fun areItemsTheSame(oldItem: FavoriteItem, newItem: FavoriteItem): Boolean { return oldItem.id newItem.id } override fun areContentsTheSame(oldItem: FavoriteItem, newItem: FavoriteItem): Boolean { return oldItem newItem } }这里有个细节是areContentsTheSame的比较。如果你的FavoriteItem里包含了一个isFavorite字段而取消收藏成功后这个字段会变成false那么DiffUtil就能识别出这个item发生了变化局部刷新对应位置。但如果你的item是data class每次Reducer生成新State时都会创建新的List即使item内容没变只要引用了新的对象DiffUtil也会判定内容不同导致整个列表无意义地全部刷新。解决方案有两个一是在Reducer里只对需要变更的item创建新对象其他item的引用保持和旧State一致。这就是不可变性带来的性能优势——数据不变时引用不变DiffUtil对比时引用相同直接判定内容相同。二是在FavoriteItem里重写equals方法只比较业务主键和时间戳。更推荐第一种因为更符合不可变数据的规范几乎不需要额外处理。如果这些都做好了但依然发现列表刷新时偶现闪屏那大概率是item的透明度或者scale动画没有正确复用到DefaultItemAnimator的动画机制里。这时候先检查RecyclerView的item视图是否在onBindViewHolder里做了一些强制的alpha 1f设置这会把动画效果冲掉。5. 拿这套架构写收藏页的最终体感把收藏页用MVI完整落地一遍后我最明显的感觉就是——这个页面的业务逻辑变得可推理了。以前写收藏页状态一多就陷入这个接口回来到底改哪个变量的泥潭。现在每一个状态变化都有迹可循ViewModel里的代码也不超过150行大部分逻辑都在Reducer的纯函数和Repository的接口实现里。具体来说有几个方面的体验是实打实的排查问题效率翻倍线上反馈快速点击取消收藏后列表出现闪屏我直接看Reducer的removingIds逻辑和DiffUtil的areContentsTheSame定位到是item复用时动画状态没重置前后不到半小时测试容易写了以前收藏页的逻辑要写测试需要Mock网络库、Mock数据库、还要处理回调线程麻烦得很。现在Reducer就是普通函数测试直接传State和Intent进去断言新State跑一遍只要几秒新人接手不慌团队里一个刚入职一年的同学接手收藏页的需求我给他讲了MVI这条链路怎么走之后他半天就能上手改需求。因为Intent、State、Reducer的分工太明确了他只需要照着现有模式添加新的Intent类型和Reducer分支。过程中遇到的坑前面都写了这里再补一个记忆最深的MVI并不等于ViewModel Kotlin Flow更不等于所有UI状态都用一个sealed class包起来就算完了。如果只是把原来的MutableLiveData换成MutableStateFlow把Repository的调用逻辑放进ViewModel然后你告诉我这就是MVI那大概率只是把旧架构搬了个家。真正的MVI落地是Reducer必须纯函数化、所有状态变化必须有Intent来源、一次性事件必须走SideEffect通道。这套约束建立起来才有意义否则就是换汤不换药。后面我打算把收藏页这个示例接着扩展一下加一个收藏搜索和收藏分组功能。到时候你会发现不管页面复杂度怎么加MVI这套骨架的改动成本都很低——定义新的Intent加Reducer分支补State字段基本上就是这三板斧。等做到第4篇的时候我应该会做一个从0到1的完整项目示例把网络层、数据层、UI绑定整个打通比这一篇更接近生产环境。也欢迎大家在实际落地MVI的过程中遇到问题来找我讨论毕竟架构这东西永远是纸上得来终觉浅绝知此事要躬行。