一、Paging3 到底是什么?

说实话,我刚接触 Paging3 的时候,第一反应是:这不就是个分页库吗?用得着搞这么复杂?

但真正在项目里用起来才发现,它解决的不只是「加载下一页」这么简单。你想想看,一个列表页要处理多少破事:

  • 数据从哪来?网络还是本地?
  • 加载失败了怎么办?
  • 用户快速滑动时,要不要取消正在进行的请求?
  • 数据库里的数据怎么和网络数据合并?

Paging3 把这些都封装好了。说白了,它就是一个帮你管理「数据流」的框架。你只需要告诉它「怎么加载数据」,剩下的它帮你搞定。

核心思想:Paging3 不是简单的分页工具,而是一个完整的数据加载管道。它把数据源、加载策略、UI 展示、状态管理全部串联起来。

二、核心组件拆解

2.1 PagingSource —— 数据从哪里来?

PagingSource 是 Paging3 最基础的组件。它的职责只有一个:根据页码加载数据。

我记得第一次写 PagingSource 时,觉得它像个「数据搬运工」。你给它一个页码,它给你返回一页数据,就这么简单。

class UserPagingSource(
    private val api: UserApi,
    private val query: String
) : PagingSource<Int, User>() {

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

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

这里有几个坑,我当初踩过:

  • key 的类型:不一定是 Int,可以是 String 甚至自定义对象。但 Int 最常用,因为服务端分页基本都是页码。
  • prevKey 和 nextKey:很多人只设置 nextKey,忘了 prevKey。这样下拉刷新时无法回到之前的位置。
  • getRefreshKey:这个方法决定了刷新后应该停留在哪一页。我习惯用 anchorPosition 来定位。
我的习惯:PagingSource 里只做数据转换,不做业务逻辑。比如把 API 返回的 DTO 转成 UI 层需要的 Model,我一般放在 Repository 层处理。

2.2 RemoteMediator —— 网络+本地双数据源

这是 Paging3 最强大的组件,没有之一。

为什么需要它?你想想看,如果只用 PagingSource,数据要么全从网络来,要么全从本地来。但实际项目中,我们通常需要「先读缓存,再请求网络,然后合并结果」。

RemoteMediator 就是干这个的。它充当了「网络数据」和「本地数据」之间的协调者。

class UserRemoteMediator(
    private val api: UserApi,
    private val db: UserDatabase,
    private val query: String
) : RemoteMediator<Int, UserEntity>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, UserEntity>
    ): MediatorResult {
        return try {
            // 1. 决定加载哪一页
            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.page + 1)
                }
            }

            // 2. 请求网络
            val response = api.searchUsers(query, page, state.config.pageSize)

            // 3. 存入数据库
            db.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    db.userDao().clearAll()
                }
                db.userDao().insertAll(response.users.map { it.toEntity(page) })
            }

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

这里有个关键点:RemoteMediator 不直接返回数据给 UI。它只负责把网络数据写入本地数据库。UI 层通过观察数据库的变化来更新列表。

我曾经踩过的坑:在 RemoteMediator 里直接返回网络数据给 UI,结果导致 UI 和数据库不同步。正确的做法是:RemoteMediator 只写数据库,PagingSource 只读数据库,各司其职。

2.3 双数据源策略的完整流程

我画了一张图,帮你理清整个流程:

Paging3 双数据源策略流程图 用户滑动列表 PagingSource 读取 Room 数据库 有数据? 直接展示数据 触发 RemoteMediator 请求网络 API 写入 Room 数据库 Flow 通知 UI 更新 重新读取

流程其实很简单:

  1. 用户滑动列表,PagingSource 先去 Room 数据库读数据
  2. 如果数据库有数据,直接展示
  3. 如果数据库没数据(或数据不够),触发 RemoteMediator 去请求网络
  4. RemoteMediator 把网络数据写入数据库
  5. 数据库的变化通过 Flow 通知 UI 更新

