一、Paging3 到底是什么?
说实话,我刚接触 Paging3 的时候,第一反应是:这不就是个分页库吗?用得着搞这么复杂?
但真正在项目里用起来才发现,它解决的不只是「加载下一页」这么简单。你想想看,一个列表页要处理多少破事:
- 数据从哪来?网络还是本地?
- 加载失败了怎么办?
- 用户快速滑动时,要不要取消正在进行的请求?
- 数据库里的数据怎么和网络数据合并?
Paging3 把这些都封装好了。说白了,它就是一个帮你管理「数据流」的框架。你只需要告诉它「怎么加载数据」,剩下的它帮你搞定。
二、核心组件拆解
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 来定位。
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 层通过观察数据库的变化来更新列表。
2.3 双数据源策略的完整流程
我画了一张图,帮你理清整个流程:
流程其实很简单:
- 用户滑动列表,PagingSource 先去 Room 数据库读数据
- 如果数据库有数据,直接展示
- 如果数据库没数据(或数据不够),触发 RemoteMediator 去请求网络
- RemoteMediator 把网络数据写入数据库
- 数据库的变化通过 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
}
}
3.2 我踩过的 DiffUtil 坑
有一次,我发现列表刷新后,有些 item 明明数据变了,但 UI 没更新。排查了半天,发现是 areContentsTheSame 返回了 true。
原因是我在 User 类里用了 data class,但某个字段是 List 类型,data class 的 equals 比较的是引用,不是内容。改成用 id + 时间戳来判断就好了。
四、加载状态与重试机制
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() 只会重试最后一次。
五、总结
Paging3 的核心其实就三件事:
- PagingSource:定义数据从哪里来
- RemoteMediator:协调网络和本地数据
- PagingDataAdapter:高效展示分页数据
说实话,刚开始用 Paging3 会觉得有点复杂,但一旦理解了它的设计思想,你会发现它真的能帮你省掉很多重复代码。尤其是双数据源策略,在离线优先的应用里简直是神器。
嗯,这一章就到这里。记住:Paging3 不是银弹,但它能解决 80% 的分页问题。剩下的 20%,就需要你根据业务场景自己定制了。