目录
  1. 1. 一、Paging 3 架构概述
    1. 1.1. 1.1 为什么需要 Paging 库
    2. 1.2. 1.2 Paging 3 核心组件关系
  2. 2. 二、PagingSource:定义数据源
    1. 2.1. 2.1 PagingSource 的作用与设计
    2. 2.2. 2.2 实现 load() 方法
    3. 2.3. 2.3 实现 getRefreshKey() 方法
    4. 2.4. 2.4 支持搜索/过滤的 PagingSource
  3. 3. 三、PagingData 与 Pager
    1. 3.1. 3.1 PagingData —— 不可变的数据流
    2. 3.2. 3.2 PagingConfig 深度配置
    3. 3.3. 3.3 Flow 操作符组合
  4. 4. 四、PagingDataAdapter:UI 层集成
    1. 4.1. 4.1 PagingDataAdapter 基本用法
    2. 4.2. 4.2 AsyncPagingDataDiffer
    3. 4.3. 4.3 LoadStateAdapter:加载状态展示
    4. 4.4. 4.4 LoadState 的三种状态含义
    5. 4.5. 4.5 加载状态流(LoadStateFlow)
  5. 5. 五、RemoteMediator:网络 + 本地数据库
    1. 5.1. 5.1 为什么需要 RemoteMediator
    2. 5.2. 5.2 RemoteMediator 详解
    3. 5.3. 5.3 LoadType 触发时机
    4. 5.4. 5.4 initialize() 方法
  6. 6. 六、完整示例:GitHub Repo 搜索
    1. 6.1. 6.1 项目结构
    2. 6.2. 6.2 数据层实现
    3. 6.3. 6.3 UI 层实现
  7. 7. 七、高级场景
    1. 7.1. 7.1 数据变换与映射
    2. 7.2. 7.2 列表项单独操作(收藏/删除)
    3. 7.3. 7.3 分页 + Header/Footer 固定项
    4. 7.4. 7.4 分页数据缓存策略
  8. 8. 八、Paging 2 vs Paging 3 对比
  9. 9. 面试常考问题
JetPack全家桶(六)之Paging分页库

Paging 是 Jetpack 中专门解决列表分页加载的库,它按需加载数据,避免一次性查询大量数据导致的内存压力与 UI 卡顿。Paging 3 以 Kotlin Coroutines + Flow 为核心,提供了简洁的响应式分页 API。

一、Paging 3 架构概述

1.1 为什么需要 Paging 库

在没有 Paging 的年代,实现 RecyclerView 分页加载通常需要:

// 传统方式实现分页 —— 太多容易出错的样板代码
class OldPagingAdapter : RecyclerView.Adapter<ViewHolder>() {
var items = listOf<Item>()
var isLoading = false
var currentPage = 1
var hasMore = true

override fun getItemCount() = items.size + if (hasMore) 1 else 0

override fun getItemViewType(position: Int): Int {
return if (position == items.size) TYPE_LOADING else TYPE_NORMAL
}
// ... onScrollListener 检测滚动到底部时触发加载
// ... 处理加载错误、重试逻辑
// ... 处理列表刷新时页码重置
// ... 处理 DiffUtil 增量更新
}

这种手动实现存在大量问题:

  • 滚动状态检测容易出错,导致重复加载或漏加载
  • 加载状态管理分散在 Adapter 和 Fragment 中
  • 页码管理和异常重试逻辑需要手写
  • 数据源变更时需要手动计算 Diff
  • 内存管理困难:列表数据越滚越多,无法自动清理

Paging 3 解构了这些问题,提供了三层架构:

┌──────────────────────────────────────────────────┐
│ UI Layer (PagingDataAdapter + LoadStateAdapter) │
│ ↑ submitData(pagingData) │
│ ↑ collectLatest { pagingData } │
├──────────────────────────────────────────────────┤
│ ViewModel (Pager) │
│ ↑ Pager(pagingConfig, pagingSourceFactory) │
│ ↑ Flow<PagingData> │
├──────────────────────────────────────────────────┤
│ Data Layer (PagingSource / RemoteMediator) │
│ ↑ load(params: LoadParams) │
│ ↑ getRefreshKey(state: PagingState) │
└──────────────────────────────────────────────────┘

1.2 Paging 3 核心组件关系

组件 作用 层级
PagingSource 定义如何从单一数据源加载分页数据 Data
RemoteMediator 协调网络 + 本地数据库双数据源 Data
Pager 创建 PagingData 流的工厂 ViewModel
PagingConfig 分页参数配置(页大小、预取距离等) ViewModel
PagingData 不可变的分页数据流容器 Shared
PagingDataAdapter RecyclerView.Adapter 的子类,处理分页数据展示 UI
LoadStateAdapter 在列表头/尾部展示加载状态 UI

二、PagingSource:定义数据源

2.1 PagingSource 的作用与设计

PagingSource 是 Paging 3 中数据加载的核心抽象。它代表了单一的数据源(Single Source of Truth)—— 要么是网络 API,要么是本地数据库(通常是 Room DAO)。

