首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >[Android 从零到一] RecyclerView 多类型列表实战:稳定刷新、状态恢复与性能治理

[Android 从零到一] RecyclerView 多类型列表实战:稳定刷新、状态恢复与性能治理

原创
作者头像
hunter android
发布2026-07-22 11:10:58
发布2026-07-22 11:10:58
420
举报

RecyclerView 多类型列表实战:稳定刷新、状态恢复与性能治理

业务列表很少只有一种卡片:顶部横幅、运营入口、内容卡片、分组标题、加载提示常常混在同一页面。功能继续叠加后,直接在一个 Adapter 里堆 `viewType`、强制刷新和位置判断,很快会出现闪烁、错位、状态串行和难以维护的问题。本文从一个资讯首页出发,逐步搭建可扩展的多类型列表,并说明局部刷新、稳定 ID、状态恢复和性能分析的关键边界。

多类型列表真正难在哪里

让 `getItemViewType()` 返回不同整数并不难,难的是数据变化后仍然保持正确:

  • 不同卡片拥有不同布局、事件和更新频率;
  • 某类卡片插入或删除后,后续位置全部变化;
  • 局部字段更新不应触发整张卡片重新绑定;
  • 横向列表、输入框或展开状态不能因为复用而串到别的条目;
  • 页面重建时,列表既要快速恢复,又不能展示过期交互状态。

因此,设计重点不应只是“如何区分布局”,而应是建立稳定的数据身份、明确的绑定协议和可观测的更新链路。

用密封类型描述页面模型

不要让 Adapter 直接拼接接口 DTO。页面模型应该只表达渲染所需信息,并为每个条目提供稳定身份。

代码语言:javascript
复制
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` 能在后台计算差异,但前提是比较规则正确。

代码语言:javascript
复制
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` 的列表和元素应尽量不可变。下面的写法有隐患:

代码语言:javascript
复制
val current = adapter.currentList
(current.first() as MutableArticle).liked = true
adapter.submitList(current)

对象被原地修改后,新旧快照可能指向同一份内容,DiffUtil 无法发现变化。更可靠的方式是复制元素并创建新列表:

代码语言:javascript
复制
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 管理创建和绑定,但要避免散落的魔法数字。

代码语言:javascript
复制
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 返回变化字段。

代码语言:javascript
复制
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 的绑定方法:

代码语言:javascript
复制
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 只更新对应控件:

代码语言:javascript
复制
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()` 时重置。例如展开按钮、复选框监听器、动画进度和异步图片请求。

代码语言:javascript
复制
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 默认值或上一个条目留下的状态。

异步任务还应在回收时清理:

代码语言:javascript
复制
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 状态:

代码语言:javascript
复制
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`:

代码语言:javascript
复制
private val sharedPool = RecyclerView.RecycledViewPool()

fun configureNestedList(recyclerView: RecyclerView) {
    recyclerView.setRecycledViewPool(sharedPool)
    recyclerView.setHasFixedSize(true)
}

共享回收池减少同类型子 ViewHolder 的重复创建,但不能跨完全不同的布局协议盲目共享。

等待有效数据再恢复滚动位置

页面重建时,RecyclerView 可能在数据尚未加载前尝试恢复位置,随后因为空列表而丢失状态。对于 `ListAdapter`,可以设置恢复策略:

代码语言:javascript
复制
adapter.stateRestorationPolicy =
    RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY

这会等列表非空后再恢复。若列表允许长期为空,应在业务状态明确为“加载完成且确实为空”时评估是否切换策略,避免恢复过程一直被阻止。

不要每次收到新列表都调用 `scrollToPosition(0)`。刷新和用户主动返回顶部是不同意图,滚动行为应该由明确事件触发,而不是和 `submitList()` 绑定。

ConcatAdapter 还是单个多类型 Adapter

`ConcatAdapter` 适合把相对独立的区块拼接起来,例如头部运营位、主体分页列表和页尾提示。各子 Adapter 可以独立更新,职责清晰。

