[Android 从零到一] RecyclerView 多类型列表实战:稳定刷新、状态恢复与性能治理
# RecyclerView 多类型列表实战:稳定刷新、状态恢复与性能治理 业务列表很少只有一种卡片:顶部横幅、运营入口、内容卡片、分组标题、加载提示常常混在同一页面。功能继续叠加后,直接在一个 Adapter 里堆 `viewType`、强制刷新和位置判断,很快会出现闪烁、错位、状态串行和难以维护的问题。本文从一个资讯首页出发,逐步搭建可扩展的多类型列表,并说明局部刷新、稳定 ID、状态恢复和性能分析的关键边界。 ## 多类型列表真正难在哪里 让 `getItemViewType()` 返回不同整数并不难,难的是数据变化后仍然保持正确: - 不同卡片拥有不同布局、事件和更新频率; - 某类卡片插入或删除后,后续位置全部变化; - 局部字段更新不应触发整张卡片重新绑定; - 横向列表、输入框或展开状态不能因为复用而串到别的条目; - 页面重建时,列表既要快速恢复,又不能展示过期交互状态。 因此,设计重点不应只是“如何区分布局”,而应是建立稳定的数据身份、明确的绑定协议和可观测的更新链路。 ## 用密封类型描述页面模型 不要让 Adapter 直接拼接接口 DTO。页面模型应该只表达渲染所需信息,并为每个条目提供稳定身份。 ``` sealed interface HomeItem { val stableId: Long data class Banner( override val stableId: Long, val images: List, val selectedIndex: Int ) : HomeItem data class SectionTitle( override val stableId: Long, val title: String, val actionText: String? ) : HomeItem data class ArticleCard( override val stableId: Long, val articleId: Long, val title: String, val summary: String, val liked: Boolean, val likeCount: Int ) : HomeItem data class EmptyHint( override val stableId: Long, val message: String ) : HomeItem } ``` 密封类型有两个好处:`when` 可以得到穷尽检查;业务层也能在提交列表前完成 DTO 到 UI 模型的转换,避免 ViewHolder 承担数据清洗逻辑。 `stableId` 不能使用 Adapter 位置,因为插入条目后位置会变化。业务实体优先使用服务端主键;没有业务主键的固定模块,可以使用不会变化的常量或由模块类型生成的确定值。 ## 先实现可靠的 DiffUtil `ListAdapter` 能在后台计算差异,但前提是比较规则正确。 ``` object HomeItemDiff : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: HomeItem, newItem: HomeItem ): Boolean = oldItem::class == newItem::class && oldItem.stableId == newItem.stableId override fun areContentsTheSame( oldItem: HomeItem, newItem: HomeItem ): Boolean = oldItem == newItem } ``` `areItemsTheSame()` 判断是不是同一个业务条目,`areContentsTheSame()` 判断内容是否变化。不要只比较位置,也不要让所有同类型模块共用一个 ID,否则 DiffUtil 会把不同条目误判为同一条。 提交给 `ListAdapter` 的列表和元素应尽量不可变。下面的写法有隐患: ``` val current = adapter.currentList (current.first() as MutableArticle).liked = true adapter.submitList(current) ``` 对象被原地修改后,新旧快照可能指向同一份内容,DiffUtil 无法发现变化。更可靠的方式是复制元素并创建新列表: ``` val updated = oldItems.map { item -> if (item is HomeItem.ArticleCard && item.articleId == targetId) { item.copy(liked = true, likeCount = item.likeCount + 1) } else { item } } adapter.submitList(updated) ``` ## 建立清晰的 ViewHolder 分发 规模较小时,可以由一个 Adapter 管理创建和绑定,但要避免散落的魔法数字。 ``` private enum class HomeViewType { BANNER, SECTION_TITLE, ARTICLE, EMPTY } class HomeAdapter( private val listener: HomeActionListener ) : ListAdapter(HomeItemDiff) { init { setHasStableIds(true) } override fun getItemId(position: Int): Long = getItem(position).stableId override fun getItemViewType(position: Int): Int = when (getItem(position)) { is HomeItem.Banner -> HomeViewType.BANNER.ordinal is HomeItem.SectionTitle -> HomeViewType.SECTION_TITLE.ordinal is HomeItem.ArticleCard -> HomeViewType.ARTICLE.ordinal is HomeItem.EmptyHint -> HomeViewType.EMPTY.ordinal } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (HomeViewType.entries[viewType]) { HomeViewType.BANNER -> BannerHolder.create(parent, listener) HomeViewType.SECTION_TITLE -> SectionTitleHolder.create(parent, listener) HomeViewType.ARTICLE -> ArticleHolder.create(parent, listener) HomeViewType.EMPTY -> EmptyHolder.create(parent) } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { is BannerHolder -> holder.bind(getItem(position) as HomeItem.Banner) is SectionTitleHolder -> holder.bind(getItem(position) as HomeItem.SectionTitle) is ArticleHolder -> holder.bind(getItem(position) as HomeItem.ArticleCard) is EmptyHolder -> holder.bind(getItem(position) as HomeItem.EmptyHint) } } } ``` 稳定 ID 不是性能开关的万能解药。只有在 ID 真正唯一且生命周期稳定时才应启用;重复 ID 会让动画和复用行为变得不可预测。 当卡片类型由多个团队维护,或者类型持续增加时,可进一步拆成 Delegate:每个 Delegate 负责类型判断、ViewHolder 创建和绑定,主 Adapter 只负责路由。这样能够减少巨型 `when`,也便于独立测试。 ## 用 payload 避免无意义的完整绑定 用户点赞时,标题、摘要和图片都没有变化。若整张卡片重新绑定,图片组件可能重复加载,复杂布局也会再次测量。可以让 DiffUtil 返回变化字段。 ``` data class ArticlePayload( val liked: Boolean? = null, val likeCount: Int? = null ) override fun getChangePayload(oldItem: HomeItem, newItem: HomeItem): Any? { if (oldItem !is HomeItem.ArticleCard || newItem !is HomeItem.ArticleCard) { return null } val liked = newItem.liked.takeIf { it != oldItem.liked } val count = newItem.likeCount.takeIf { it != oldItem.likeCount } return if (liked != null || count != null) { ArticlePayload(liked = liked, likeCount = count) } else { null } } ``` Adapter 覆写带 payload 的绑定方法: ``` override fun onBindViewHolder( holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList ) { if (holder is ArticleHolder) { val changes = payloads.filterIsInstance() if (changes.isNotEmpty()) { changes.forEach(holder::bindPayload) return } } super.onBindViewHolder(holder, position, payloads) } ``` ViewHolder 只更新对应控件: ``` fun bindPayload(payload: ArticlePayload) { payload.liked?.let { liked -> binding.likeButton.isSelected = liked } payload.likeCount?.let { count -> binding.likeCount.text = count.toString() } } ``` payload 只是优化路径,完整绑定仍必须能独立得到正确 UI。条目刚进入屏幕、ViewHolder 被回收复用,或者 RecyclerView 合并更新时,都可能走完整绑定。 ## 处理复用导致的状态串行 常见错误是在 ViewHolder 内保存与业务条目绑定的临时状态,却没有在 `bind()` 时重置。例如展开按钮、复选框监听器、动画进度和异步图片请求。 ``` fun bind(item: HomeItem.ArticleCard) = with(binding) { likeButton.setOnClickListener(null) title.text = item.title summary.text = item.summary likeButton.isSelected = item.liked likeCount.text = item.likeCount.toString() likeButton.setOnClickListener { listener.onLikeClick(item.articleId) } } ``` 绑定前暂时移除监听器,可以避免设置选中状态时误触回调。所有可见属性都应在绑定中明确赋值,不能依赖 XML 默认值或上一个条目留下的状态。 异步任务还应在回收时清理: ``` override fun onViewRecycled(holder: RecyclerView.ViewHolder) { when (holder) { is BannerHolder -> holder.stopAutoScroll() is ArticleHolder -> holder.clearImageRequest() } super.onViewRecycled(holder) } ``` 真正属于业务的数据,例如点赞、收藏和展开选择,应该进入 UI 模型或 ViewModel;仅与视图生命周期相关的任务,才由 ViewHolder 管理。 ## 嵌套 RecyclerView 的状态恢复 首页经常包含横向商品栏。纵向滚动后再回来,横向列表可能跳回起点。简单地关闭回收池并不能解决问题,反而会增加创建成本。 可以按父条目的稳定 ID 保存 LayoutManager 状态: ``` class HorizontalStateStore { private val states = mutableMapOf() fun save(id: Long, recyclerView: RecyclerView) { states[id] = recyclerView.layoutManager?.onSaveInstanceState() } fun restore(id: Long, recyclerView: RecyclerView) { recyclerView.layoutManager?.onRestoreInstanceState(states[id]) } } ``` 在父 ViewHolder 即将绑定新条目前保存旧 ID 的状态,绑定完成后恢复新 ID 的状态。多个横向列表布局相同,还可以共享 `RecycledViewPool`: ``` private val sharedPool = RecyclerView.RecycledViewPool() fun configureNestedList(recyclerView: RecyclerView) { recyclerView.setRecycledViewPool(sharedPool) recyclerView.setHasFixedSize(true) } ``` 共享回收池减少同类型子 ViewHolder 的重复创建,但不能跨完全不同的布局协议盲目共享。 ## 等待有效数据再恢复滚动位置 页面重建时,RecyclerView 可能在数据尚未加载前尝试恢复位置,随后因为空列表而丢失状态。对于 `ListAdapter`,可以设置恢复策略: ``` adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY ``` 这会等列表非空后再恢复。若列表允许长期为空,应在业务状态明确为“加载完成且确实为空”时评估是否切换策略,避免恢复过程一直被阻止。 不要每次收到新列表都调用 `scrollToPosition(0)`。刷新和用户主动返回顶部是不同意图,滚动行为应该由明确事件触发,而不是和 `submitList()` 绑定。 ## ConcatAdapter 还是单个多类型 Adapter `ConcatAdapter` 适合把相对独立的区块拼接起来,例如头部运营位、主体分页列表和页尾提示。各子 Adapter 可以独立更新,职责清晰。 ``` val concatAdapter = ConcatAdapter( ConcatAdapter.Config.Builder() .setIsolateViewTypes(true) .setStableIdMode(ConcatAdapter.Config.StableIdMode.ISOLATED_STABLE_IDS) .build(), headerAdapter, articleAdapter, footerAdapter ) ``` 若条目需要跨区块统一排序、拖拽或执行一套 Diff,单个多类型 Adapter 更直接。不要为了“架构漂亮”把每张卡片都拆成一个 Adapter;层级增多也会带来位置换算和事件协调成本。 事件回调中尽量传业务 ID,不传 `position`。异步回调发生时,原位置可能已因 Diff 更新而失效;即使必须查询位置,也应使用 `bindingAdapterPosition` 并判断不等于 `RecyclerView.NO_POSITION`。 ## 关闭动画不是修复闪烁的首选方案 很多项目遇到点赞闪烁,马上关闭 `itemAnimator`: ``` recyclerView.itemAnimator = null ``` 这会掩盖症状,也会失去正常的插入、删除动画。更合理的排查顺序是: - 确认条目 ID 是否稳定且唯一; - 确认没有原地修改旧列表; - 确认 DiffUtil 内容比较包含变化字段; - 为小范围变化提供 payload; - 检查图片加载是否在完整绑定时重复执行; - 最后再根据产品体验调整 change animation。 若只想避免内容变化时的交叉淡入淡出,可以针对默认动画器配置: ``` (recyclerView.itemAnimator as? SimpleItemAnimator) ?.supportsChangeAnimations = false ``` 这比关闭全部动画影响更小,但仍应先保证更新协议正确。 ## 用工具找到真正的卡顿来源 “列表卡”可能发生在数据转换、Diff 计算、布局测量、图片解码或主线程 I/O,不应只凭感觉修改参数。 推荐从这些证据入手: - 用 Layout Inspector 检查卡片层级和过度嵌套; - 用 System Trace 查看主线程上的长任务、布局和绘制区间; - 用 `FrameMetricsAggregator` 或 JankStats 统计慢帧; - 在图片加载链路检查原图尺寸、缩略图和缓存命中; - 给 DTO 转换、列表合并和 `submitList()` 前后的流程增加 trace 区间; - 在可复现场景下使用 Macrobenchmark,而不是只看一次手动滑动。 RecyclerView 本身通常不是唯一瓶颈。比如绑定代码只耗时很少,但图片解码在主线程执行,增加回收池容量并不会改善慢帧。 ## 一套可落地的更新链路 推荐把页面更新保持为单向数据流: ``` class HomeViewModel( private val repository: HomeRepository ) : ViewModel() { private val likedIds = MutableStateFlow>(emptySet()) val items: StateFlow> = combine( repository.observeHomeData(), likedIds ) { data, likes -> buildList { add(data.banner.toUiItem()) add(HomeItem.SectionTitle(1001L, "推荐内容", "更多")) addAll(data.articles.map { article -> article.toUiItem(liked = article.id in likes) }) } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyList() ) fun toggleLike(articleId: Long) { likedIds.update { current -> if (articleId in current) current - articleId else current + articleId } } } ``` 页面只负责收集并提交新快照: ``` viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.items.collectLatest(adapter::submitList) } } ``` 这样,点击事件回到 ViewModel,状态变化生成新列表,DiffUtil 决定最小更新范围。Adapter 不再私自维护另一份业务状态,旋转屏幕和数据刷新也更容易保持一致。 ## 常见问题清单 上线前可以逐项检查: - 稳定 ID 是否唯一,并且不会随位置或文案变化; - 列表及元素是否采用不可变快照; - 完整绑定是否覆盖所有可见属性; - payload 是否只作为优化,而不是正确性的唯一依赖; - 回调是否传业务 ID,而不是缓存旧位置; - ViewHolder 回收时是否停止动画、计时器和异步任务; - 嵌套列表是否按父条目保存滚动状态; - 状态恢复是否等待有效数据; - Diff、图片、布局和数据转换是否分别做过性能观测; - 关闭动画前是否排除了身份和绑定协议错误。 ## 总结 可维护的多类型列表,不是把更多布局塞进 Adapter,而是让每个条目拥有稳定身份,让每次数据变化形成不可变快照,让完整绑定与局部绑定都遵守清晰协议。配合正确的状态恢复、嵌套列表回收策略和基于证据的性能分析,RecyclerView 即使承载复杂首页,也能保持更新准确、滚动稳定,并为后续新增卡片留出足够空间。