PagingSource 是一个抽象类,定义了两个泛型参数:

  • Key:分页的键类型。对于基于页码的 API,通常是 Int(页码);对于基于游标的 API,可能是 String(cursor/nextPageToken)。
  • Value:加载的数据项类型。

2.2 实现 load() 方法

load() 是 PagingSource 唯一必须实现的核心方法,它接收 LoadParams<Key>,返回 LoadResult<Key, Value>

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

/**
* 核心加载方法
* @param params LoadParams 包含:
* - params.key: 当前要加载的键(首次加载时为 null)
* - params.loadSize: 请求加载的条数(来自 PagingConfig.pageSize)
* - params.placeholdersEnabled: 是否启用了占位符
*/
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
val page = params.key ?: 1 // 首次加载时 key 为 null,从第 1 页开始
val pageSize = params.loadSize

return try {
val response = api.searchArticles(
query = query,
page = page,
pageSize = pageSize
)

// 检查 API 响应有效性
if (!response.isSuccessful) {
return LoadResult.Error(
HttpException(response)
)
}

val body = response.body()!!
val articles = body.items
val hasMore = body.currentPage < body.totalPages

LoadResult.Page(
data = articles,
prevKey = if (page > 1) page - 1 else null,
nextKey = if (hasMore) page + 1 else null
)
} catch (e: IOException) {
// 网络异常:区分网络不可达 vs 其他 IO 错误
LoadResult.Error(e)
} catch (e: Exception) {
// 其他未预期异常
LoadResult.Error(e)
}
}

LoadParams 关键参数详解:

参数 类型 说明
key Key? 当前要加载的键。初始加载时为 null,后续通过 Page 的 prevKey/nextKey 确定
loadSize Int 期望加载的数据条数,来自 PagingConfig.pageSize
placeholdersEnabled Boolean 是否启用了占位符功能

LoadResult 三种结果:

结果类型 含义 触发后续行为
LoadResult.Page(data, prevKey, nextKey) 成功加载一页数据 传给 PagingDataAdapter 展示,继续监控滚动以加载下一页
LoadResult.Error(throwable) 加载失败 展示错误状态,可以通过 retry() 方法触发重新加载
LoadResult.Invalid() 数据源已失效 触发 PagingSource 重建并重新加载

2.3 实现 getRefreshKey() 方法

getRefreshKey() 是 Paging 3 区别于 Paging 2 的关键创新之一。当 PagingData 需要刷新时(如用户下拉刷新、数据库更新触发重新加载),Paging 3 需要知道”从哪个 key 开始加载”才能保持列表位置不乱跳。

/**
* 当 PagingData 失效需要刷新时,返回用于重新加载的 key
* @param state 当前 PagingState,包含已加载的所有页面信息
* @return 用于重新加载的 key 或 null(从第一页开始加载)
*/
override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
// 策略:找到当前最靠近锚点位置(屏幕中央)的页面,
// 返回该页前面一页的 key,这样刷新后用户仍能看到附近的条目
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}

PagingState 包含的信息:

  • pages: List<PagingSource.LoadResult.Page<Key, Value>>:当前已加载的所有页面
  • anchorPosition: Int?:列表滚动锚点位置(大致是屏幕中央可见项的索引)
  • config: PagingConfig:当前分页配置

closestPageToPosition(position) 方法找到包含指定位置的页面。prevKeynextKey 是你在 LoadResult.Page 中返回的键。

2.4 支持搜索/过滤的 PagingSource

当用户输入搜索关键词时,PagingSource 需要重建:

class SearchArticlePagingSource(
private val api: ApiService,
private val queryFlow: Flow<String>
) : PagingSource<Int, Article>() {

// 将搜索词流与分页加载结合
// 搜索词变化时,通过 PagingSource.invalidate() 触发刷新
}

// 在 ViewModel 中
class SearchViewModel(
private val api: ApiService
) : ViewModel() {

private val query = MutableStateFlow("")

// 关键:当 query 变化时,使用 flatMapLatest 重建 Pager
val pagingDataFlow = query.flatMapLatest { searchQuery ->
Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { ArticlePagingSource(api, searchQuery) }
).flow
}.cachedIn(viewModelScope)
}

三、PagingData 与 Pager

3.1 PagingData —— 不可变的数据流

PagingData 是 Paging 3 中承载分页数据的不可变容器。它是只读的,一旦生成就不能被修改 —— UI 层只能通过 submitData() 提交给 Adapter,不能直接操作 PagingData 内部数据。

// Pager 创建 PagingData 流
val pager = Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { dao.getAllArticles() } // Room 自动生成 PagingSource
)

// flow 属性返回 Flow<PagingData<Article>>
// cachedIn() 让 Flow 在指定的 CoroutineScope 中缓存,避免配置变更时重新加载
val pagingDataFlow: Flow<PagingData<Article>> = pager.flow.cachedIn(viewModelScope)

