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,迁移时得重写不少代码。

Paging3 核心架构图 PagingSource 定义数据加载逻辑 Repository 提供PagingData流 ViewModel 暴露LiveData<PagingData> Activity/Fragment 观察LiveData,提交PagingData 数据流方向:PagingSource → Repository → ViewModel → UI Paging3自动处理:页码管理、缓存、重试、加载状态

20.2 LiveData与PagingData如何结合?

这是本章的重点。PagingData本身是一个冷数据流,它需要被收集才能触发加载。而LiveData正好可以充当这个「桥梁」。

具体做法很简单:在ViewModel里,用Pager对象生成一个Flow<PagingData>,然后通过.asLiveData()扩展函数转成LiveData。UI层直接观察这个LiveData,拿到PagingData后提交给Adapter。

我在项目中遇到过一个问题:如果直接暴露Flow,有些同事不熟悉协程,容易忘记在合适的生命周期里collect。用LiveData反而更直观,尤其是和DataBinding配合时。

💡 我的建议:如果你的项目已经全面拥抱协程,直接用Flow更灵活。但如果团队里还有新手,或者项目历史代码大量使用LiveData,那就用LiveData包装一层,降低心智负担。

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根据配置自动算出来的,你不用手动传每页大小。另外,prevKeynextKey决定了加载方向。我早期写的时候把这两个搞反了,结果列表只能往下翻不能往上翻,排查了半天。

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()
    }
}

这里要区分refreshappendrefresh是首次加载或下拉刷新,append是加载更多。我习惯在列表底部显示一个加载进度条,就是监听append状态实现的。

⚠️ 注意:如果你同时监听refresh和append,记得处理状态冲突。比如网络错误时,refresh显示错误页面,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 避坑指南

最后,分享几个我实战中遇到的坑:

  • 列表闪烁:如果PagingConfigenablePlaceholders设为true,但数据源返回的totalCount不准确,列表会频繁刷新。我建议设为false,用加载状态来展示占位。
  • 内存泄漏:记得在ViewModel里用cachedIn(viewModelScope),否则PagingData会一直持有Activity引用。
  • 数据重复:检查你的API是否返回了重复数据。Paging3依赖DiffUtil去重,但如果服务端返回了相同id但内容不同的数据,DiffUtil会认为它们是不同的。
  • 首次加载慢:把initialLoadSize设大一点,比如pageSize * 2。用户打开页面时,能更快看到内容。

好了,这一章的内容就到这里。Paging3 + LiveData的组合,说白了就是帮你把分页逻辑标准化了。你只需要关注数据源和UI展示,中间那些页码管理、缓存、重试的脏活,Paging3全包了。

下一章我们会深入Paging3的进阶用法,包括RemoteMediator、网络+数据库双源分页等。但今天的内容已经足够你应付大部分无限滚动场景了。去试试吧,有问题随时交流。


公众号:蓝海资料掘金营,微信deep3321