20. LiveData与Paging3:打造无限滚动列表
各位同学,今天我们来聊聊分页加载。说实话,这是移动开发里最常遇到的场景之一——你刷朋友圈、刷微博、刷商品列表,本质上都是无限滚动。我早期做项目时,自己手写过翻页逻辑,那叫一个痛苦。后来Google推出了Paging3,配合LiveData,简直不要太爽。
这一章,我会带你从零搭建一个无限滚动列表。咱们不讲虚的,直接上干货。
20.1 Paging3是什么?
Paging3是Jetpack家族的一员。它的核心任务就一个:帮你优雅地加载大量数据。
你想想看,如果一次性加载10000条数据,内存直接爆掉。Paging3的做法是——按需加载,滑到哪加载到哪。它内部帮你处理了线程切换、缓存、重试、加载状态等一堆脏活累活。
我个人习惯把Paging3理解成一个「数据管道」。你只需要告诉它数据从哪来,它自动帮你分页、递送、刷新。
- PagingSource:数据源,定义如何加载每一页数据
- PagingConfig:分页配置,比如每页大小、预取距离
- PagingData:分页数据流,UI层直接观察这个
- PagingDataAdapter:专门适配PagingData的RecyclerView.Adapter
说白了,Paging3就是帮你把「加载更多」这件事标准化了。你不用再自己写页码管理、加载状态判断、重试逻辑。嗯,这里要注意,Paging3和Paging2不兼容,如果你之前用过Paging2,迁移时得重写不少代码。
20.2 LiveData与PagingData如何结合?
这是本章的重点。PagingData本身是一个冷数据流,它需要被收集才能触发加载。而LiveData正好可以充当这个「桥梁」。
具体做法很简单:在ViewModel里,用Pager对象生成一个Flow<PagingData>,然后通过.asLiveData()扩展函数转成LiveData。UI层直接观察这个LiveData,拿到PagingData后提交给Adapter。
我在项目中遇到过一个问题:如果直接暴露Flow,有些同事不熟悉协程,容易忘记在合适的生命周期里collect。用LiveData反而更直观,尤其是和DataBinding配合时。
20.3 实战:搭建无限滚动列表
好,理论说完了,咱们直接写代码。我会一步步带你实现一个完整的无限滚动列表。
20.3.1 添加依赖
首先,在build.gradle里加上Paging3的依赖:
// 请根据实际版本号调整
implementation "androidx.paging:paging-runtime-ktx:3.1.1"
// 如果要用LiveData扩展,这个就够了,paging-runtime-ktx已经包含
20.3.2 定义数据源
假设我们从网络API获取分页数据。API返回的格式大概是:
{
"data": [...],
"next_page": 2,
"has_more": true
}
对应的PagingSource长这样:
class ArticlePagingSource(
private val api: ArticleApi
) : PagingSource<Int, Article>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
return try {
val page = params.key ?: 1
val response = api.getArticles(page, params.loadSize)
LoadResult.Page(
data = response.data,
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, Article>): Int? {
// 刷新时,尽量回到当前页附近
return state.anchorPosition?.let { anchorPos ->
state.closestPageToPosition(anchorPos)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPos)?.nextKey?.minus(1)
}
}
}
这里有个细节:params.loadSize是Paging3根据配置自动算出来的,你不用手动传每页大小。另外,prevKey和nextKey决定了加载方向。我早期写的时候把这两个搞反了,结果列表只能往下翻不能往上翻,排查了半天。
20.3.3 在ViewModel中暴露LiveData
class ArticleViewModel(private val api: ArticleApi) : ViewModel() {
val articles: LiveData<PagingData<Article>> = Pager(
config = PagingConfig(
pageSize = 20, // 每页20条
enablePlaceholders = false, // 不显示占位符
initialLoadSize = 40 // 首次加载40条,让用户快速看到内容
),
pagingSourceFactory = { ArticlePagingSource(api) }
).flow
.cachedIn(viewModelScope)
.asLiveData()
}
注意cachedIn(viewModelScope)这行。它的作用是缓存PagingData,避免旋转屏幕时重新加载。我曾经没加这行,结果每次转屏都重新请求网络,被测试骂惨了。
20.3.4 UI层观察并展示
在Fragment里,观察LiveData并提交数据:
class ArticleFragment : Fragment() {
private val viewModel: ArticleViewModel by viewModels()
private val adapter = ArticlePagingAdapter()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView.adapter = adapter
viewModel.articles.observe(viewLifecycleOwner) { pagingData ->
adapter.submitData(lifecycle, pagingData)
}
}
}
这里有个坑:submitData的第一个参数要传lifecycle,而不是viewLifecycleOwner。传错了会导致数据不更新。嗯,我当初就踩过这个坑。
Adapter的定义也很简单,继承PagingDataAdapter即可:
class ArticlePagingAdapter :
PagingDataAdapter<Article, ArticleViewHolder>(ArticleDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
return ArticleViewHolder(
ItemArticleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
}
override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
class ArticleDiffCallback : 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
}
}
20.4 加载状态与错误处理
光能滚动还不够,用户需要知道加载状态。Paging3提供了CombinedLoadStates,你可以监听它来显示加载动画、错误提示、重试按钮。
adapter.addLoadStateListener { loadStates ->
when (loadStates.refresh) {
is LoadState.Loading -> showLoading()
is LoadState.Error -> showError((loadStates.refresh as LoadState.Error).error)
is LoadState.NotLoading -> hideLoading()
}
}
这里要区分refresh和append。refresh是首次加载或下拉刷新,append是加载更多。我习惯在列表底部显示一个加载进度条,就是监听append状态实现的。
20.5 下拉刷新与Paging3配合
下拉刷新是标配。Paging3和SwipeRefreshLayout配合很简单:
// 在Fragment中
swipeRefreshLayout.setOnRefreshListener {
adapter.refresh()
}
// 监听刷新状态
adapter.addLoadStateListener { loadStates ->
swipeRefreshLayout.isRefreshing = loadStates.refresh is LoadState.Loading
}
注意:adapter.refresh()会触发PagingSource的invalidate(),然后重新加载第一页。这个过程中,getRefreshKey()方法会被调用,用来决定刷新后定位到哪一页。
20.6 避坑指南
最后,分享几个我实战中遇到的坑:
- 列表闪烁:如果
PagingConfig的enablePlaceholders设为true,但数据源返回的totalCount不准确,列表会频繁刷新。我建议设为false,用加载状态来展示占位。 - 内存泄漏:记得在ViewModel里用
cachedIn(viewModelScope),否则PagingData会一直持有Activity引用。 - 数据重复:检查你的API是否返回了重复数据。Paging3依赖DiffUtil去重,但如果服务端返回了相同id但内容不同的数据,DiffUtil会认为它们是不同的。
- 首次加载慢:把
initialLoadSize设大一点,比如pageSize * 2。用户打开页面时,能更快看到内容。
好了,这一章的内容就到这里。Paging3 + LiveData的组合,说白了就是帮你把分页逻辑标准化了。你只需要关注数据源和UI展示,中间那些页码管理、缓存、重试的脏活,Paging3全包了。
下一章我们会深入Paging3的进阶用法,包括RemoteMediator、网络+数据库双源分页等。但今天的内容已经足够你应付大部分无限滚动场景了。去试试吧,有问题随时交流。
公众号:蓝海资料掘金营,微信deep3321