PagingData 的重要特性:

  1. 不可变性:一旦通过 cachedIn() 收集,PagingData 是不可变的。这意味着多个观察者收到的是同一份数据,不会出现数据不一致。
  2. 分离关注点:PagingData 不关心数据来自哪里(网络还是本地)、如何加载(PagingSource 还是 RemoteMediator)。UI 层只知道”这里有分页数据”。
  3. Flow 集成:PagingData 通过 Flow 暴露,天然支持 Kotlin 协程的取消机制。
  4. cachedIn() 关键作用cachedIn(viewModelScope) 确保数据在 ViewModel 的 scope 中缓存,即使 UI 层重新 subscribe(如配置变更),也不会触发重新加载。

3.2 PagingConfig 深度配置

val config = PagingConfig(
// 每页加载的数据条数。这是传递给 PagingSource.load() 的 loadSize 参数
pageSize = 20,

// 预取距离:当用户滚动到距离底部 prefetchDistance 条数据时,提前触发下一页加载
// 典型值:pageSize 的 1-3 倍,让用户滚动时几乎永远有数据可用
prefetchDistance = 20,

// 是否启用占位符:开启后 PagingDataAdapter 会在数据未加载时用 null 占位
// 优点:列表不会在加载过程中发生大小变化
// 缺点:需要 PagingSource 能预估总数据量;可能与某些数据源不兼容
enablePlaceholders = false,

// PagingDataAdapter 内存中缓存的最大条目数
// 超过此值后,距离当前可见区域最远的页面会被丢弃
maxSize = 200,

// 跳转阈值:当用户通过 scrollToPosition() 跳过的条目数超过此值,
// Paging 3 将放弃加载中间页,直接加载目标页
// 用于处理用户快速跳转到列表深处的场景
jumpThreshold = 10
)

各参数的最佳实践:

  • pageSize:取决于 UI 设计,通常是一个屏幕能显示条目数的 2-3 倍。太小导致频繁网络请求,太大造成单次加载耗时过长。
  • prefetchDistance:保持与 pageSize 一致或稍大即可。太大会预加载大量无用数据,太小可能导致用户看到加载指示器。
  • enablePlaceholders:适合总数已知且变化不频繁的场景(如离线通讯录)。对于无限滚动的社交信息流,应关闭(因为不知道总数据量)。
  • maxSize:默认 Int.MAX_VALUE,建议根据场景设置合理值(如 PagingConfig(pageSize=20, maxSize=200)),避免内存溢出。
  • jumpThreshold:结合 PagingDataAdapter.withLoadStateHeader()scrollToPosition() 使用。

3.3 Flow 操作符组合

Paging 3 支持与 Kotlin Flow 操作符组合使用,实现高级场景:

// 场景 1:下拉刷新
viewModelScope.launch {
pagingDataFlow.collectLatest { pagingData ->
adapter.submitData(pagingData) // 每次新的 PagingData 替换旧数据
}
}
// 触发刷新:
adapter.refresh() // 会重新创建 PagingSource,从 getRefreshKey 开始加载

// 场景 2:搜索 + 过滤
val queryFlow = MutableStateFlow("")
val filterFlow = MutableStateFlow(Filter.ALL)

combine(queryFlow, filterFlow) { query, filter ->
Pair(query, filter)
}.flatMapLatest { (query, filter) ->
Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { FilteredPagingSource(api, query, filter) }
).flow
}.cachedIn(viewModelScope)

// 场景 3:将本地数据和远程数据合并展示
val localFlow = dao.getAllArticles() // Room 返回 PagingSource
val remoteFlow = ... // RemoteMediator 的 PagingData

// 通过 RemoteMediator 自动合并本地和远程,无需手动 combine

四、PagingDataAdapter:UI 层集成

4.1 PagingDataAdapter 基本用法

class ArticlePagingAdapter : PagingDataAdapter<Article, ArticleViewHolder>(ArticleDiffCallback) {

// DiffCallback:Paging 3 使用它来增量更新列表
companion object {
val ArticleDiffCallback = object : DiffUtil.ItemCallback<Article>() {
override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean {
return oldItem.id == newItem.id
}

override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean {
return oldItem == newItem
}

// 可选:为 PagingDataAdapter 提供负载(payload)信息
// 如果只更新了部分字段(如收藏状态),可以通过 payload 实现局部刷新
override fun getChangePayload(oldItem: Article, newItem: Article): Any? {
if (oldItem.isFavorited != newItem.isFavorited) {
return Bundle().apply {
putBoolean("favorite_changed", true)
}
}
return null
}
}
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
val binding = ItemArticleBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return ArticleViewHolder(binding)
}

override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
val article = getItem(position) // getItem() 可能返回 null(如果启用了 placeholders)
holder.bind(article)
}
}

4.2 AsyncPagingDataDiffer

PagingDataAdapter 内部使用 AsyncPagingDataDiffer 在后台线程执行 DiffUtil 计算:

┌─────────────────────────────────────────────────┐
│ PagingDataAdapter.submitData(pagingData) │
│ ↓ │
│ AsyncPagingDataDiffer (Dispatcher.Default) │
│ ├── collectFrom(PagingDataDiffer) │
│ ├── DiffUtil.calculateDiff(oldList, newList) │
│ └── dispatchUpdatesTo(adapter) → Main Thread │
└─────────────────────────────────────────────────┘

这意味着即使有数千条数据,DiffUtil 计算也不会阻塞主线程。submitData() 是挂起函数(suspend),建议使用 lifecycleScope.launchrepeatOnLifecycle 调用。

