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 } }
这种手动实现存在大量问题:
滚动状态检测容易出错,导致重复加载或漏加载
加载状态管理分散在 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>() { override suspend fun load (params: LoadParams <Int >) : LoadResult<Int , Article> { val page = params.key ?: 1 val pageSize = params.loadSize return try { val response = api.searchArticles( query = query, page = page, pageSize = pageSize ) 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) { 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 开始加载”才能保持列表位置不乱跳。
override fun getRefreshKey (state: PagingState <Int , Article>) : Int ? { 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) 方法找到包含指定位置的页面。prevKey 和 nextKey 是你在 LoadResult.Page 中返回的键。
2.4 支持搜索/过滤的 PagingSource 当用户输入搜索关键词时,PagingSource 需要重建:
class SearchArticlePagingSource ( private val api: ApiService, private val queryFlow: Flow<String> ) : PagingSource<Int , Article>() { } class SearchViewModel ( private val api: ApiService ) : ViewModel() { private val query = MutableStateFlow("" ) val pagingDataFlow = query.flatMapLatest { searchQuery -> Pager( config = PagingConfig(pageSize = 20 ), pagingSourceFactory = { ArticlePagingSource(api, searchQuery) } ).flow }.cachedIn(viewModelScope) }
3.1 PagingData —— 不可变的数据流 PagingData 是 Paging 3 中承载分页数据的不可变容器。它是只读的,一旦生成就不能被修改 —— UI 层只能通过 submitData() 提交给 Adapter,不能直接操作 PagingData 内部数据。
val pager = Pager( config = PagingConfig(pageSize = 20 ), pagingSourceFactory = { dao.getAllArticles() } ) val pagingDataFlow: Flow<PagingData<Article>> = pager.flow.cachedIn(viewModelScope)
PagingData 的重要特性:
不可变性 :一旦通过 cachedIn() 收集,PagingData 是不可变的。这意味着多个观察者收到的是同一份数据,不会出现数据不一致。
分离关注点 :PagingData 不关心数据来自哪里(网络还是本地)、如何加载(PagingSource 还是 RemoteMediator)。UI 层只知道”这里有分页数据”。
Flow 集成 :PagingData 通过 Flow 暴露,天然支持 Kotlin 协程的取消机制。
cachedIn() 关键作用 :cachedIn(viewModelScope) 确保数据在 ViewModel 的 scope 中缓存,即使 UI 层重新 subscribe(如配置变更),也不会触发重新加载。
3.2 PagingConfig 深度配置 val config = PagingConfig( pageSize = 20 , prefetchDistance = 20 , enablePlaceholders = false , maxSize = 200 , 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 操作符组合使用,实现高级场景:
viewModelScope.launch { pagingDataFlow.collectLatest { pagingData -> adapter.submitData(pagingData) } } adapter.refresh() 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) val localFlow = dao.getAllArticles() val remoteFlow = ...
四、PagingDataAdapter:UI 层集成 4.1 PagingDataAdapter 基本用法 class ArticlePagingAdapter : PagingDataAdapter <Article, ArticleViewHolder >(ArticleDiffCallback) { 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 } 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) 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.launch 或 repeatOnLifecycle 调用。
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 } } } } } 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) 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() } } } }
PagingSource 只能从单一数据源加载。但在实际应用中,我们通常需要这样的模式:
用户打开应用 → 先展示本地缓存数据 → 后台同步网络最新数据 → 回写本地数据库 → 通知 UI 更新
这就是 RemoteMediator 的价值:它协调网络和本地数据库两个数据源,以本地数据库为 Single Source of Truth。
┌──────────┐ load() ┌────────────────┐ 查询 ┌──────────┐ │ UI │ ──────────→ │ PagingSource │ ────────→ │ Room │ │ │ │ (from Room) │ ←──────── │ DAO │ │ │ └────────────────┘ 返回数据 └──────────┘ │ │ ↑ │ │ ┌─────────────────────┘ │ │ │ 写入 │ │ ┌──────┴─────────┐ │ │ │ RemoteMediator │ │ │ │ load() │ │ │ └──────┬─────────┘ │ │ │ 网络请求 │ │ ┌──────┴─────────┐ │ │ │ Retrofit/API │ │ │ └────────────────┘ └──────────┘
@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 } } override suspend fun load ( loadType: LoadType , state: PagingState <Int , Article> ) : MediatorResult { return try { 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 ) } } val response = api.searchArticles(query, page, state.config.pageSize) ?: return MediatorResult.Success(endOfPaginationReached = true ) val articles = response.items val endOfPaginationReached = response.currentPage >= response.totalPages db.withTransaction { if (loadType == LoadType.REFRESH) { db.articleDao().clearAll() db.remoteKeyDao().clearAll() } db.articleDao().insertAll(articles) 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) } } private suspend fun getRemoteKeyClosestToCurrentPosition ( state: PagingState <Int , Article> ) : RemoteKey? { return state.anchorPosition?.let { position -> state.closestItemToPosition(position)?.id?.let { id -> db.remoteKeyDao().getRemoteKeyByArticleId(id) } } } 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) } } 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 数据层实现 @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 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(entities = [Repo::class, RemoteKey::class], version = 1, exportSchema = false) abstract class AppDatabase : RoomDatabase () { abstract fun repoDao () : RepoDao abstract fun remoteKeyDao () : RemoteKeyDao } 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> ) 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 ) } @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 ) 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) } } } 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 层实现 @HiltViewModel class RepoListViewModel @Inject constructor ( private val repository: GithubRepository ) : ViewModel() { private val _query = MutableStateFlow("" ) val query: StateFlow<String> = _query.asStateFlow() val repos = _query .debounce(300 ) .flatMapLatest { query -> repository.searchRepos(query) } .cachedIn(viewModelScope) fun onQueryChanged (newQuery: String ) { _query.value = newQuery } } 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) } } } } } @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) 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), ) } }
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) } } }
有时候需要在分页列表上方或下方添加固定的非分页项(如广告 banner、统计卡片)。推荐使用 ConcatAdapter:
val concatAdapter = ConcatAdapter( headerAdapter, pagingAdapter, footerAdapter ) 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 的迁移要点:
将 DataSource.Factory 替换为 PagingSource
将 PagedList 替换为 Flow<PagingData>
将 PagedListAdapter 替换为 PagingDataAdapter
将 BoundaryCallback 替换为 RemoteMediator
将 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 中缓存,避免以下场景的重复加载:
配置变更(如屏幕旋转):ViewModel 的 scope 存活,PagingData 不会重建
多个观察者:如果多个 Fragment 观察同一个 PagingData 流,cachedIn 确保只加载一次
多次 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 库并非”一刀切”的解决方案。以下场景不适合:
数据量很小(< 100 条),直接使用 ListAdapter + List 更简单
需要 Cell 拖拽排序(Paging 与 ItemTouchHelper 配合困难)
需要精确控制每一项的动画(Paging 的 DiffUtil 更新粒度是页面级别的,可能与精细动画冲突)
数据源不支持分页查询(只能全量加载的场景下,Paging 的价值大打折扣)