写收藏页之前,我心里想的是“这还不简单”:一个列表,一个心形图标,点一下变色,再点一下恢复。真做起来才发现,收藏页是整个App里交互密度最高的页面之一——快速连点、跨页同步、加载态和空态来回切换、分页加载和取消收藏同时发生……任何一个环节没处理好,用户感知都很明显。这也是我为什么把MVI架构的第3篇实战选在收藏页,而不是那些看起来更“有技术含量”的业务。这篇就把我完整落地“我的收藏页面”的过程拆开讲,从State设计、Intent流转、Reducer收敛,到数据层联动和实测踩坑,全部过一遍。
1. 收藏页凭什么配得上MVI:三个容易被低估的复杂度
先给没看过系列前两篇的朋友简单交代一下MVI的核心思想:状态就是真相。整个页面是什么样子,完全由一份不可变的State决定;用户做了什么操作,转换成Intent;Intent经过Reducer计算产出新的State;View拿到新State重新渲染。单向数据流,环环相扣,没有例外。
这套思想用在普通列表页上,可能显得有点“杀鸡用牛刀”。但收藏页不一样,它的复杂度恰好是MVI最容易发挥价值的地方。
第一个复杂度是交互冲突。用户不会老老实实等一个收藏操作完成再点下一个。你点了收藏,列表项还没从数据库删掉,手指又滑到下一页触发分页加载;或者同一个item上连点两次“取消收藏”,第一次请求还没回来,第二次又发出去。传统写法里这种场景基本靠“flag”硬扛,flag一多页面状态就腐烂。MVI的思路是:把“正在操作”本身也放进State,由Reducer统一决定某个item能不能点、要不要置灰。
第二个复杂度是跨页面同步。收藏页不是孤立存在的——详情页也有收藏按钮,侧边栏可能也有快捷入口,甚至消息推送、WebView页面都能改变某个商品的收藏状态。如果每个页面自己维护一份收藏列表,必然会出现“详情页取消了收藏,返回收藏页列表里还躺着那条”的尴尬。MVI和响应式数据流配合,能让所有页面共用同一份“收藏数据源”,任何页面修改了收藏状态,收藏页自动跟着变。
第三个复杂度是状态分支多。收藏页至少要覆盖:首次加载、下拉刷新、分页加载、加载失败、空列表、操作中、操作失败回滚……这些状态在传统写法里会散落在多个变量里,互相之间还能组合出非法状态,比如“isLoading=true 同时 items 不为空且 errorMessage!=null”。MVI把状态收拢成一个代数结构,不允许非法组合出现。
说白了,收藏页是个“看起来简单、做起来全是细节”的页面。用MVI不是为了炫技,是为了让这些细节有章可循——所有状态变化都有迹可查,所有异常分支都能在Reducer里找到对应处理。这也是我坚持选它做实战示例的原因。
2. 先把页面长什么样固化成数据:State模型的设计过程
MVI里最关键的一步,是设计State。因为整个页面的任何风吹草动,最终都要在这份数据模型上体现。State设计得不好,后面的Intent和Reducer全都会跟着别扭。
我一开始犯过一个错误:把所有状态塞进一个data class,包括每个item的选中状态、加载动画标记、错误提示字段……结果State膨胀得没法看。后来我总结出一个原则——页面级状态和列表项状态分离。
页面级状态描述的是“整个页面现在处于什么阶段”,列表项状态描述的是“某一条收藏项自己是什么情况”。
data class FavoriteUiState( val isLoading: Boolean = false, // 首次加载 val isRefreshing: Boolean = false, // 下拉刷新 val isLoadingMore: Boolean = false, // 加载下一页 val items: List<FavoriteItem> = emptyList(), val errorMessage: String? = null, // 页面级错误 val endOfList: Boolean = false, // 没有更多数据 val lastUpdateTime: Long = 0L // 用于后续做静默刷新 )这里要注意一个细节:isLoading和isRefreshing是分开的。它们虽然都表示“正在请求数据”,但UI表现完全不同——首次加载要显示全屏Loading,下拉刷新要显示刷新动画。如果合并成一个布尔值,View层就得猜,这是个隐患。
列表项我单独定义了一个模型:
data class FavoriteItem( val id: String, val title: String, val coverUrl: String?, val isFavorite: Boolean = true, val operationPending: Boolean = false, // 当前item是否正在执行收藏/取消收藏 val lastUpdatedAt: Long = 0L )operationPending是收藏页State设计的核心亮点。用户在列表项上点了“取消收藏”,网络请求需要几百毫秒,这期间这个item要置灰、不允许再点。原来我用的是“正在操作的那条itemId存到一个变量里”,后来发现一旦涉及分页、增量更新,单靠一个id根本管不过来——多条item可能同时处于操作中,而且数据刷新后id对应的item可能已经不在列表里。把“是否正在操作”作为item本身的字段后,Reducer处理起来就是简单的map,不存在跨状态同步问题。
页面级状态和列表项状态分离还有个额外好处:列表项的局部变化不会触发整个页面的全量重建。配合RecyclerView的DiffUtil,只有真正变化的item会刷新,性能上也很舒服。这一点后面讲优化时还会再展开。
还有一个大家容易忽略的点:State必须是不可变的。所有“修改”都是通过copy()生成新对象。这里涉及一个权衡——每次列表操作都会复制整个items列表,如果收藏数据特别大(比如上万条),会有性能顾虑。我的实测结论是:几百上千条的收藏列表完全没问题,在StateFlow的conflate特性下,内存开销和重建成本都可以接受;如果真的做到万级数据,那就应该考虑改用分页模型而不是一次性全量加载。模式服务于场景,别为了“全量State”把一个列表页做成内存炸弹。
设计完State,我强烈建议做一次头脑风暴:把所有视觉状态列出来,逐一确认在State里能找到对应字段。比如“空态”并没有单独字段,它由isLoading == false && items.isEmpty() && errorMessage == null推导而来,View层自己做推导即可,没必要专门存一个isEmpty。类似的,“加载失败且已有缓存数据”也不需要用特殊字段,errorMessage != null && items.isNotEmpty()就是。少一个状态字段,就少一个产生非法组合的机会。
3. 所有变化从哪来:Intent的定义与Reducer的收敛
State准备好了,接下来就是设计“入口”——Intent。MVI里的Intent不是Android的Intent,它表示“用户意图”或“系统状态事件”,是驱动State变化的唯一入口。我习惯把Intent分成两类:UI事件Intent(用户操作)和内部事件Intent(异步结果)。
收藏页的Intent我定义成这样:
sealed interface FavoriteIntent { data object InitLoad : FavoriteIntent // 首次进入页面 data object PullRefresh : FavoriteIntent // 下拉刷新 data object LoadMore : FavoriteIntent // 加载下一页 data class ToggleFavorite(val itemId: String) : FavoriteIntent // 点击收藏/取消收藏 data class ToggleComplete(val itemId: String, val success: Boolean) : FavoriteIntent data class LoadMoreResult(val items: List<FavoriteItem>, val endOfList: Boolean) : FavoriteIntent data class Error(val message: String, val retryable: Boolean) : FavoriteIntent }为什么不直接暴露“请求成功”“请求失败”给View层?因为那会让View层越过Reducer直接修改State,打破单向数据流。异步结果也是Intent,只是它的来源是repository而不是用户交互。这样一来,所有改变State的路径都是同一套机制,可预测性大大提高。
ViewModel的职责因此变得很薄——收Intent、分发到对应处理函数、把异步结果再封装成新的Intent交给Reducer:
class FavoriteViewModel( private val repository: FavoriteRepository ) : ViewModel() { private val _uiState = MutableStateFlow(FavoriteUiState()) val uiState: StateFlow<FavoriteUiState> = _uiState.asStateFlow() fun onIntent(intent: FavoriteIntent) { when (intent) { is FavoriteIntent.InitLoad -> loadInitial() is FavoriteIntent.PullRefresh -> refresh() is FavoriteIntent.LoadMore -> loadMore() is FavoriteIntent.ToggleFavorite -> toggleFavorite(intent.itemId) else -> Unit } } private fun loadInitial() { viewModelScope.launch { val result = repository.fetchFavorites(0) val nextIntent = if (result.isSuccess) { FavoriteIntent.LoadMoreResult(result.getOrNull() ?: emptyList(), result.hasMore) } else { FavoriteIntent.Error("加载失败", retryable = true) } reduce(nextIntent) } } private fun reduce(intent: FavoriteIntent) { _uiState.update { current -> FavoriteReducer.reduce(current, intent) } } }注意这里我把Reducer抽成了独立的纯函数。这是MVI里最值得坚持的一点:纯函数意味着可测试、可追踪。不需要Mock任何依赖,传进去一个State和Intent,断言返回的State长什么样就行。
Reducer的写法关键在when分支要穷尽,且每个分支只做一件事。我拿“点击取消收藏”这条路径举例:
object FavoriteReducer { fun reduce(state: FavoriteUiState, intent: FavoriteIntent): FavoriteUiState = when (intent) { is FavoriteIntent.ToggleFavorite -> state.copy( items = state.items.map { item -> if (item.id == intent.itemId) { item.copy(operationPending = true, isFavorite = !item.isFavorite) } else item } ) is FavoriteIntent.ToggleComplete -> state.copy( items = if (intent.success) { // 成功则移除该item,并清空错误提示 state.items.filterNot { it.id == intent.itemId } } else { // 失败则恢复状态,把isFavorite翻转回去 state.items.map { item -> if (item.id == intent.itemId) { item.copy(isFavorite = !item.isFavorite, operationPending = false) } else item } }, errorMessage = if (intent.success) null else state.errorMessage ) is FavoriteIntent.LoadMoreResult -> { val merged = mergeByPosition(state.items, intent.items) state.copy( items = merged, isLoading = false, isRefreshing = false, isLoadingMore = false, endOfList = intent.endOfList, errorMessage = null ) } // 其余分支略 } }注意ToggleFavorite分支里我用了isFavorite = !item.isFavorite,这是乐观更新。用户点击后立刻翻转状态,UI马上有反馈;如果后台请求失败,ToggleComplete(success=false)再把它翻回去。这个策略后面在数据层部分详细说,State层面只需要支持“翻转”和“回滚”这两个动作。
关于Reducer还有个常见的疑问:为什么要用_uiState.update { }而不是直接_uiState.value = reduce(...)?因为stateflow的更新不是线程安全的,update内部用CAS循环保证并发场景不丢中间状态。虽然我们的Reducer是同步纯函数,但多个协程可能同时在ViewModel里发Intent,update能避免“读旧值、写旧值”的问题。
我把Reducer从ViewModel里拆出来之后,写单元测试变得异常舒服:
@Test fun `toggle favorite should mark pending`() { val state = FavoriteUiState(items = listOf(favItem(id = "1"))) val newState = FavoriteReducer.reduce(state, FavoriteIntent.ToggleFavorite("1")) assertTrue(newState.items[0].operationPending) assertFalse(newState.items[0].isFavorite) } @Test fun `toggle fail should rollback`() { val state = FavoriteUiState(items = listOf(favItem(id = "1", isFavorite = false, operationPending = true))) val newState = FavoriteReducer.reduce(state, FavoriteIntent.ToggleComplete("1", success = false)) assertTrue(newState.items[0].isFavorite) assertFalse(newState.items[0].operationPending) }这类测试成本极低、覆盖的全是容易出问题的边界场景。传统MVP写法里,同样的逻辑藏在接口回调里,根本没法这么干净地测。
4. 收藏/取消收藏的可信实现:数据层与Repository的设计
State和Reducer都在UI层,但收藏页真正的血肉在数据层。这一节讲Repository怎么和MVI配合,以及“乐观更新”到底该怎么落地。
我的项目里数据来源有两处:Room本地数据库是唯一数据源,网络接口负责和服务器同步。收藏页的列表数据直接观察数据库的Flow,网络请求的结果先写库,再由Flow驱动UI自动更新。
Repository的接口设计如下:
interface FavoriteRepository { fun observeFavorites(uid: String): Flow<List<FavoriteItem>> suspend fun refreshFavorites(uid: String): Boolean suspend fun toggleFavorite(item: FavoriteItem): Boolean suspend fun loadMoreFavorites(uid: String, page: Int): List<FavoriteItem> }observeFavorites返回的是Room的Flow,ViewModel里通过.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), initialValue)把它接入StateFlow。为什么要WhileSubscribed而不是WhileInViewMode?因为收藏页退到后台时我们希望暂停数据流、节省资源,切回来再自动恢复。5秒的timeout避免快速切页时反复重新订阅。
这里有个MVI新手容易踩的坑:ViewModel里同时存在两个数据源,一个是Room的Flow,一个是用户点击触发的临时状态(比如operationPending),怎么合并?我把它们分成两条线:
observeFavorites的数据线负责维护items的内容、顺序、增删- 用户操作线负责维护
operationPending这类临时UI状态
合并之后用items的id作为key,把临时状态“盖”到数据上。这块逻辑我放在了ViewModel的combine方法里,而不是污染Reducer——Reducer只处理“一次Intent到一次State变化”的原子操作,跨数据流的合并属于组合逻辑,放在ViewModel里更清晰。
然后是收藏操作的完整链路。用户点击取消收藏时:
- View层发出
ToggleFavorite(itemId) - ViewModel先把Reducer的结果推进State,item的
isFavorite翻转、operationPending = true - 同一协程里调用
repository.toggleFavorite(item) - 拿到结果后发
ToggleComplete(itemId, success)给Reducer
这里最关键的是第2步和第3步不能在同一个协程里同步执行,因为同步模式会导致UI反馈必须等网络请求返回,几百毫秒的延迟用户早就不耐烦了。MVI的解法是“乐观更新”:先改UI、再发请求、失败再回滚。
乐观更新和悲观更新的取舍,我整理了一张表供参考:
| 维度 | 乐观更新 | 悲观更新 |
|---|---|---|
| UI反馈 | 立即,用户体验好 | 等请求返回,延迟明显 |
| 实现复杂度 | 需处理失败回滚 | 相对简单 |
| 失败场景 | 状态回滚,若请求期间用户又操作易冲突 | 几乎无冲突 |
| 适用场景 | 收藏/点赞等可逆操作 | 支付、删除文件等不可逆操作 |
收藏操作是可逆的,所以要选乐观更新。但“失败回滚”有个隐患:用户点了取消收藏,请求还没回来,他又滑了一下屏幕,此时列表可能已经因为其他原因刷新了,item都不在了,回滚到哪?我在Reducer里处理ToggleComplete(success=false)时是按itemId去map的,如果item已经不在列表里,就丢弃这次回滚。想想也能理解:数据源刷新代表服务器与本地已同步,旧操作的结果自然以新数据为准。
仓库层的toggleFavorite内部要考虑事务性:不能出现“本地改了数据库但服务端没改成功”的中间状态。我的实现是先调接口,成功后再写Room并返回true,失败则直接返回false且不碰Room。也就是说,本地永远以服务端结果为准。代码大概长这样:
override suspend fun toggleFavorite(item: FavoriteItem): Boolean { return try { val success = api.toggleFavorite(item.id, !item.isFavorite).isSuccess if (success) { if (item.isFavorite) { dao.delete(item.id) // 原状态是已收藏 → 取消收藏 → 删除本地记录 } else { dao.insert(item.copy(isFavorite = true, lastUpdatedAt = System.currentTimeMillis())) } } success } catch (e: Exception) { // 网络异常上报 false } }这里要注意,用item原始状态判断是“添加收藏”还是“取消收藏”,而不是用翻转后的状态,容易被绕晕。我踩过一次:在Repository里取了!item.isFavorite判断,结果和UI层翻转逻辑耦合在一起,出了bug很难查。后来统一约定:Repository收到的item是操作前的原始状态,Repository内部自己决定怎么改库。
5. 跨页面联动:数据库当唯一真相,StateFlow当消息总线
收藏页不能孤立地看。在真实项目里,详情页、个人主页、消息通知都可能影响收藏状态。这一节讲的是怎么让所有页面在多端操作下保持一致。
我的方案就一句话:数据库是唯一真相,所有页面只读写数据库,不各自维护收藏列表。收藏页观察“收藏表”的Flow;详情页要做“取消收藏”时,调用同一个Repository写库。数据库一变,收藏页的Flow自动发出新数据,UI自动更新。
这套机制的关键在于Room的数据变更感知。Room的Flow基于表级别的invalidation tracker,只要收藏表有写操作,所有观察该表的Flow都会重新查询。代价是每次取消收藏都会触发收藏页全列表刷新查询——数据量大时会有毫秒级延迟,但换来的是跨页面绝对一致。
有个细节值得注意:不要用Flow的冷流直接赋值给State,要小心热流与冷流的差异。Room的Flow是冷流,每次收集都会重新查询数据库;如果直接把它stateIn到ViewModel层面,确实能转成热流,但有个问题——ViewModel销毁时stateIn会自动取消收集,再次进入页面会重新查询,Room Flow的查询结果默认不带分页偏移,自然没问题。真正会出问题的是如果你在stateIn之前对Flow做了distinctUntilChanged,Room重新查询返回了相同的数据集,不会发出重复事件,UI不会闪烁。这个组合比想象中省心。
跨页面的“写”路径也要统一。详情页和收藏页如果要改收藏状态,不能各自调用不同的接口,一定要走同一个Repository的toggleFavorite。否则就会出现“详情页调了A接口、收藏页调了B接口,两边数据不同步”的灾难。我在项目里定了规矩:所有变更收藏状态的操作,都必须经手FavoriteRepository,页面层不允许直接操作Dao。
关于“静默刷新”也有个经验。收藏页从后台切换回前台时,理想情况是要刷新一次,但直接全屏Loading体验很差。我们利用State里的lastUpdateTime实现“超时静默刷新”:时间差超过10分钟就静默拉一次列表,不打断阅读;时间差小就跳过。实现上是在ViewModel里监听生命周期,发送一个PullRefreshIntent,但Reducer里区分isRefreshing为true且items不为空时,View层不显示刷新动画,只更新数据。流畅和新鲜感都保住了。
另外,Flow会不会把“旧数据”和“新数据”同时发出来导致UI抖动?Room的Flow已经是基于新查询结果的,不会重复发旧值。但在特殊场景——比如收藏页列表顶部有一个“收藏数量”的统计角标,它是另一张表的查询结果——两个Flow合流时可能短暂出现数据不一致(角标已经变小、列表还没来得及删)。这是跨表一致性的经典问题。我的临时方案是把两个Flow放同一个Room事务的withTransaction里并行查询,这样Room会在事务提交后统一触发两个Flow,数据一致性有保证。这点代码虽然简单,解决的问题却很不简单。
6. 实测中的坑:StateFlow并发、分页加载和进程重建恢复
最后一章写给已经照着上面思路动手实现的朋友。这仨坑是我在开发模式下跑了小半个月才彻底想明白的,绕过去能省不少调试时间。
第一个坑是StateFlow的并发收集问题。StateFlow默认是conflate的——如果View层收集速度跟不上状态更新速度,旧状态会被丢弃,只保留最新值。这本身是特性,但有个副作用:用户快速连点时,中间状态可能被跳过。比如“点击取消收藏”这个Intent发出后,Reducer先把operationPending=true写进State,随后异步完成又写operationPending=false,如果这两次写发生在同一个UI主线程帧内收集,View层可能只收到第二个状态,导致item的置灰效果一闪而过甚至完全没渲染。
这不是bug,但会造成视觉上的“点了没反应”。我的解法是给操作中的item做延时保护——不只是靠State的瞬时值,而是在Adapter层记录一个本地的pendingIdSet,item进入pending状态后至少保持300毫秒置灰,不管State怎么变。这块逻辑虽然有点违背“纯State驱动UI”的强迫症,但实际体验好了很多。记住:MVI是指导原则,不是教条,必要时刻允许局部妥协。
第二个坑是分页加载和Reducer的合并逻辑。刚开始写LoadMoreResult时我直接state.items + newItems,很快就出了问题——点击“加载更多”时用户取消了某一条收藏,Repository的Flow同时在更新数据库,分页结果里混进了那条已取消的数据,列表就出现了重复。后来改成按id去重合并:
private fun mergeItems(current: List<FavoriteItem>, incoming: List<FavoriteItem>): List<FavoriteItem> { val ids = current.mapTo(mutableSetOf()) { it.id } return current + incoming.filter { it.id !in ids } }再配合Room的Flow自动发出新数据,重复项会被后续的Flow更新“纠正”掉。虽然有点双保险的意思,但实测稳定。分页的“正在加载更多”最好单独用isLoadingMore字段,不要和首次加载共用同一布尔值,否则View层无法区分是否要显示列表底部的progressBar。
第三个坑是进程重建后的状态恢复。MVI的State存储在ViewModel里,ViewModel在配置变更后会保留,但进程被杀后重建,State就没了。如果不做处理,用户回到页面时会看到白屏或重新加载。ViewModel的SavedStateHandle可以兜底——把必要的State字段存进去,重建时恢复。但注意:收藏列表这种大对象不适合直接塞进SavedStateHandle,你只需要恢复page、lastUpdateTime、endOfList这几个轻量字段,真正的列表数据交给Room在首次观察时自动恢复。这也是我坚持用数据库当唯一真相的原因之一——它天然承担了状态持久化的责任。
还有个小细节:SavedStateHandle里存的字段类型要可以作为Bundle。有些State字段是sealed class或复杂对象,要用序列化包装。我吃了这个亏后直接改成只存lastVisiblePage: Int和lastFavoriteCount: Int,恢复后再走一次正常的Observe流程,简单可靠。
第四个坑关于RecyclerView的使用技巧。Reducer每次操作都会产生一个全新的State和全新的items列表,直接adapter.submitList(newItems)如果不用DiffUtil,全列表刷新会让滚动位置丢失,而且闪烁严重。我在收藏页用了带async的DiffUtil(ListAdapter),它会在后台线程计算差异,只更新真正变化的item。实测下来,几百条列表的差异计算大概几毫秒,用户毫无感知。唯一的额外工作是要给FavoriteItem实现hashCode/equals——我把id、isFavorite、operationPending都纳入equals判断,这样收藏状态变化时diff结果才能命中正确的item。
我还养成了一个习惯:每次合并Flow到State后,在debug模式下打个日志,把State的关键字段(items.size、isLoading、operationPendingIds)打印出来。MVI的好处是状态是数据,日志永远能还原出“当时页面到底看到什么”。排查问题的时候,这条日志救了很多次命。
收藏页这个项目做完,我最大的体会是:MVI不是让你多写代码,而是让你少写“猜代码”。传统写法里很多bug来自于“某个回调晚了几毫秒把状态改坏了”,MVI把所有状态变化收敛到一条清晰的时间线上,你永远知道当前页面为什么长这样。当然,它也有代价——入门门槛、样板代码、还有那套State合并的心智负担。但如果你手头也有一个像收藏页这样“看起来简单、操作密集、跨页联动”的业务,我建议你真的试一次MVI,体验一下状态尽在掌握的感觉。如果你也在实战中遇到别的坑,欢迎一起交流,毕竟架构这东西,只有踩过坑才算真正学会。