代码语言:javascript
复制
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`:

代码语言:javascript
复制
recyclerView.itemAnimator = null

这会掩盖症状,也会失去正常的插入、删除动画。更合理的排查顺序是:

  • 确认条目 ID 是否稳定且唯一;
  • 确认没有原地修改旧列表;
  • 确认 DiffUtil 内容比较包含变化字段;
  • 为小范围变化提供 payload;
  • 检查图片加载是否在完整绑定时重复执行;
  • 最后再根据产品体验调整 change animation。

若只想避免内容变化时的交叉淡入淡出,可以针对默认动画器配置:

代码语言:javascript
复制
(recyclerView.itemAnimator as? SimpleItemAnimator)
    ?.supportsChangeAnimations = false

这比关闭全部动画影响更小,但仍应先保证更新协议正确。

用工具找到真正的卡顿来源

“列表卡”可能发生在数据转换、Diff 计算、布局测量、图片解码或主线程 I/O,不应只凭感觉修改参数。

推荐从这些证据入手:

  • 用 Layout Inspector 检查卡片层级和过度嵌套;
  • 用 System Trace 查看主线程上的长任务、布局和绘制区间;
  • 用 `FrameMetricsAggregator` 或 JankStats 统计慢帧;
  • 在图片加载链路检查原图尺寸、缩略图和缓存命中;
  • 给 DTO 转换、列表合并和 `submitList()` 前后的流程增加 trace 区间;
  • 在可复现场景下使用 Macrobenchmark,而不是只看一次手动滑动。

RecyclerView 本身通常不是唯一瓶颈。比如绑定代码只耗时很少,但图片解码在主线程执行,增加回收池容量并不会改善慢帧。

一套可落地的更新链路

推荐把页面更新保持为单向数据流:

代码语言:javascript
复制
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
        }
    }
}

页面只负责收集并提交新快照:

代码语言:javascript
复制
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.items.collectLatest(adapter::submitList)
    }
}

这样,点击事件回到 ViewModel,状态变化生成新列表,DiffUtil 决定最小更新范围。Adapter 不再私自维护另一份业务状态,旋转屏幕和数据刷新也更容易保持一致。

常见问题清单

上线前可以逐项检查:

  • 稳定 ID 是否唯一,并且不会随位置或文案变化;
  • 列表及元素是否采用不可变快照;
  • 完整绑定是否覆盖所有可见属性;
  • payload 是否只作为优化,而不是正确性的唯一依赖;
  • 回调是否传业务 ID,而不是缓存旧位置;
  • ViewHolder 回收时是否停止动画、计时器和异步任务;
  • 嵌套列表是否按父条目保存滚动状态;
  • 状态恢复是否等待有效数据;
  • Diff、图片、布局和数据转换是否分别做过性能观测;
  • 关闭动画前是否排除了身份和绑定协议错误。

总结

可维护的多类型列表,不是把更多布局塞进 Adapter,而是让每个条目拥有稳定身份,让每次数据变化形成不可变快照,让完整绑定与局部绑定都遵守清晰协议。配合正确的状态恢复、嵌套列表回收策略和基于证据的性能分析,RecyclerView 即使承载复杂首页,也能保持更新准确、滚动稳定,并为后续新增卡片留出足够空间。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • RecyclerView 多类型列表实战:稳定刷新、状态恢复与性能治理
    • 多类型列表真正难在哪里
    • 用密封类型描述页面模型
    • 先实现可靠的 DiffUtil
    • 建立清晰的 ViewHolder 分发
    • 用 payload 避免无意义的完整绑定
    • 处理复用导致的状态串行
    • 嵌套 RecyclerView 的状态恢复
    • 等待有效数据再恢复滚动位置
    • ConcatAdapter 还是单个多类型 Adapter
    • 关闭动画不是修复闪烁的首选方案
    • 用工具找到真正的卡顿来源
    • 一套可落地的更新链路
    • 常见问题清单
    • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档