4.3 LoadStateAdapter:加载状态展示

LoadStateAdapter 是展示加载、错误状态的专用 Adapter,通常作为列表的 Header 或 Footer:

class ArticleLoadStateAdapter(
private val retry: () -> Unit
) : LoadStateAdapter<ArticleLoadStateAdapter.LoadStateViewHolder>() {

override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): LoadStateViewHolder {
val binding = ItemLoadStateBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return LoadStateViewHolder(binding, retry)
}

override fun onBindViewHolder(holder: LoadStateViewHolder, loadState: LoadState) {
holder.bind(loadState)
}

class LoadStateViewHolder(
private val binding: ItemLoadStateBinding,
private val retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {

fun bind(loadState: LoadState) {
binding.btnRetry.setOnClickListener { retry() }

when (loadState) {
is LoadState.NotLoading -> {
// 不在加载中,隐藏加载指示器
binding.progressBar.isVisible = false
binding.tvError.isVisible = false
binding.btnRetry.isVisible = false
}
is LoadState.Loading -> {
binding.progressBar.isVisible = true
binding.tvError.isVisible = false
binding.btnRetry.isVisible = false
}
is LoadState.Error -> {
binding.progressBar.isVisible = false
binding.tvError.isVisible = true
binding.tvError.text = when (loadState.error) {
is IOException -> "网络连接失败,请检查网络"
is HttpException -> "服务器异常 (${loadState.error.code()})"
else -> "加载失败,请重试"
}
binding.btnRetry.isVisible = true
}
}
}
}
}

// 使用 withLoadStateHeaderAndFooter 将 LoadStateAdapter 附加到 PagingDataAdapter
val adapter = ArticlePagingAdapter()
val headerAdapter = ArticleLoadStateAdapter { adapter.retry() }
val footerAdapter = ArticleLoadStateAdapter { adapter.retry() }

val adapterWithLoadState = adapter
.withLoadStateHeader(headerAdapter) // 顶部加载状态
.withLoadStateFooter(footerAdapter) // 底部加载状态

4.4 LoadState 的三种状态含义

LoadState 触发场景 UI 展示
NotLoading 没有正在进行的加载操作 隐藏加载指示器
Loading PagingSource.load() 正在执行 显示加载动画/进度条
Error(throwable) PagingSource 抛出异常 显示错误信息和重试按钮

LoadState 独立于 PagingData —— 即使在显示错误时,之前成功加载的数据仍然可见。

4.5 加载状态流(LoadStateFlow)

// 在 Fragment 中观察加载状态
viewLifecycleOwner.lifecycleScope.launch {
adapter.loadStateFlow.collectLatest { loadStates ->
val refreshState = loadStates.refresh // 刷新(首次加载/下拉刷新)
val appendState = loadStates.append // 追加(加载更多)
val prependState = loadStates.prepend // 前插(加载更早数据)

when {
refreshState is LoadState.Loading && adapter.itemCount == 0 -> {
// 首次加载中,显示全屏加载
showFullScreenLoading()
}
refreshState is LoadState.Error && adapter.itemCount == 0 -> {
// 首次加载失败,显示全屏错误页
showFullScreenError(
message = (refreshState as LoadState.Error).error.message,
onRetry = { adapter.retry() }
)
}
else -> {
// 正常展示列表
showContent()
}
}
}
}

五、RemoteMediator:网络 + 本地数据库

5.1 为什么需要 RemoteMediator

PagingSource 只能从单一数据源加载。但在实际应用中,我们通常需要这样的模式:

用户打开应用 → 先展示本地缓存数据 → 后台同步网络最新数据 → 回写本地数据库 → 通知 UI 更新

这就是 RemoteMediator 的价值:它协调网络和本地数据库两个数据源,以本地数据库为 Single Source of Truth。

┌──────────┐   load()    ┌────────────────┐   查询     ┌──────────┐
│ UI │ ──────────→ │ PagingSource │ ────────→ │ Room │
│ │ │ (from Room) │ ←──────── │ DAO │
│ │ └────────────────┘ 返回数据 └──────────┘
│ │ ↑
│ │ ┌─────────────────────┘
│ │ │ 写入
│ │ ┌──────┴─────────┐
│ │ │ RemoteMediator │
│ │ │ load() │
│ │ └──────┬─────────┘
│ │ │ 网络请求
│ │ ┌──────┴─────────┐
│ │ │ Retrofit/API │
│ │ └────────────────┘
└──────────┘

5.2 RemoteMediator 详解

