RecyclerView 多类型列表实战:稳定刷新、状态恢复与性能治理
业务列表很少只有一种卡片:顶部横幅、运营入口、内容卡片、分组标题、加载提示常常混在同一页面。功能继续叠加后,直接在一个 Adapter 里堆 viewType、强制刷新和位置判断,很快会出现闪烁、错位、状态串行和难以维护的问题。本文从一个资讯首页出发,逐步搭建可扩展的多类型列表,并说明局部刷新、稳定 ID、状态恢复和性能分析的关键边界。
多类型列表真正难在哪里
让 getItemViewType() 返回不同整数并不难,难的是数据变化后仍然保持正确:
- 不同卡片拥有不同布局、事件和更新频率;
- 某类卡片插入或删除后,后续位置全部变化;
- 局部字段更新不应触发整张卡片重新绑定;
- 横向列表、输入框或展开状态不能因为复用而串到别的条目;
- 页面重建时,列表既要快速恢复,又不能展示过期交互状态。
因此,设计重点不应只是“如何区分布局”,而应是建立稳定的数据身份、明确的绑定协议和可观测的更新链路。
用密封类型描述页面模型
不要让 Adapter 直接拼接接口 DTO。页面模型应该只表达渲染所需信息,并为每个条目提供稳定身份。
sealed interface HomeItem {
val stableId: Long
data class Banner(
override val stableId: Long,
val images: List<String>,
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<HomeItem>() {
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<HomeItem, RecyclerView.ViewHolder>(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<Any>
) {
if (holder is ArticleHolder) {
val changes = payloads.filterIsInstance<ArticlePayload>()
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<Long, Parcelable?>()
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<Set<Long>>(emptySet())
val items: StateFlow<List<HomeItem>> = 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 即使承载复杂首页,也能保持更新准确、滚动稳定,并为后续新增卡片留出足够空间。