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 内部已经帮你切好了协程上下文。
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 核心流程
避坑指南:我踩过的几个坑
讲几个我实际遇到过的问题,希望能帮你少走弯路。
- 分页 key 的类型不匹配:后端返回的 nextPage 可能是 String 类型,但 PagingSource 的泛型声明了 Int。我曾经因为这个崩溃过好几次。解决方案是统一用 String 作为 key,或者自己写一个转换层。
- Placeholder 导致列表闪烁:
enablePlaceholders = true时,Paging 3 会预留空位。但如果数据加载速度不一致,列表会出现「跳动」。我建议在大多数场景下设为 false,除非你有明确的骨架屏需求。 - 网络错误时无限重试:Paging 3 默认会在
LoadResult.Error时自动重试。但如果没有限流,网络差的时候会疯狂请求。我习惯在 PagingSource 里加一个重试计数器,超过 3 次就返回错误。
说白了,Paging 3 的 KTX 扩展就是让分页这件事变得「声明式」——你描述数据怎么来、怎么变,剩下的交给框架。我个人觉得,这是 Android 官方在 Kotlin 化道路上做得最漂亮的一个组件。