Paging 3 分页:从手动分页到声明式加载
为什么需要分页
当列表数据量很大时,不可能一次性把所有数据加载到内存中。比如一个新闻列表有上万条数据,一次性加载会导致:
- 内存溢出。 上万条数据全部持有在内存中,列表滚动时还可能触发更多 GC。
- 首屏慢。 用户要等所有数据下载完才能看到页面,体验极差。
- 流量浪费。 用户可能只看前几条,后面的数据根本用不到。
分页加载的思路就是:先加载一小部分数据展示,用户滚动到底部时再自动加载更多。
手动分页的痛点
在 Paging 3 出现之前,很多开发者手动实现分页逻辑:
kotlin
class NewsAdapter : RecyclerView.Adapter<NewsAdapter.VH>() {
private val items = mutableListOf<News>()
private var currentPage = 1
private var isLoading = false
private var hasMore = true
fun loadMore() {
if (isLoading || !hasMore) return
isLoading = true
api.getNews(page = currentPage).enqueue { list ->
items.addAll(list)
currentPage++
hasMore = list.isNotEmpty()
isLoading = false
notifyDataSetChanged()
}
}
}
这段代码有几个典型问题:
- 状态管理复杂。
isLoading、hasMore、currentPage等标志位容易出 bug,比如重复请求或漏掉加载。 - 与 RecyclerView 耦合。 分页逻辑散落在 Adapter 和 Fragment/Activity 中,难以测试。
- 错误处理不统一。 加载失败后的重试逻辑需要自己维护。
- 不支持 DiffUtil。 用
notifyDataSetChanged()做全量刷新,性能差。
Paging 3 把这些繁琐的工作都封装好了。
Paging 3 是什么
Paging 3 是 Android Jetpack 中的分页加载库,核心特点:
- 基于 Kotlin Coroutines + Flow。 天然支持协程,数据流用
Flow<PagingData<T>>表示。 - 自动加载下一页。 监听滚动位置,在合适的时机自动触发加载,不需要手动判断。
- 内置状态管理。 加载中、加载失败、加载完成等状态都有内置支持,包括 Footer 显示。
- 支持 DiffUtil。
PagingDataAdapter内置 DiffUtil,自动做增量更新。 - 支持多种数据源。 网络、Room 数据库、或者两者结合都行。
添加依赖
在 build.gradle 中:
groovy
dependencies {
implementation "androidx.paging:paging-runtime:3.3.0"
// 如果只用 Compose
implementation "androidx.paging:paging-compose:3.3.0"
}
核心组件
Paging 3 有三个核心组件:
| 组件 | 职责 |
|---|---|
| PagingSource | 数据源,定义"从哪里加载数据"以及"如何加载下一页/上一页" |
| Pager | 配置器,设置页面大小、预加载阈值等参数,输出 Flow<PagingData<T>> |
| PagingDataAdapter | RecyclerView 适配器,消费 PagingData 并展示列表 |
第一步:定义 PagingSource
PagingSource 是分页的数据来源。对于网络分页,它接收页码,返回一页数据:
kotlin
class NewsPagingSource(
private val api: NewsApi
) : PagingSource<Int, News>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, News> {
val page = params.key ?: 1
return try {
val response = api.getNews(page = page, size = params.loadSize)
LoadResult.Page(
data = response.data,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.data.isEmpty()) null else page + 1
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, News>): Int? {
return state.anchorPosition?.let { pos ->
state.closestPageToPosition(pos)?.prevKey?.plus(1)
?: state.closestPageToPosition(pos)?.nextKey?.minus(1)
}
}
}
几个要点:
LoadParams.key是页码,首次加载时为null,我们用默认值 1。prevKey/nextKey告诉 Paging 框架上一页和下一页的 key。如果到头了就返回null。getRefreshKey用于刷新(如下拉刷新)后恢复滚动位置。
第二步:用 Pager 创建数据流
在 ViewModel 中用 Pager 把 PagingSource 包装成 Flow<PagingData<T>>:
kotlin
class NewsViewModel(private val api: NewsApi) : ViewModel() {
val newsFlow: Flow<PagingData<News>> = Pager(
config = PagingConfig(
pageSize = 20, // 每页 20 条
prefetchDistance = 5, // 距离底部 5 条时预加载
initialLoadSize = 40, // 首次加载 40 条
enablePlaceholders = false
)
) {
NewsPagingSource(api)
}.flow.cachedIn(viewModelScope)
}
关键参数:
pageSize: 每次加载的数据量。prefetchDistance: 距离末尾多少条时开始预加载下一页。initialLoadSize: 首次加载的数据量,通常设为pageSize的 2-3 倍,让用户看到更充实的首屏。cachedIn(viewModelScope): 将 Flow 缓存在 ViewModel 的生命周期内,避免配置变化后重新加载。
第三步:用 PagingDataAdapter 展示
kotlin
class NewsAdapter : PagingDataAdapter<News, NewsAdapter.VH>(DIFF_CALLBACK) {
companion object {
val DIFF_CALLBACK = object : DiffUtil.ItemCallback<News>() {
override fun areItemsTheSame(old: News, new: News) = old.id == new.id
override fun areContentsTheSame(old: News, new: News) = old == new
}
}
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = view.findViewById(R.id.tv_title)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_news, parent, false)
return VH(view)
}
override fun onBindViewHolder(holder: VH, position: Int) {
val news = getItem(position)
news?.let { holder.title.text = it.title }
}
}
在 Fragment 中收集数据:
kotlin
viewLifecycleOwner.lifecycleScope.launch {
viewModel.newsFlow.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
collectLatest 会在新的 PagingData 到来时取消上一次收集,确保下拉刷新时旧数据被替换。
处理加载状态
Paging 3 内置了加载状态管理,可以通过 adapter.loadStateFlow 监听:
kotlin
adapter.addLoadStateListener { loadState ->
// 首次加载
if (loadState.refresh is LoadState.Loading) {
binding.progressBar.isVisible = true
} else {
binding.progressBar.isVisible = false
}
// 加载失败
val error = when {
loadState.refresh is LoadState.Error -> loadState.refresh as LoadState.Error
loadState.append is LoadState.Error -> loadState.append as LoadState.Error
else -> null
}
error?.let {
Toast.makeText(requireContext(), "加载失败:${it.error.message}", Toast.LENGTH_SHORT).show()
}
}
添加 Footer(加载中/失败/重试)
创建一个 LoadStateAdapter 作为 Footer:
kotlin
class PagingLoadStateAdapter(
private val retry: () -> Unit
) : LoadStateAdapter<PagingLoadStateAdapter.VH>() {
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
val progressBar: ProgressBar = view.findViewById(R.id.progress)
val errorMsg: TextView = view.findViewById(R.id.error_msg)
val retryBtn: Button = view.findViewById(R.id.retry_btn)
}
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): VH {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_load_state, parent, false)
return VH(view).apply {
retryBtn.setOnClickListener { retry() }
}
}
override fun onBindViewHolder(holder: VH, loadState: LoadState) {
holder.progressBar.isVisible = loadState is LoadState.Loading
holder.errorMsg.isVisible = loadState is LoadState.Error
holder.retryBtn.isVisible = loadState is LoadState.Error
}
}
把它合并到主 Adapter 上:
kotlin
val adapterWithFooter = adapter.withLoadStateFooter(
PagingLoadStateAdapter { adapter.retry() }
)
recyclerView.adapter = adapterWithFooter
这样列表底部就会自动显示"加载中"或"加载失败 + 重试按钮"。
Room + Paging 3
当数据源是本地数据库时,Room 直接支持 Paging 3:
kotlin
@Dao
interface NewsDao {
@Query("SELECT * FROM news ORDER BY publishTime DESC")
fun pagingSource(): PagingSource<Int, News>
}
ViewModel 中直接使用:
kotlin
val newsFlow = Pager(
config = PagingConfig(pageSize = 20)
) {
newsDao.pagingSource()
}.flow.cachedIn(viewModelScope)
Room 会自动处理数据库变更后的自动刷新------当数据表有 INSERT/UPDATE/DELETE 操作时,PagingSource 会自动重新加载。
网络 + 本地联合分页
最常见的架构是"网络请求 → 写入 Room → 从 Room 读取展示"。Paging 3 提供了 RemoteMediator 来实现这种模式:
kotlin
class NewsRemoteMediator(
private val api: NewsApi,
private val db: AppDatabase
) : RemoteMediator<Int, News>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, News>
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val lastItem = state.lastItemOrNull() ?: return MediatorResult.Success(true)
lastItem.pageIndex + 1
}
}
return try {
val response = api.getNews(page = page, size = state.config.pageSize)
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.newsDao().clearAll()
}
db.newsDao().insertAll(response.data)
}
MediatorResult.Success(endOfPaginationReached = response.data.isEmpty())
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
}
使用方式:
kotlin
val newsFlow = Pager(
config = PagingConfig(pageSize = 20),
remoteMediator = NewsRemoteMediator(api, db)
) {
db.newsDao().pagingSource()
}.flow.cachedIn(viewModelScope)
这样用户看到的始终是 Room 中的数据,网络请求在后台自动进行,离线时也能展示缓存数据。
下拉刷新
下拉刷新只需要让 adapter 重新提交数据:
kotlin
binding.swipeRefresh.setOnRefreshListener {
adapter.refresh()
}
// 监听刷新状态,自动关闭刷新动画
adapter.addLoadStateListener { loadState ->
binding.swipeRefresh.isRefreshing = loadState.refresh is LoadState.Loading
}
adapter.refresh() 会触发 PagingSource 的 LoadType.REFRESH,从第一页重新开始加载。
常见问题
Q:列表位置在刷新后丢失了怎么办?
A:确保 getRefreshKey() 正确实现。Paging 3 会根据它恢复滚动位置。
Q:如何实现按时间分组(Header)?
A:在 PagingData 上做 map 转换,插入分组标题项:
kotlin
newsFlow.map { pagingData ->
pagingData.map { news -> NewsItem.Content(news) }
}.map { pagingData ->
pagingData.insertSeparators { before, after ->
if (before == null && after != null) {
NewsItem.Header("最新")
} else if (before != null && after != null && before.news.date != after.news.date) {
NewsItem.Header(after.news.date)
} else null
}
}
Q:pageSize 设多大合适?
A:通常一屏能展示 5-8 条数据,pageSize 设为 15-25 比较合适。太大浪费流量,太小频繁请求。
小结
Paging 3 把分页加载这个常见需求标准化了。它的核心思路是:
- PagingSource 负责定义数据从哪来、怎么翻页。
- Pager 负责配置加载策略,输出
Flow<PagingData>。 - PagingDataAdapter 负责消费数据并展示,自动处理 DiffUtil 和滚动监听。
配合 LoadStateAdapter 和 RemoteMediator,可以覆盖从简单网络分页到"网络 + 本地缓存"联合分页的各种场景。如果你还在手动管理分页状态,是时候切换到 Paging 3 了。