@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
private val db: AppDatabase,
private val api: ApiService,
private val query: String = ""
) : RemoteMediator<Int, Article>() {

override suspend fun initialize(): InitializeAction {
// 可选覆写:决定是否触发远程刷新
// 检查本地缓存新鲜度
val cacheTimeout = TimeUnit.HOURS.toMillis(1)
val lastUpdated = db.articleDao().getLastUpdated()
val isCacheExpired = lastUpdated == null ||
System.currentTimeMillis() - lastUpdated >= cacheTimeout

return if (isCacheExpired) {
// 缓存过期,触发网络刷新
InitializeAction.LAUNCH_INITIAL_REFRESH
} else {
// 缓存有效,跳过刷新,直接使用本地数据
InitializeAction.SKIP_INITIAL_REFRESH
}
}

/**
* 核心加载方法
* @param loadType 加载类型:REFRESH / PREPEND / APPEND
* @param state 当前已加载数据的 PagingState
* @return MediatorResult
*/
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, Article>
): MediatorResult {
return try {
// Step 1: 根据 loadType 确定要请求的页码
val page = when (loadType) {
LoadType.REFRESH -> {
// 刷新:从头开始加载
val remoteKey = getRemoteKeyClosestToCurrentPosition(state)
remoteKey?.nextKey?.minus(1) ?: 1
}
LoadType.PREPEND -> {
// 前插(加载更早数据):通常用于双向无限滚动的时间线
val remoteKey = getRemoteKeyForFirstItem(state) ?:
return MediatorResult.Success(endOfPaginationReached = true)
remoteKey.prevKey ?:
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
// 追加(加载更多):常规的"向下滚动加载下一页"
val remoteKey = getRemoteKeyForLastItem(state) ?:
return MediatorResult.Success(endOfPaginationReached = true)
remoteKey.nextKey ?:
return MediatorResult.Success(endOfPaginationReached = true)
}
}

// Step 2: 从网络加载数据
val response = api.searchArticles(query, page, state.config.pageSize)
?: return MediatorResult.Success(endOfPaginationReached = true)

val articles = response.items
val endOfPaginationReached = response.currentPage >= response.totalPages

// Step 3: 写入本地数据库(在同一个事务中)
db.withTransaction {
// 刷新时清除旧缓存
if (loadType == LoadType.REFRESH) {
db.articleDao().clearAll()
db.remoteKeyDao().clearAll()
}
// 保存数据
db.articleDao().insertAll(articles)
// 保存远程键:记录每个 item 对应的网络页码
db.remoteKeyDao().insertAll(
articles.map { article ->
RemoteKey(
articleId = article.id,
prevPage = if (page > 1) page - 1 else null,
currentPage = page,
nextPage = if (!endOfPaginationReached) page + 1 else null
)
}
)
}

MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (e: IOException) {
MediatorResult.Error(e)
} catch (e: HttpException) {
MediatorResult.Error(e)
}
}

/**
* 获取距离当前列表中心位置最近的 RemoteKey
*/
private suspend fun getRemoteKeyClosestToCurrentPosition(
state: PagingState<Int, Article>
): RemoteKey? {
return state.anchorPosition?.let { position ->
state.closestItemToPosition(position)?.id?.let { id ->
db.remoteKeyDao().getRemoteKeyByArticleId(id)
}
}
}

/**
* 获取列表中第一项的 RemoteKey
*/
private suspend fun getRemoteKeyForFirstItem(
state: PagingState<Int, Article>
): RemoteKey? {
return state.pages.firstOrNull {
it.data.isNotEmpty()
}?.data?.firstOrNull()?.let { article ->
db.remoteKeyDao().getRemoteKeyByArticleId(article.id)
}
}

/**
* 获取列表中最后一项的 RemoteKey
*/
private suspend fun getRemoteKeyForLastItem(
state: PagingState<Int, Article>
): RemoteKey? {
return state.pages.lastOrNull {
it.data.isNotEmpty()
}?.data?.lastOrNull()?.let { article ->
db.remoteKeyDao().getRemoteKeyByArticleId(article.id)
}
}
}

RemoteKey 实体设计:

@Entity(tableName = "remote_keys")
data class RemoteKey(
@PrimaryKey val articleId: Int,
val prevPage: Int?,
val currentPage: Int,
val nextPage: Int?
)

@Dao
interface RemoteKeyDao {
@Query("SELECT * FROM remote_keys WHERE articleId = :id")
suspend fun getRemoteKeyByArticleId(id: Int): RemoteKey?

@Query("DELETE FROM remote_keys")
suspend fun clearAll()

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(remoteKeys: List<RemoteKey>)
}

5.3 LoadType 触发时机

LoadType 触发场景 说明
REFRESH PagingData 首次加载、下拉刷新、adapter.refresh() 从头开始加载,应清除旧缓存
PREPEND 列表顶部上拉、时间线向上滚动 加载比当前更早的数据(仅双向滚动场景使用)
APPEND 滚动到底部、列表数据不足 加载下一页数据(最常见的场景)

5.4 initialize() 方法

initialize() 在 RemoteMediator 首次被使用时调用,返回 InitializeAction

  • InitializeAction.LAUNCH_INITIAL_REFRESH:立即触发一次 REFRESH 加载。适用于本地缓存为空或过期的场景。
  • InitializeAction.SKIP_INITIAL_REFRESH:跳过首次网络刷新,直接使用本地已有数据。适用于本地已有有效缓存的场景。

关键实现细节:initialize() 在 Room 的数据库中执行(Room 的事务上下文),不要在其中执行网络请求 —— 它应该只检查本地缓存状态并返回操作指令。

六、完整示例:GitHub Repo 搜索