这个策略的好处是:用户永远看到的是本地数据,即使网络请求失败,也不会出现空白页面。

三、PagingDataAdapter 与 DiffUtil

3.1 为什么需要 PagingDataAdapter?

普通的 RecyclerView.Adapter 处理分页数据时,有个很头疼的问题:数据是分批到达的,每次都要 notifyDataSetChanged(),导致列表闪烁。

PagingDataAdapter 就是来解决这个问题的。它内部集成了 AsyncPagingDataDiffer,可以高效地计算新旧数据的差异,只更新变化的部分。

class UserAdapter : PagingDataAdapter<User, UserAdapter.UserViewHolder>(UserDiffCallback()) {

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

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
        return UserViewHolder(
            LayoutInflater.from(parent.context)
                .inflate(R.layout.item_user, parent, false)
        )
    }
}

class UserDiffCallback : DiffUtil.ItemCallback<User>() {
    override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
        return oldItem == newItem
    }
}
关键点:DiffUtil 的 areItemsTheSame 和 areContentsTheSame 一定要实现正确。areItemsTheSame 判断是不是同一个对象(通常用 id),areContentsTheSame 判断内容有没有变化。

3.2 我踩过的 DiffUtil 坑

有一次,我发现列表刷新后,有些 item 明明数据变了,但 UI 没更新。排查了半天,发现是 areContentsTheSame 返回了 true。

原因是我在 User 类里用了 data class,但某个字段是 List 类型,data class 的 equals 比较的是引用,不是内容。改成用 id + 时间戳来判断就好了。

避坑指南:如果数据类里有 List、Map 等集合类型,建议重写 equals 和 hashCode,或者在 DiffUtil 里手动比较关键字段。

四、加载状态与重试机制

4.1 加载状态的三层结构

Paging3 的加载状态分为三层:

状态 含义 触发时机
LoadState.Loading 正在加载 发起请求时
LoadState.NotLoading 加载完成 请求成功或失败时
LoadState.Error 加载失败 请求抛出异常时

每个 LoadState 又分为三种类型:

  • Refresh:刷新操作(下拉刷新或首次加载)
  • Prepend:向前加载(上拉加载更多,通常用不到)
  • Append:向后加载(下滑加载更多)

4.2 如何监听加载状态?

// 在 Fragment 或 Activity 中
viewLifecycleOwner.lifecycleScope.launch {
    adapter.loadStateFlow.collect { loadStates ->
        when (loadStates.refresh) {
            is LoadState.Loading -> showLoading()
            is LoadState.Error -> showError(loadStates.refresh.error)
            is LoadState.NotLoading -> hideLoading()
        }

        when (loadStates.append) {
            is LoadState.Loading -> showFooterLoading()
            is LoadState.Error -> showFooterError(loadStates.append.error)
            is LoadState.NotLoading -> hideFooter()
        }
    }
}

4.3 重试机制

重试其实很简单,PagingDataAdapter 提供了 retry() 方法:

// 在错误页面的按钮点击事件中
binding.btnRetry.setOnClickListener {
    adapter.retry()
}

但要注意:retry() 只能重试最近一次失败的操作。如果用户连续失败多次,retry() 只会重试最后一次。

我的建议:在 Error 状态下,不要只显示一个「重试」按钮。最好显示错误原因,比如「网络连接失败,请检查网络设置」。这样用户体验更好。

五、总结

Paging3 的核心其实就三件事:

  • PagingSource:定义数据从哪里来
  • RemoteMediator:协调网络和本地数据
  • PagingDataAdapter:高效展示分页数据

说实话,刚开始用 Paging3 会觉得有点复杂,但一旦理解了它的设计思想,你会发现它真的能帮你省掉很多重复代码。尤其是双数据源策略,在离线优先的应用里简直是神器。

嗯,这一章就到这里。记住:Paging3 不是银弹,但它能解决 80% 的分页问题。剩下的 20%,就需要你根据业务场景自己定制了。


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