Paging 3 分页:从手动分页到声明式加载

简介: Paging 3 是 Android Jetpack 的声明式分页库,基于 Kotlin Flow 与协程,自动处理滚动加载、状态管理(加载中/失败/重试)、DiffUtil 增量更新及下拉刷新。支持网络、Room 或混合数据源,大幅简化手动分页的复杂逻辑,提升性能与可维护性。

Paging 3 分页:从手动分页到声明式加载

为什么需要分页

当列表数据量很大时,不可能一次性把所有数据加载到内存中。比如一个新闻列表有上万条数据,一次性加载会导致:

  1. 内存溢出。 上万条数据全部持有在内存中,列表滚动时还可能触发更多 GC。
  2. 首屏慢。 用户要等所有数据下载完才能看到页面,体验极差。
  3. 流量浪费。 用户可能只看前几条,后面的数据根本用不到。

分页加载的思路就是:先加载一小部分数据展示,用户滚动到底部时再自动加载更多。

手动分页的痛点

在 Paging 3 出现之前,很多开发者手动实现分页逻辑:

class NewsAdapter : RecyclerView.Adapter<NewsAdapter.VH>() {
    private val items = mutableListOf<News>()
    private var currentPage = 1
    private var isLoading = false
    private var hasMore = true

    fun loadMore() {
        if (isLoading || !hasMore) return
        isLoading = true
        api.getNews(page = currentPage).enqueue { list ->
            items.addAll(list)
            currentPage++
            hasMore = list.isNotEmpty()
            isLoading = false
            notifyDataSetChanged()
        }
    }
}

这段代码有几个典型问题:

  • 状态管理复杂。 isLoadinghasMorecurrentPage 等标志位容易出 bug,比如重复请求或漏掉加载。
  • 与 RecyclerView 耦合。 分页逻辑散落在 Adapter 和 Fragment/Activity 中,难以测试。
  • 错误处理不统一。 加载失败后的重试逻辑需要自己维护。
  • 不支持 DiffUtil。notifyDataSetChanged() 做全量刷新,性能差。

Paging 3 把这些繁琐的工作都封装好了。

Paging 3 是什么

Paging 3 是 Android Jetpack 中的分页加载库,核心特点:

  1. 基于 Kotlin Coroutines + Flow。 天然支持协程,数据流用 Flow<PagingData<T>> 表示。
  2. 自动加载下一页。 监听滚动位置,在合适的时机自动触发加载,不需要手动判断。
  3. 内置状态管理。 加载中、加载失败、加载完成等状态都有内置支持,包括 Footer 显示。
  4. 支持 DiffUtil。 PagingDataAdapter 内置 DiffUtil,自动做增量更新。
  5. 支持多种数据源。 网络、Room 数据库、或者两者结合都行。

添加依赖

build.gradle 中:

dependencies {
   
    implementation "androidx.paging:paging-runtime:3.3.0"
    // 如果只用 Compose
    implementation "androidx.paging:paging-compose:3.3.0"
}

核心组件

Paging 3 有三个核心组件:

组件 职责
PagingSource 数据源,定义"从哪里加载数据"以及"如何加载下一页/上一页"
Pager 配置器,设置页面大小、预加载阈值等参数,输出 Flow<PagingData<T>>
PagingDataAdapter RecyclerView 适配器,消费 PagingData 并展示列表

第一步:定义 PagingSource

PagingSource 是分页的数据来源。对于网络分页,它接收页码,返回一页数据:

class NewsPagingSource(
    private val api: NewsApi
) : PagingSource<Int, News>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, News> {
        val page = params.key ?: 1
        return try {
            val response = api.getNews(page = page, size = params.loadSize)
            LoadResult.Page(
                data = response.data,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (response.data.isEmpty()) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, News>): Int? {
        return state.anchorPosition?.let { pos ->
            state.closestPageToPosition(pos)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(pos)?.nextKey?.minus(1)
        }
    }
}

几个要点:

  • LoadParams.key 是页码,首次加载时为 null,我们用默认值 1。
  • prevKey / nextKey 告诉 Paging 框架上一页和下一页的 key。如果到头了就返回 null
  • getRefreshKey 用于刷新(如下拉刷新)后恢复滚动位置。

第二步:用 Pager 创建数据流

在 ViewModel 中用 PagerPagingSource 包装成 Flow<PagingData<T>>

class NewsViewModel(private val api: NewsApi) : ViewModel() {

    val newsFlow: Flow<PagingData<News>> = Pager(
        config = PagingConfig(
            pageSize = 20,           // 每页 20 条
            prefetchDistance = 5,    // 距离底部 5 条时预加载
            initialLoadSize = 40,    // 首次加载 40 条
            enablePlaceholders = false
        )
    ) {
        NewsPagingSource(api)
    }.flow.cachedIn(viewModelScope)
}

关键参数:

  • pageSize 每次加载的数据量。
  • prefetchDistance 距离末尾多少条时开始预加载下一页。
  • initialLoadSize 首次加载的数据量,通常设为 pageSize 的 2-3 倍,让用户看到更充实的首屏。
  • cachedIn(viewModelScope) 将 Flow 缓存在 ViewModel 的生命周期内,避免配置变化后重新加载。

第三步:用 PagingDataAdapter 展示

class NewsAdapter : PagingDataAdapter<News, NewsAdapter.VH>(DIFF_CALLBACK) {

    companion object {
        val DIFF_CALLBACK = object : DiffUtil.ItemCallback<News>() {
            override fun areItemsTheSame(old: News, new: News) = old.id == new.id
            override fun areContentsTheSame(old: News, new: News) = old == new
        }
    }

    inner class VH(view: View) : RecyclerView.ViewHolder(view) {
        val title: TextView = view.findViewById(R.id.tv_title)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_news, parent, false)
        return VH(view)
    }

    override fun onBindViewHolder(holder: VH, position: Int) {
        val news = getItem(position)
        news?.let { holder.title.text = it.title }
    }
}

在 Fragment 中收集数据:

viewLifecycleOwner.lifecycleScope.launch {
    viewModel.newsFlow.collectLatest { pagingData ->
        adapter.submitData(pagingData)
    }
}

collectLatest 会在新的 PagingData 到来时取消上一次收集,确保下拉刷新时旧数据被替换。

处理加载状态

Paging 3 内置了加载状态管理,可以通过 adapter.loadStateFlow 监听:

adapter.addLoadStateListener { loadState ->
    // 首次加载
    if (loadState.refresh is LoadState.Loading) {
        binding.progressBar.isVisible = true
    } else {
        binding.progressBar.isVisible = false
    }

    // 加载失败
    val error = when {
        loadState.refresh is LoadState.Error -> loadState.refresh as LoadState.Error
        loadState.append is LoadState.Error -> loadState.append as LoadState.Error
        else -> null
    }
    error?.let {
        Toast.makeText(requireContext(), "加载失败:${it.error.message}", Toast.LENGTH_SHORT).show()
    }
}

添加 Footer(加载中/失败/重试)

创建一个 LoadStateAdapter 作为 Footer:

class PagingLoadStateAdapter(
    private val retry: () -> Unit
) : LoadStateAdapter<PagingLoadStateAdapter.VH>() {

    inner class VH(view: View) : RecyclerView.ViewHolder(view) {
        val progressBar: ProgressBar = view.findViewById(R.id.progress)
        val errorMsg: TextView = view.findViewById(R.id.error_msg)
        val retryBtn: Button = view.findViewById(R.id.retry_btn)
    }

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): VH {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_load_state, parent, false)
        return VH(view).apply {
            retryBtn.setOnClickListener { retry() }
        }
    }

    override fun onBindViewHolder(holder: VH, loadState: LoadState) {
        holder.progressBar.isVisible = loadState is LoadState.Loading
        holder.errorMsg.isVisible = loadState is LoadState.Error
        holder.retryBtn.isVisible = loadState is LoadState.Error
    }
}

把它合并到主 Adapter 上:

val adapterWithFooter = adapter.withLoadStateFooter(
    PagingLoadStateAdapter { adapter.retry() }
)
recyclerView.adapter = adapterWithFooter

这样列表底部就会自动显示"加载中"或"加载失败 + 重试按钮"。

Room + Paging 3

当数据源是本地数据库时,Room 直接支持 Paging 3:

@Dao
interface NewsDao {
    @Query("SELECT * FROM news ORDER BY publishTime DESC")
    fun pagingSource(): PagingSource<Int, News>
}

ViewModel 中直接使用:

