Paging3:从分页焦虑到优雅加载
做Android开发的朋友,谁没被列表分页折磨过?
我记得刚入行那会儿,自己写分页逻辑,ScrollListener、加载状态、数据拼接、网络重试……一套下来代码又臭又长。后来用了Paging2,好了一些,但依然有不少坑。直到Paging3出来,我才真正觉得——嗯,分页这件事,终于可以优雅地解决了。
今天我们就来聊聊Paging3。我会从架构讲起,带你一步步理解它的核心组件,最后用一个实战案例收尾。你想想看,掌握了这套东西,以后做列表加载是不是就轻松多了?
Paging3的整体架构
Paging3的架构其实不复杂,说白了就是三层:数据层、中间层、UI层。
核心思想:数据从哪里来(PagingSource),怎么传递(PagingData),怎么展示(PagingDataAdapter)。如果需要从网络和本地数据库混合加载,再加一个RemoteMediator。
我个人习惯把Paging3的架构画成下面这张图,你看一眼就明白了:
你看,数据从PagingSource出来,经过Pager包装成PagingData,最后交给PagingDataAdapter展示。整个过程是响应式的,你只需要关心数据源和UI展示,中间的分页逻辑Paging3全帮你搞定了。
PagingSource:数据从哪里来
PagingSource是Paging3的数据源接口。说白了,它就是告诉Paging3:你要的数据在哪儿,怎么按页取。
我项目中遇到过最典型的场景,就是从网络API分页加载用户列表。来看代码:
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 { anchorPos ->
state.closestPageToPosition(anchorPos)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPos)?.nextKey?.minus(1)
}
}
}
小提示:LoadParams.loadSize是Paging3根据屏幕尺寸和RecyclerView的item数量自动计算出来的。你不需要手动算每页多少条,Paging3会帮你优化。
这里有个关键点——getRefreshKey。为什么需要它?
嗯,当数据刷新时(比如下拉刷新),Paging3需要知道刷新后应该定位到哪一页。如果不实现这个方法,刷新后会回到第一页,用户体验很差。我曾经踩过这个坑,用户刷着刷着突然跳回顶部,被吐槽了好几次。
PagingData:数据怎么传递
PagingData是一个不可变的数据容器,它持有分页数据的快照。你不需要直接操作它,只需要通过Pager来创建。
Pager是Paging3的入口,它把PagingSource和配置信息组合起来,生成一个Flow<PagingData>:
val pager = Pager(
config = PagingConfig(
pageSize = 20,
enablePlaceholders = false,
initialLoadSize = 40
),
pagingSourceFactory = { UserPagingSource(api, query) }
).flow.cachedIn(viewModelScope)
注意:cachedIn()非常重要!如果不调用这个方法,每次收集Flow都会重新创建PagingData,导致重复加载。我见过有人忘了加,结果列表每次旋转屏幕都重新请求网络。
PagingConfig里几个参数我解释一下:
- pageSize:每页加载多少条。这个根据你的业务来,我一般设20。
- enablePlaceholders:是否显示占位符。如果设为true,列表会预留空位,加载完成后填充。设为false则不会显示占位,体验更流畅。
- initialLoadSize:首次加载多少条。通常是pageSize的两倍,让用户第一眼看到更多内容。
PagingDataAdapter:数据怎么展示
PagingDataAdapter是RecyclerView.Adapter的子类,专门用来消费PagingData。它的用法和普通Adapter很像,但多了几个回调:
class UserAdapter : PagingDataAdapter<User, 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(
ItemUserBinding.inflate(LayoutInflater.from(parent.context), 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
}
}
在Activity或Fragment中,这样使用:
val adapter = UserAdapter()
viewLifecycleOwner.lifecycleScope.launch {
viewModel.pagingData.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
binding.recyclerView.adapter = adapter
你看,代码是不是很简洁?不需要手动管理加载更多、不需要监听滚动位置,PagingDataAdapter会自动处理这些。
RemoteMediator:网络+数据库的完美配合
如果你的App同时使用网络和本地数据库(比如Room),RemoteMediator就派上用场了。它的作用是:当本地数据不够时,自动从网络加载更多,并保存到数据库。
我做过一个新闻App,就是用RemoteMediator实现的离线优先策略:
@OptIn(ExperimentalPagingApi::class)
class NewsRemoteMediator(
private val api: NewsApi,
private val db: NewsDatabase
) : RemoteMediator<Int, NewsEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, NewsEntity>
): MediatorResult {
return try {
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)
}
}
val response = api.getNews(page, state.config.pageSize)
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.newsDao().clearAll()
}
db.newsDao().insertAll(response.news.map { it.toEntity(page) })
}
MediatorResult.Success(endOfPaginationReached = response.news.isEmpty())
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
}
关键点:RemoteMediator的load方法会根据LoadType决定加载行为。REFRESH是刷新,PREPEND是向前加载(一般用不到),APPEND是向后加载更多。我建议你在APPEND时从数据库最后一条记录获取页码,而不是从网络状态判断,这样更可靠。
实战:列表分页加载
好了,理论讲完了,我们来做一个完整的实战。假设我们要做一个GitHub用户搜索列表,支持分页加载。
第一步,定义数据层:
// API接口
interface GitHubApi {
@GET("search/users")
suspend fun searchUsers(
@Query("q") query: String,
@Query("page") page: Int,
@Query("per_page") perPage: Int
): GitHubResponse
}
// PagingSource
class GitHubPagingSource(
private val api: GitHubApi,
private val query: String
) : PagingSource<Int, GitHubUser>() {
// ... 实现同上
}
第二步,ViewModel中创建Pager:
class SearchViewModel : ViewModel() {
private val api = Retrofit.Builder()
.baseUrl("https://api.github.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(GitHubApi::class.java)
fun searchUsers(query: String): Flow<PagingData<GitHubUser>> {
return Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { GitHubPagingSource(api, query) }
).flow.cachedIn(viewModelScope)
}
}
第三步,UI层展示:
class SearchFragment : Fragment() {
private val viewModel: SearchViewModel by viewModels()
private val adapter = GitHubUserAdapter()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.recyclerView.adapter = adapter
// 搜索按钮点击
binding.searchButton.setOnClickListener {
val query = binding.searchInput.text.toString()
if (query.isNotBlank()) {
lifecycleScope.launch {
viewModel.searchUsers(query).collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
}
}
// 监听加载状态,显示加载动画或错误提示
lifecycleScope.launch {
adapter.loadStateFlow.collect { loadState ->
when (loadState.refresh) {
is LoadState.Loading -> showLoading()
is LoadState.Error -> showError((loadState.refresh as LoadState.Error).error)
is LoadState.NotLoading -> hideLoading()
}
}
}
}
}
避坑指南:我曾经在collectLatest中忘记处理生命周期,导致Fragment销毁后还在收集数据流。建议使用lifecycleScope和collectLatest,或者用repeatOnLifecycle来确保安全。
到这里,一个完整的Paging3分页加载就完成了。你想想看,是不是比你自己写ScrollListener+AsyncTaskLoader要清爽得多?
Paging3把分页的复杂性封装得很好,你只需要关注数据源和UI展示。中间的分页逻辑、加载状态管理、数据拼接,它全帮你处理了。我个人觉得,这是Android官方在列表加载方面做得最漂亮的一个组件。
好了,今天的分享就到这里。希望你在实际项目中能用上Paging3,让列表分页不再成为你的烦恼。