6.1 项目结构

.
├── data/
│ ├── local/
│ │ ├── AppDatabase.kt # Room Database
│ │ ├── RepoDao.kt # Repository DAO
│ │ └── RemoteKeyDao.kt # RemoteKey DAO(分页键缓存)
│ ├── remote/
│ │ └── GithubApiService.kt # Retrofit API 定义
│ ├── model/
│ │ ├── Repo.kt # 数据模型(Room Entity)
│ │ └── RemoteKey.kt # 分页键
│ └── repository/
│ ├── GithubRepository.kt # Repository 层
│ └── RepoRemoteMediator.kt # RemoteMediator
├── ui/
│ ├── RepoListViewModel.kt # ViewModel
│ ├── RepoListFragment.kt # Fragment
│ ├── RepoPagingAdapter.kt # PagingDataAdapter
│ └── RepoLoadStateAdapter.kt # LoadStateAdapter
└── di/
└── DataModule.kt # Hilt DI Module

6.2 数据层实现

// ==================== Model ====================

@Entity(tableName = "repos")
data class Repo(
@PrimaryKey val id: Int,
val name: String,
val fullName: String,
val description: String?,
val stars: Int,
val forks: Int,
val language: String?,
val ownerName: String,
val ownerAvatar: String,
val htmlUrl: String,
val createdAt: String,
val updatedAt: String
)

// ==================== DAO ====================

@Dao
interface RepoDao {
@Query("SELECT * FROM repos ORDER BY stars DESC")
fun getAllRepos(): PagingSource<Int, Repo>

@Query("SELECT * FROM repos WHERE name LIKE '%' || :query || '%' OR description LIKE '%' || :query || '%' ORDER BY stars DESC")
fun searchRepos(query: String): PagingSource<Int, Repo>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(repos: List<Repo>)

@Query("DELETE FROM repos")
suspend fun clearAll()

@Query("SELECT MAX(updatedAt) FROM repos")
suspend fun getLastUpdated(): String?
}

// ==================== Database ====================

@Database(entities = [Repo::class, RemoteKey::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun repoDao(): RepoDao
abstract fun remoteKeyDao(): RemoteKeyDao
}

// ==================== API ====================

interface GithubApiService {
@GET("search/repositories")
suspend fun searchRepos(
@Query("q") query: String,
@Query("page") page: Int,
@Query("per_page") perPage: Int,
@Query("sort") sort: String = "stars"
): Response<GithubSearchResponse>
}

data class GithubSearchResponse(
@Json(name = "total_count") val totalCount: Int,
@Json(name = "items") val items: List<GithubRepo>
)

// 注意:API 返回的模型和 Room Entity 应分开定义
data class GithubRepo(
@Json(name = "id") val id: Int,
@Json(name = "name") val name: String,
@Json(name = "full_name") val fullName: String,
@Json(name = "description") val description: String?,
@Json(name = "stargazers_count") val stars: Int,
@Json(name = "forks_count") val forks: Int,
@Json(name = "language") val language: String?,
@Json(name = "owner") val owner: GithubOwner,
@Json(name = "html_url") val htmlUrl: String,
@Json(name = "created_at") val createdAt: String,
@Json(name = "updated_at") val updatedAt: String,
) {
fun toEntity(): Repo = Repo(
id = id,
name = name,
fullName = fullName,
description = description,
stars = stars,
forks = forks,
language = language,
ownerName = owner.login,
ownerAvatar = owner.avatarUrl,
htmlUrl = htmlUrl,
createdAt = createdAt,
updatedAt = updatedAt
)
}

// ==================== RemoteMediator ====================

@OptIn(ExperimentalPagingApi::class)
class RepoRemoteMediator(
private val db: AppDatabase,
private val api: GithubApiService,
private val query: String
) : RemoteMediator<Int, Repo>() {

override suspend fun load(
loadType: LoadType,
state: PagingState<Int, Repo>
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(true)
LoadType.APPEND -> {
val lastItem = state.lastItemOrNull()
?: return MediatorResult.Success(true)
// 从 RemoteKey 表获取当前最后一项的页码
val remoteKey = db.remoteKeyDao().getRemoteKeyByRepoId(lastItem.id)
remoteKey?.nextPage ?: return MediatorResult.Success(true)
}
}

return try {
val response = api.searchRepos(
query = query,
page = page,
perPage = state.config.pageSize
)

if (!response.isSuccessful) {
return MediatorResult.Error(HttpException(response))
}

val body = response.body()!!
val repos = body.items.map { it.toEntity() }
val endOfPagination = body.items.size < state.config.pageSize

db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.repoDao().clearAll()
db.remoteKeyDao().clearAll()
}

db.repoDao().insertAll(repos)
db.remoteKeyDao().insertAll(
repos.map { repo ->
RemoteKey(
repoId = repo.id,
nextPage = if (!endOfPagination) page + 1 else null
)
}
)
}

MediatorResult.Success(endOfPaginationReached = endOfPagination)
} catch (e: IOException) {
MediatorResult.Error(e)
} catch (e: HttpException) {
MediatorResult.Error(e)
}
}
}

// ==================== Repository ====================

