12、Paging 3 KTX:PagingSource与PagingData的KTX扩展,流式加载

分页加载,说白了就是「数据太多,一次拿不完,得分批拿」。在 Android 里,Paging 3 算是官方给出的标准答案。而加上 KTX 扩展之后,这个答案变得更「Kotlin 化」了——协程、Flow、suspend 函数,用起来非常顺手。

我个人习惯把 Paging 3 的 KTX 扩展分成两块来看:一块是 PagingSource 的扩展,另一块是 PagingData 的流式处理。这两块搞明白了,整个分页体系就通了。

PagingSource 的 KTX 扩展:少写样板代码

先说说 PagingSource。它是数据源,负责从网络或数据库里「一页一页」地拿数据。在纯 Java 时代,你得手动处理很多回调。现在有了 KTX,直接写 suspend 函数就行。

举个例子,一个典型的 PagingSource 实现长这样:

class ArticlePagingSource(
    private val api: ArticleApi,
    private val query: String
) : PagingSource<Int, Article>() {

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

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

这里有个细节:load() 是 suspend 函数,这意味着你可以在里面直接调用其他 suspend 函数,比如网络请求。我在项目中遇到过一个问题——有人把网络请求包在了 runBlocking 里,结果把主线程卡住了。其实完全没必要,Paging 3 内部已经帮你切好了协程上下文。

小技巧:如果你的分页 key 是 Int 类型,Paging 3 默认从 1 开始。但有些后端接口从 0 开始,你可以在 load() 里手动处理 params.key ?: 0

PagingData 的流式加载:用 Flow 串联一切

PagingSource 只是数据源,真正让数据「流起来」的是 PagingData。KTX 扩展提供了 Pager 这个顶层函数,直接返回 Flow<PagingData<T>>

用法很简单:

val pagingDataFlow = Pager(
    config = PagingConfig(
        pageSize = 20,
        enablePlaceholders = false,
        initialLoadSize = 40
    ),
    pagingSourceFactory = { ArticlePagingSource(api, query) }
).flow

这个 flow 就是你的数据流。在 ViewModel 里拿到它,然后交给 UI 层去 collect。你想想看,整个过程没有回调,没有 LiveData 的粘性事件问题,就是一个干净的 Flow。

我曾经在项目里把 PagingData 直接暴露成 LiveData,后来发现每次刷新都要手动调用 invalidate(),很麻烦。改用 Flow 之后,配合 flatMapLatest,查询条件一变,数据自动重新加载:

class ArticleViewModel(
    private val api: ArticleApi
) : ViewModel() {

    private val queryFlow = MutableStateFlow("")

    val articles: StateFlow<PagingData<Article>> = queryFlow
        .flatMapLatest { query ->
            Pager(
                config = PagingConfig(pageSize = 20),
                pagingSourceFactory = { ArticlePagingSource(api, query) }
            ).flow
        }
        .cachedIn(viewModelScope)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), PagingData.empty())
}

这里有个关键点:cachedIn()。它会把分页数据缓存在 ViewModel 的作用域里,避免旋转屏幕时重新加载。嗯,这个一定要加,不然每次配置变更都会重新请求第一页数据。

注意:cachedIn() 必须在 viewModelScope 里调用,不能在 Activity 或 Fragment 的 scope 里。否则页面销毁时缓存就丢了,等于白加。

PagingData 的转换与组合

有时候你需要在分页数据上做一些转换。比如从网络拿到的 Article 对象里,需要把时间戳转成可读的日期格式。Paging 3 的 KTX 扩展提供了 map()insertSeparators() 等操作符。

举个例子,给列表插入「日期分隔线」:

val pagingDataWithSeparators = pagingDataFlow.map { pagingData ->
    pagingData.insertSeparators { before, after ->
        if (before == null) {
            // 第一个 item 之前,插入一个标题
            SeparatorItem("最新文章")
        } else if (after == null) {
            // 最后一个 item 之后,不插入
            null
        } else if (before.date != after.date) {
            // 日期不同时,插入日期分隔线
            SeparatorItem(after.date)
        } else {
            null
        }
    }
}

这个 insertSeparators() 是我个人非常喜欢的一个扩展。它不会破坏分页的增量加载特性,只是在数据流里「插队」。我在做一个新闻客户端时,就用它实现了「按天分组」的效果,用户体验很好。

SVG 流程图:Paging 3 KTX 核心流程

Paging 3 KTX 核心流程 PagingSource Pager { flow } Flow<PagingData<T>> cachedIn(viewModelScope) map / insertSeparators UI 层 collect / submitData

避坑指南:我踩过的几个坑

讲几个我实际遇到过的问题,希望能帮你少走弯路。

  • 分页 key 的类型不匹配:后端返回的 nextPage 可能是 String 类型,但 PagingSource 的泛型声明了 Int。我曾经因为这个崩溃过好几次。解决方案是统一用 String 作为 key,或者自己写一个转换层。
  • Placeholder 导致列表闪烁enablePlaceholders = true 时,Paging 3 会预留空位。但如果数据加载速度不一致,列表会出现「跳动」。我建议在大多数场景下设为 false,除非你有明确的骨架屏需求。
  • 网络错误时无限重试:Paging 3 默认会在 LoadResult.Error 时自动重试。但如果没有限流,网络差的时候会疯狂请求。我习惯在 PagingSource 里加一个重试计数器,超过 3 次就返回错误。
核心要点:Paging 3 KTX 的本质,是把分页逻辑抽象成「数据源 + 流式管道」。你只需要关心 PagingSource 怎么写,剩下的 Flow 转换、缓存、生命周期管理,KTX 扩展都帮你包办了。

说白了,Paging 3 的 KTX 扩展就是让分页这件事变得「声明式」——你描述数据怎么来、怎么变,剩下的交给框架。我个人觉得,这是 Android 官方在 Kotlin 化道路上做得最漂亮的一个组件。

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