val newsFlow = Pager(
    config = PagingConfig(pageSize = 20)
) {
    newsDao.pagingSource()
}.flow.cachedIn(viewModelScope)

Room 会自动处理数据库变更后的自动刷新——当数据表有 INSERT/UPDATE/DELETE 操作时,PagingSource 会自动重新加载。

网络 + 本地联合分页

最常见的架构是"网络请求 → 写入 Room → 从 Room 读取展示"。Paging 3 提供了 RemoteMediator 来实现这种模式:

class NewsRemoteMediator(
    private val api: NewsApi,
    private val db: AppDatabase
) : RemoteMediator<Int, News>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, News>
    ): MediatorResult {
        val page = when (loadType) {
            LoadType.REFRESH -> 1
            LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
            LoadType.APPEND -> {
                val lastItem = state.lastItemOrNull() ?: return MediatorResult.Success(true)
                lastItem.pageIndex + 1
            }
        }

        return try {
            val response = api.getNews(page = page, size = state.config.pageSize)
            db.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    db.newsDao().clearAll()
                }
                db.newsDao().insertAll(response.data)
            }
            MediatorResult.Success(endOfPaginationReached = response.data.isEmpty())
        } catch (e: Exception) {
            MediatorResult.Error(e)
        }
    }
}

使用方式:

val newsFlow = Pager(
    config = PagingConfig(pageSize = 20),
    remoteMediator = NewsRemoteMediator(api, db)
) {
    db.newsDao().pagingSource()
}.flow.cachedIn(viewModelScope)

这样用户看到的始终是 Room 中的数据,网络请求在后台自动进行,离线时也能展示缓存数据。

下拉刷新

下拉刷新只需要让 adapter 重新提交数据:

binding.swipeRefresh.setOnRefreshListener {
    adapter.refresh()
}

// 监听刷新状态,自动关闭刷新动画
adapter.addLoadStateListener { loadState ->
    binding.swipeRefresh.isRefreshing = loadState.refresh is LoadState.Loading
}

adapter.refresh() 会触发 PagingSource 的 LoadType.REFRESH,从第一页重新开始加载。

常见问题

Q:列表位置在刷新后丢失了怎么办?

A:确保 getRefreshKey() 正确实现。Paging 3 会根据它恢复滚动位置。

Q:如何实现按时间分组(Header)?

A:在 PagingData 上做 map 转换,插入分组标题项:

newsFlow.map { pagingData ->
    pagingData.map { news -> NewsItem.Content(news) }
}.map { pagingData ->
    pagingData.insertSeparators { before, after ->
        if (before == null && after != null) {
            NewsItem.Header("最新")
        } else if (before != null && after != null && before.news.date != after.news.date) {
            NewsItem.Header(after.news.date)
        } else null
    }
}

Q:pageSize 设多大合适?

A:通常一屏能展示 5-8 条数据,pageSize 设为 15-25 比较合适。太大浪费流量,太小频繁请求。

小结

Paging 3 把分页加载这个常见需求标准化了。它的核心思路是:

  1. PagingSource 负责定义数据从哪来、怎么翻页。
  2. Pager 负责配置加载策略,输出 Flow<PagingData>
  3. PagingDataAdapter 负责消费数据并展示,自动处理 DiffUtil 和滚动监听。

配合 LoadStateAdapterRemoteMediator,可以覆盖从简单网络分页到"网络 + 本地缓存"联合分页的各种场景。如果你还在手动管理分页状态,是时候切换到 Paging 3 了。