class GithubRepository @Inject constructor(
private val db: AppDatabase,
private val api: GithubApiService
) {
fun searchRepos(query: String): Flow<PagingData<Repo>> {
return Pager(
config = PagingConfig(
pageSize = 30,
prefetchDistance = 15,
enablePlaceholders = false
),
remoteMediator = RepoRemoteMediator(db, api, query),
pagingSourceFactory = {
if (query.isBlank()) {
db.repoDao().getAllRepos()
} else {
db.repoDao().searchRepos(query)
}
}
).flow
}
}

6.3 UI 层实现

// ==================== ViewModel ====================

@HiltViewModel
class RepoListViewModel @Inject constructor(
private val repository: GithubRepository
) : ViewModel() {

private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()

// 搜索词变化时,flatMapLatest 自动取消旧流,创建新 Pager
val repos = _query
.debounce(300) // 防抖:用户停止输入 300ms 后才搜索
.flatMapLatest { query ->
repository.searchRepos(query)
}
.cachedIn(viewModelScope) // 缓存到 ViewModel scope

fun onQueryChanged(newQuery: String) {
_query.value = newQuery
}
}

// ==================== Adapter ====================

class RepoPagingAdapter(
private val onItemClick: (Repo) -> Unit
) : PagingDataAdapter<Repo, RepoPagingAdapter.RepoViewHolder>(RepoDiffCallback) {

companion object {
val RepoDiffCallback = object : DiffUtil.ItemCallback<Repo>() {
override fun areItemsTheSame(oldItem: Repo, newItem: Repo) =
oldItem.id == newItem.id

override fun areContentsTheSame(oldItem: Repo, newItem: Repo) =
oldItem == newItem
}
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RepoViewHolder {
val binding = ItemRepoBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return RepoViewHolder(binding, onItemClick)
}

override fun onBindViewHolder(holder: RepoViewHolder, position: Int) {
val repo = getItem(position)
holder.bind(repo)
}

class RepoViewHolder(
private val binding: ItemRepoBinding,
private val onItemClick: (Repo) -> Unit
) : RecyclerView.ViewHolder(binding.root) {

fun bind(repo: Repo?) {
repo?.let {
binding.tvRepoName.text = it.fullName
binding.tvDescription.text = it.description ?: ""
binding.tvStars.text = "${it.stars}"
binding.tvLanguage.text = it.language ?: "Unknown"
binding.root.setOnClickListener { _ -> onItemClick(it) }
}
}
}
}

// ==================== Fragment ====================

@AndroidEntryPoint
class RepoListFragment : Fragment() {
private val viewModel: RepoListViewModel by viewModels()
private lateinit var adapter: RepoPagingAdapter
private lateinit var binding: FragmentRepoListBinding

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentRepoListBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

adapter = RepoPagingAdapter { repo ->
// 点击跳转到详情页
findNavController().navigate(
RepoListFragmentDirections.actionRepoListToRepoDetail(repo.id)
)
}

val headerAdapter = RepoLoadStateAdapter { adapter.retry() }
val footerAdapter = RepoLoadStateAdapter { adapter.retry() }

binding.recyclerView.adapter = adapter
.withLoadStateHeader(headerAdapter)
.withLoadStateFooter(footerAdapter)

// 收集 PagingData 并提交给 adapter
viewLifecycleOwner.lifecycleScope.launch {
viewModel.repos.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}

// 监控加载状态
viewLifecycleOwner.lifecycleScope.launch {
adapter.loadStateFlow.collectLatest { loadStates ->
val refreshState = loadStates.refresh

binding.swipeRefresh.isRefreshing = refreshState is LoadState.Loading

if (refreshState is LoadState.Error && adapter.itemCount == 0) {
binding.tvEmpty.text = "加载失败:${(refreshState as LoadState.Error).error.message}"
binding.tvEmpty.isVisible = true
} else if (adapter.itemCount == 0) {
binding.tvEmpty.text = "暂无结果"
binding.tvEmpty.isVisible = true
} else {
binding.tvEmpty.isVisible = false
}
}
}

// 下拉刷新
binding.swipeRefresh.setOnRefreshListener {
adapter.refresh()
}

// 搜索框
binding.etSearch.doAfterTextChanged { text ->
viewModel.onQueryChanged(text.toString())
}
}
}

七、高级场景

7.1 数据变换与映射

PagingData 支持通过 map 操作符进行转换:

val uiModelFlow = pagingDataFlow.map { pagingData ->
pagingData.map { repo ->
RepoUiModel(
id = repo.id,
displayName = repo.fullName,
starCountFormatted = formatNumber(repo.stars),
// ... 其他 UI 需要的字段
)
}
}

7.2 列表项单独操作(收藏/删除)

class RepoListViewModel @Inject constructor(
private val db: AppDatabase,
private val api: GithubApiService
) : ViewModel() {

fun toggleFavorite(repoId: Int) {
viewModelScope.launch {
db.repoDao().toggleFavorite(repoId)
// Room 自动追踪数据变更,PagingDataAdapter 通过 PagingSource 收到更新
// 无需手动 notifyItemChanged()
}
}
}

