50、Paging3分页加载:PagingSource、PagingData、PagingDataAdapter、RemoteMediator

分页加载,说白了就是「数据太多,一次拿不完」。

我刚开始做列表页时,总觉得一次性把所有数据塞进 RecyclerView 就完事了。直到有一次,用户反馈说「打开页面卡了 3 秒」,我一看日志——好家伙,一次性拉了 2000 条数据。嗯,从那以后,我再也不敢这么干了。

Paging3 是 Google 官方提供的分页解决方案。它帮你处理了「什么时候加载下一页」「加载过程中 UI 怎么展示」「数据源切换」这些脏活累活。今天我们就把它拆开看看。

核心组件一览

Paging3 有四个核心角色,我习惯把它们比作一个「数据流水线」:

组件 角色 一句话说明
PagingSource 数据工人 负责从网络或数据库里搬数据
PagingData 数据容器 装着一批一批的数据,按页切好
PagingDataAdapter 搬运工 把 PagingData 里的数据展示到 RecyclerView
RemoteMediator 调度员 协调网络和本地数据库,实现「先看缓存,再拉新」

核心流程: PagingSource 产出数据 → 包装成 PagingData → PagingDataAdapter 消费展示 → RemoteMediator 在需要时触发网络加载。

PagingSource 数据工人 PagingData 数据容器 PagingDataAdapter 搬运工 RemoteMediator 调度员 网络 / 数据库

PagingSource:数据从哪里来

PagingSource 是你需要自己实现的类。它告诉 Paging3:「数据长什么样」「怎么按页取」。我个人习惯把它放在 repository 层,和网络请求或数据库查询放在一起。

class ArticlePagingSource(
    private val api: ArticleApi,
    private val query: String
) : PagingSource<Int, Article>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        return try {
            val page = params.key ?: 1
            val response = api.searchArticles(query, page, params.loadSize)
            LoadResult.Page(
                data = response.articles,
                prevKey = if (page > 1) page - 1 else null,
                nextKey = if (response.articles.isNotEmpty()) page + 1 else null
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }
}

小技巧: getRefreshKey 决定了刷新后回到哪一页。我一般用 anchorPosition 来定位,这样用户下拉刷新后还能回到之前看的位置,体验好很多。

PagingData:数据怎么流动

PagingData 是一个不可变的「数据流」。你不需要手动创建它,而是通过 Pager 来生成。它内部维护了加载状态、重试机制、数据合并等逻辑。

val pagingDataFlow: Flow<PagingData<Article>> = Pager(
    config = PagingConfig(
        pageSize = 20,
        enablePlaceholders = false,
        initialLoadSize = 40
    ),
    pagingSourceFactory = { ArticlePagingSource(api, query) }
).flow.cachedIn(viewModelScope)

这里有个坑——cachedIn 必须调用。我曾经漏掉它,结果每次屏幕旋转都重新加载数据,用户骂声一片。cachedIn 会把 PagingData 缓存到 ViewModel 的作用域里,配置变更时不会丢失。

PagingDataAdapter:数据怎么展示

PagingDataAdapter 继承自 RecyclerView.Adapter,但它知道怎么「增量更新」。你只需要像普通 Adapter 一样实现 DiffUtil 即可。

class ArticleAdapter : PagingDataAdapter<Article, ArticleAdapter.ViewHolder>(ArticleDiffCallback()) {

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = getItem(position) ?: return
        holder.bind(item)
    }

    // ... ViewHolder 实现略
}

// 在 Fragment 或 Activity 中
val adapter = ArticleAdapter()
binding.recyclerView.adapter = adapter

viewLifecycleOwner.lifecycleScope.launch {
    viewModel.pagingDataFlow.collectLatest { pagingData ->
        adapter.submitData(pagingData)
    }
}

注意: submitData 必须在 lifecycleScope 中调用,并且使用 collectLatest。否则会出现「数据还没加载完,协程就被取消」的问题。我踩过这个坑,调试了一下午才发现是 collect 和 collectLatest 的区别。

RemoteMediator:网络+数据库的终极方案

如果你的 App 有本地数据库(比如 Room),RemoteMediator 就是你的最佳搭档。它的工作流程是:

  1. 先查本地数据库,有数据就直接展示
  2. 用户往下翻,本地数据不够了,触发 RemoteMediator
  3. RemoteMediator 去网络拉新数据,存到数据库
  4. 数据库更新后,PagingSource 自动感知变化,刷新列表

说白了,就是「先看缓存,再拉新」。用户体验上,用户永远看到的是「秒开」,因为数据来自本地。

class ArticleRemoteMediator(
    private val api: ArticleApi,
    private val db: ArticleDatabase,
    private val query: String
) : RemoteMediator<Int, Article>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, Article>
    ): MediatorResult {
        return try {
            val page = when (loadType) {
                LoadType.REFRESH -> 1
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND -> {
                    val lastItem = state.lastItemOrNull()
                    if (lastItem == null) 1 else (lastItem.pageIndex + 1)
                }
            }

            val response = api.searchArticles(query, page, state.config.pageSize)
            db.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    db.articleDao().clearAll()
                }
                db.articleDao().insertAll(response.articles)
            }

            MediatorResult.Success(endOfPaginationReached = response.articles.isEmpty())
        } catch (e: Exception) {
            MediatorResult.Error(e)
        }
    }
}

关键点: PREPEND 一般直接返回 Success,因为往上翻页的场景很少。REFRESH 时记得清空旧数据,否则会出现「新旧数据混在一起」的 bug。我曾在生产环境遇到过这个问题,用户反馈「列表里出现了两条一模一样的新闻」,就是因为没清空旧数据。

避坑指南

  • 不要用 List 接收 PagingData: PagingData 是流式的,不能直接转成 List。如果你需要操作数据,请在 PagingSource 里处理。
  • 页面大小别设太小: pageSize 设成 10 以下,会导致频繁触发加载,网络请求太多。我一般设 20 或 30。
  • enablePlaceholders 看情况: 如果设为 true,列表会预留空位,但需要你知道总数据量。不知道总条数时,设为 false 更安全。
  • RemoteMediator 的事务: 数据库写入一定要用事务,否则可能出现「写了一半,App 被杀」的情况。

总结

Paging3 不是那种「学完就忘」的库。它解决的是真实世界里的真实问题——数据太多、网络太慢、用户没耐心。你想想看,如果你的列表页能做到「秒开 + 无限滚动 + 不卡顿」,用户怎么会不喜欢?

我个人建议,新项目直接上 Paging3 + Room + RemoteMediator 的组合。虽然初期配置多一点,但后期维护起来真的很省心。

公众号:蓝海资料掘金营,微信deep3321