相关文章
|
5天前
|
弹性计算 缓存 负载均衡
可用架构实践:阿里云支撑跑腿平台稳定运行,分账链解决交易结算核心痛点
同城跑腿、即时代办、即时配送属于典型的高并发、短时效、强交易、高波动业务场景:节假日、午晚高峰、暴雨暴雪天气会瞬间触发流量峰值,订单秒级涌入;同时每一笔订单都涉及用户、平台、入驻商户、跑腿个人师傅四方交易分润,业务链路复杂。 对于开发者而言,跑腿平台上线运营核心要解决两大问题:业务层高可用稳定承载 + 交易层合规自动化分账。 绝大多数成熟跑腿平台,均基于阿里云云原生架构实现业务稳定、弹性扩容、故障自愈,保障全时段服务可用;而针对行业专属的多方分账、高分润、逆向退款、合规清算难题,行业通用最优解是垂直场景专用系统——分账链。 本文从阿里云架构落地、业务痛点拆解、交易分账解决方案三个维度,完整复盘
191 122
|
5天前
|
人工智能 API C++
出海 SEO 怎么找关键词?5 个方法
看了很多出海 SEO 找词教程后,我发现方法看起来很碎,其实核心就 5 个。
|
5天前
|
人工智能 自然语言处理 小程序
秒悟meoo和阿里云万小智有什么区别?都是AI建站和搭建小程序写代码?区别对比一看就懂
阿里云两大AI产品定位迥异:秒悟(Meoo)是“全能AI开发工具”,面向零基础用户,支持自然语言生成全栈应用并一键部署;万小智是“AI数字员工”,专注中小微企业,提供AI建站、智能客服、内容创作与SEO运营一站式服务。阿里云万小智官网:https://t.aliyun.com/U/FmBHHe
50 1
|
5天前
|
存储 运维
企业出海选用阿里云、腾讯云、AWS亚马逊云,华圣科技HuaSaint支持多币种结算可开票
华圣科技HuaSaint是正规国际云代理,一站式代办阿里云、腾讯云、AWS国际站开户与充值,支持多币种结算、签订合同、开具合规票据,解决出海企业跨境支付难、财务入账难、多平台对账难及时差响应慢等痛点,助力全球上云省心合规。
|
2天前
|
存储 缓存 安全
OpenClaw中文专用版TopClaw下载,3分钟免费安装部署!
TopClaw是OpenClaw官方中文版,专为国内用户优化:一键安装、全中文界面、本地存储、零配置。支持Win10/11、Mac(Intel/M1/M2),3分钟极速部署,免注册、纯免费、无隐私风险,新手也能轻松上手。
72 2
|
5天前
|
人工智能 自然语言处理 安全
阿里云千问大模型深度解读:功能详解、参数配置与订阅方案全攻略
阿里云千问大模型是面向个人与企业的通用大模型服务,依托阿里云百炼平台提供稳定调用能力,覆盖文本生成、多模态交互、代码开发、智能体执行等全场景需求。本文从核心功能、参数配置、订阅方案与性价比选择三方面,全面解析千问大模型的使用与订阅逻辑,帮助不同需求用户精准选型、高效配置、降低使用成本。
196 5
|
5天前
|
缓存 前端开发 数据挖掘
为什么你的 BI 项目没人用?把 BI 嵌入业务系统,才是真正的数据价值!
为什么你的 BI 项目没人用?把 BI 嵌入业务系统,才是真正的数据价值!
59 3
|
2天前
|
消息中间件 安全 Java
内存泄漏与 LeakCanary:从问题定位到修复思路
本文详解Android内存泄漏原理与LeakCanary实战:从GC Roots引用链分析、依赖接入、报告解读,到单例Context滥用、匿名内部类、Fragment ViewBinding、Adapter强引用、监听未反注册、协程作用域误用等六大高频场景的修复方案,并强调“长生命周期对象不持短生命周期对象”的预防原则。
41 0
|
5天前
|
数据采集 JavaScript 安全
混合型 PaaS 驱动政务短信钓鱼攻击机理与全域防御研究
本文剖析塞尔维亚交通罚单短信钓鱼案例,首次揭示Darcula与Phoenix两类PhaaS平台混合复用的新型攻击模式。攻击融合政务仿冒、形近域名、JS动态混淆(视口解码/空闲延迟)及Telegram数据外泄,规避传统检测。研究提出四层协同防御架构,并提供JS混淆识别、恶意URL筛查两套可落地代码,强调全链路情报联动与前端行为分析是阻断关键。(239字)
81 1
|
5天前
|
边缘计算 缓存 架构师
认知与工程架构设计哲学上的“微创新”
创新点: 1. 打破了“全局完美”的执念:传统的架构设计往往追求一个能解决所有问题的“大而全”系统。将视角从“全局通用”抽离,精准地切入了“局部稳定”这个特定维度。 2. 重新定义了架构的“边界”:这套架构限定在“特定、必须局部稳定、且无需长期演进”的场景中。在这个特定的边界内,原本可能显得笨重的架构变成了“最完美的解法”。 3. 升华了架构哲学:真正的顶级架构师知道“什么时候该用复杂的有状态架构去承载历史,什么时候该用冷酷的无状态架构去斩断包袱”。
45 2