有时候需要在分页列表上方或下方添加固定的非分页项(如广告 banner、统计卡片)。推荐使用 ConcatAdapter

val concatAdapter = ConcatAdapter(
headerAdapter, // 固定 Header(非 PagingDataAdapter)
pagingAdapter, // 分页内容
footerAdapter // 固定 Footer(非 PagingDataAdapter)
)
binding.recyclerView.adapter = concatAdapter

7.4 分页数据缓存策略

Paging 3 通过 cachedIn() 提供了灵活的缓存机制:

缓存位置 Scope 场景
cachedIn(viewModelScope) ViewModel 中存在,配置变更时存活 配置变更时不重新加载
cachedIn(Lifecycle.State.STARTED) 仅在前台缓存,back stack 时释放 节省内存
不调用 cachedIn() 每次 collect 都创建新 PagingData 不推荐

八、Paging 2 vs Paging 3 对比

特性 Paging 2 Paging 3
异步 API RxJava / LiveData Kotlin Coroutines / Flow
数据容器 PagedList PagingData
数据源抽象 DataSource.Factory(三种子类) PagingSource(单一抽象)
分页键管理 手动在 DataSource 中管理 getRefreshKey() 自动处理
加载状态 NetworkState(需手动创建) LoadState(框架内置)
错误重试 无内置机制,需手动实现 adapter.retry() 一键重试
头/尾状态 手动处理 ViewType withLoadStateHeader/Footer()
分隔符 手动在 Adapter 中插入 insertSeparators API
本地+远程 BoundaryCallback RemoteMediator(更强大)
迁移状态 已停止更新 活跃开发中

Paging 2 到 Paging 3 的迁移要点:

  1. DataSource.Factory 替换为 PagingSource
  2. PagedList 替换为 Flow<PagingData>
  3. PagedListAdapter 替换为 PagingDataAdapter
  4. BoundaryCallback 替换为 RemoteMediator
  5. LivePagedListBuilder / RxPagedListBuilder 替换为 Pager

面试常考问题

Q1:PagingSource 的 getRefreshKey() 作用?

当 PagingData 失效(如数据库更新、用户下拉刷新)需要重建 PagingSource 时,getRefreshKey() 返回重新加载的起始 key。它接收当前 PagingState(含 anchorPosition),通过 closestPageToPosition() 找到用户当前可见区域对应的页面,返回其 prevKey 或 nextKey,确保刷新后列表位置不错乱。如果不设置 refreshKey,每次刷新都会回到第一页。

Q2:Paging 2 vs Paging 3 主要区别?

Paging 3 全面拥抱 Kotlin Coroutines/Flow,去除了 PagedList/DataSource 等旧 API。加载状态通过 loadStateFlow 统一暴露,支持 retry 操作。Paging 2 基于 RxJava/LiveData,需手动管理 DataSource.Factory 三种类型(ItemKeyedDataSource、PageKeyedDataSource、PositionalDataSource),而 Paging 3 统一为 PagingSource。

Q3:RemoteMediator 中三种 LoadType 分别何时触发?

  • REFRESH:PagingData 首次加载时、手动调用 adapter.refresh() 时、initialize() 返回 LAUNCH_INITIAL_REFRESH 时触发
  • PREPEND:用户向列表顶部滚动,触发加载”更早”的数据时触发(常用于时间线类型应用的向上翻页)
  • APPEND:用户向列表底部滚动,到达 prefetchDistance 阈值时触发

源码定义在 androidx.paging.LoadType 枚举中。

Q4:cachedIn() 的作用?

cachedIn(scope)Flow<PagingData> 在指定 CoroutineScope 中缓存,避免以下场景的重复加载:

  1. 配置变更(如屏幕旋转):ViewModel 的 scope 存活,PagingData 不会重建
  2. 多个观察者:如果多个 Fragment 观察同一个 PagingData 流,cachedIn 确保只加载一次
  3. 多次 collect:Flow 的冷流特性意味着每次 collect 都会触发重新加载,cachedIn 将其转换为热流

最佳实践是在 ViewModel 中 cachedIn(viewModelScope),确保数据在 ViewModel 存活期间只需加载一次。

Q5:Room 的 PagingSource 如何自动感知数据变更?

Room 使用 InvalidationTracker 监控数据变更。当 DAO 返回 PagingSource 时,Room 会在内部注册数据库表的观察者。一旦该表的任何数据被 INSERT/UPDATE/DELETE,InvalidationTracker 会通知 PagingSource 调用 invalidate(),触发 PagingData 的重新加载。整个链路是自动的,开发者无需手动处理。

Q6:何时不使用 Paging 库?

Paging 库并非”一刀切”的解决方案。以下场景不适合:

  1. 数据量很小(< 100 条),直接使用 ListAdapter + List 更简单
  2. 需要 Cell 拖拽排序(Paging 与 ItemTouchHelper 配合困难)
  3. 需要精确控制每一项的动画(Paging 的 DiffUtil 更新粒度是页面级别的,可能与精细动画冲突)
  4. 数据源不支持分页查询(只能全量加载的场景下,Paging 的价值大打折扣)
打赏
  • 微信
  • 支付宝

评论