外观
官方 Paging 3 分页
列表基础在列表、滚动与自动加载更多:懒布局怎么写、key / contentType 为什么必须给、LazyListState 怎么读、下拉刷新怎么和分页共存。那一页里的手写分页是退路,不是默认做法。
这一页是规定做法的正文。项目要求「滚到底自动请求下一页,不要『点击加载更多』按钮」—— 而官方 Paging 3 天生就是这么工作的:不用你写阈值判断,不用你自己管页码,不用你守重复请求。它替你做的事在下面第一节全部列出来。
先改掉一个旧判断
早前的文档写过「本项目不用 Paging 3」,那是错的判断。只要列表的数据来自「可以只取某一页」的远端接口,Paging 3 就是推荐做法;只有接口一次性给全量、或者数据要在本地二次合并时,才退回手写。判断标准一句话在第九节。
一、为什么用官方 Paging
1.1 它替你做了什么
| 手写要自己管的东西(lists.md 里的那套) | Paging 3 里对应的东西 |
|---|---|
页码 / 游标(nextPage) | PagingSource.load() 返回的 prevKey / nextKey |
loading / hasMore 入口守卫,防一个请求在飞时再发一个 | PagingData 把 load 串行化,同一个 PagingSource 同时只有一个请求 |
滚到接近底部触发(snapshotFlow + 阈值 + totalItemsCount > 0 + distinctUntilChanged) | PagingConfig.prefetchDistance |
去重(items.none { it.id == n.id }) | PagingData 按 item key 做 diff,重复 key 直接合并 |
失败态 + 重试入口(footerError,还得先清错误才放行守卫) | LoadState.Error + items.retry() |
到底判定(hasMore = page.items.isNotEmpty()) | LoadState.append.endOfPaginationReached |
切筛选 / 切分类时丢弃过期结果(自增 generation 对账) | invalidate() → 新 PagingSource → 旧结果自动作废 |
| 重组、切后台回来不重拉 | cachedIn(scope) |
| 下拉刷新回第一页 | refresh()(落点由 getRefreshKey() 决定) |
| 详情页里删掉一条后列表局部更新 | PagingSource.invalidate(),Paging 按 key 重算 diff |
1.2 手写最容易漏的三处
- 守卫漏一个:滚动触发点不止一个(滚动、页脚重试、下拉刷新、切分类),每加一个入口就多一处
if (loading || !hasMore) return。Paging 的守卫在框架里,不在你的调用点。 - 重复请求:同一帧的
snapshotFlow抖动、页脚高度变化导致的下标翻转,都能让手写方案在同一页上打两次请求。Paging 只认「已加载末端 +prefetchDistance」,不认像素。 - 失败没重试:手写方案里「加载失败,点击重试」是必备件,漏了就是用户永远看不到下一页。Paging 里它是
LoadState.Error+ 一行retry()。
表里唯一没有的一项:失败退避
Paging 的 retry() 是「用户点了就打一次」,不带递增等待。接口有频率限制时,连点重试照样能把自己打进冷却 —— 那套 1s→2s→4s→8s 的退避仍然要自己加(放在 PagingSource 或它上面那层仓库里)。写法见 lists.md 三、限流与退避。
反过来,Mutex 那套不要再抄一遍:Paging 已经保证同一个 PagingSource 单飞,外面再套一层并发互斥只是噪声。
二、依赖
两条坐标,版本取 AndroidX 当前稳定版(paging-runtime 与 paging-compose 版本号一致):
toml
# gradle/libs.versions.toml —— 插件模块自己的版本目录
[versions]
# ★ 别凭记忆写数字:到 AndroidX Paging 的发布页确认当前稳定版
paging = "<AndroidX Paging 当前稳定版>"
[libraries]
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
androidx-paging-testing = { module = "androidx.paging:paging-testing", version.ref = "paging" } # 只有写测试的模块才需要kotlin
// build.gradle.kts
dependencies {
// <catalogName> 就是为该模块注册的版本目录名
implementation(<catalogName>.androidx.paging.runtime)
implementation(<catalogName>.androidx.paging.compose)
testImplementation(<catalogName>.androidx.paging.testing)
}本项目里,坐标写在插件模块自己的 gradle/libs.versions.toml;构建脚本先把这个版本目录注册成 <catalogName>,之后 <catalogName>. 前缀才解析得到。顺序别弄反:注册了却没人引用,会撞 GenerateProjectAccessors 超时 / does not specify compileSdk 那类误导报错(见常见报错速查)。
paging-compose会带上paging-common——PagingSource/PagingConfig/LoadState/PagingData全在它里面。paging-runtime是给基于RecyclerView的宿主用的,纯 Compose 页面其实用不到;两条坐标放在一起不会出问题,按上面的写法一起加。- 别顺手引别的分页库,也别自己糊一个
PagingSource抽象 —— 一个项目里只留一套分页模型。
三、完整链路:从接口到 LazyColumn
text
VideoApi ──► VideoPagingSource(load) ──► Pager(config, factory) ──► Flow<PagingData<T>>
──► .cachedIn(scope) ──► collectAsLazyPagingItems() ──► LazyColumn / items[index]3.1 PagingSource:一页一页取数
kotlin
private const val FIRST_PAGE = 1
class VideoPagingSource(
private val api: VideoApi,
) : PagingSource<Int, VideoDto>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, VideoDto> {
val page = params.key ?: FIRST_PAGE // key = null 表示第一次加载
return try {
val chunk = api.getVideos(page = page, size = params.loadSize)
LoadResult.Page(
data = chunk.items,
// 只往下拉:prevKey 永远是 null;要向上翻(聊天记录)才回填 page - 1
prevKey = if (page == FIRST_PAGE) null else page - 1,
// ★ 没有下一页时必须给 null,这就是「到底了」的唯一信号
nextKey = if (chunk.items.isEmpty()) null else page + 1,
)
} catch (e: IOException) {
LoadResult.Error(e) // ★ 返回错误,不要往外抛
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
// 刷新后从哪一页重建:锚在用户当前看到的位置,而不是回到第一页
override fun getRefreshKey(state: PagingState<Int, VideoDto>): Int? =
state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.let { page ->
page.prevKey?.plus(1) ?: page.nextKey?.minus(1)
}
}
}四条硬规则:
- 只捕获预期的异常(
IOException/HttpException)。catch (Exception)会把真 bug(NullPointerException、解析错误)伪装成「网络失败」,UI 上变成一个可以重试的错误态,永远查不出来。 - 工厂必须每次返回新实例。
PagingSource被复用会直接崩:PagingSource was re-used。 - 到底就用
nextKey = null,别让接口返回空页时还继续给页码。接口返回总数时也可以用page * pageSize >= total判,二选一,别两种混着写。 getRefreshKey()靠anchorPosition:它是刷新前最后被访问的下标。不写这个方法,刷新会回到列表第一页 —— 用户在中间下拉刷新一次就被弹回顶部。
3.2 Pager + cachedIn:整条链只建一次
kotlin
@Composable
fun App(onFinish: () -> Unit) {
val scope = rememberCoroutineScope()
val api = remember { VideoApi() }
// ★ remember 把整条链钉住:重组不会重建 Pager,也就不会重拉第一页
val pagingFlow: Flow<PagingData<Video>> = remember(scope) {
Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 5, // ★ 自动加载更多的全部配置,见第四节
enablePlaceholders = false, // ★ 不暴露 null 占位,见 5.4
),
// 工厂每次调用都造一个新 PagingSource
pagingSourceFactory = { VideoPagingSource(api) },
).flow
.map { data -> data.map { it.toUi() } } // 变换放在 cachedIn 之前
.cachedIn(scope) // ★ 必须:缓存已加载的页
}
val items = pagingFlow.collectAsLazyPagingItems()
TopTitleScaffold(title = "作品列表", onBack = onFinish) {
VideoListScreen(items) // 三态与页脚在第五节
}
}cachedIn(scope)的位置:链尾、map/filter之后。放在变换之前,变换会在缓存命中时被跳过。scope要活够久:这里的rememberCoroutineScope()寿命等于App()这个组合。有 ViewModel 层时把这条链放进 ViewModel 的val、用viewModelScope,这是 Paging 的标准形态,宿主 Activity 重建也不会重拉。
3.3 只有一个筛选条件要联动时
分类切换、搜索词变化 → flatMapLatest,每个参数组合出一条新的 Paging 链:
kotlin
val pagingFlow: Flow<PagingData<Video>> = query
.debounce(300) // 输入框:防一个字一个 Pager
.distinctUntilChanged() // 值没变就别重建
.flatMapLatest { keyword ->
Pager(
config = PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false),
pagingSourceFactory = { VideoPagingSource(api, keyword = keyword) },
).flow
}
.cachedIn(scope) // ★ 在 flatMapLatest 外面,不是里面cachedIn 写进 flatMapLatest 的 lambda 里,等于每次换词就把缓存扔掉。另外:两条 PagingData 流不能用 combine 合并,Paging 会抛 Collecting from multiple PagingData concurrently —— 参数变化一律走 flatMapLatest。
四、★ 自动加载更多就是默认行为
prefetchDistance 是「离已加载内容的末端还剩几条没被读到,就开始预取下一页」。prefetchDistance = 5 的意思:用户读到倒数第 5 条时,下一页已经在路上。
| 配置 | 什么时候发下一页请求 |
|---|---|
prefetchDistance = 0 | 读到列表最后一条才请求,滚到底会看到一段空白等待 |
prefetchDistance = 5 | 读到「末端往前数第 5 条」时请求,滑到底时下一页基本已经在了 |
不传(默认 = pageSize = 20) | 一页那么早就开始预取,滚动最顺,请求也最费 |
它按被访问到的下标算距离,不是按像素、也不是按可见项的下标 —— 所以页脚高度跳变、横竖屏、大屏小屏都不影响触发点。enablePlaceholders = false 时也一样有效。
项目规范「滚到底自动加载、不要按钮」在这里是同一件事
所以lists.md 二、那段代码不用抄:
kotlin
// ❌ 用了 Paging 3 之后这一段是多余的(连 totalItemsCount > 0 那个守卫一起多余)
LaunchedEffect(listState) {
snapshotFlow { info.totalItemsCount > 0 && last >= info.totalItemsCount - 3 }
.distinctUntilChanged().filter { it }.collect { loadNextPage() }
}PagingConfig 一行就把它替换掉了,连 totalItemsCount > 0 那个守卫都不需要 —— Paging 不会在还没加载到内容时认为自己到底了。手写 snapshotFlow 只在接口不适配 PagingSource(一次性给全量、或要在本地多源合并)时才是正确选择。
另外两个参数:initialLoadSize 默认是 pageSize 的 3 倍(pageSize = 20 → 首屏一次拉 60 条),首屏想省流量就显式传 initialLoadSize = 2 * pageSize;enablePlaceholders 见 5.4。
五、★ 三态 UI 怎么写全
三种状态各有自己的组件,别用 if/else 手搓:
| 场景 | 判据 | 用组件 |
|---|---|---|
| 首屏加载中 | loadState.refresh is LoadState.Loading 且 itemCount == 0 | LoadingState |
| 首屏失败 | loadState.refresh is LoadState.Error 且 itemCount == 0 | ErrorState,onRetry = { items.retry() } |
| 成功但一条没有 | itemCount == 0 且 refresh 已结束 | EmptyHint |
| 追加下一页加载中 | loadState.append is LoadState.Loading | LoadingIndicator(Small 档,放页脚) |
| 追加失败 | loadState.append is LoadState.Error | TonalButton「加载失败,点击重试」 |
| 到底了 | loadState.append.endOfPaginationReached && itemCount > 0 | 一行「没有更多了」 |
5.1 完整实现(首屏三态 + 页脚三态 + 下拉刷新)
kotlin
private fun Throwable.readableMessage(): String =
localizedMessage?.takeIf { it.isNotBlank() } ?: "加载失败,请稍后重试"
@Composable
private fun VideoListScreen(items: LazyPagingItems<Video>) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val refresh = items.loadState.refresh
when {
// ① 首屏还在来:整页加载,别在列表里放个转圈
refresh is LoadState.Loading && items.itemCount == 0 ->
LoadingState(text = "加载中…")
// ② 首屏没来成:ErrorState 自带「重新加载」按钮,文案不可配,也不用自己拼
refresh is LoadState.Error && items.itemCount == 0 ->
ErrorState(
message = refresh.error.readableMessage(),
onRetry = { items.retry() },
)
// ③ 来了但是空:给下一步动作,别只说「没有东西」
items.itemCount == 0 ->
EmptyHint(
text = "还没有作品",
description = "换个关键词,或者下拉刷新试试",
actionText = "刷新",
onAction = { items.refresh() },
)
// ④ 有内容:三态交给页脚,整页状态视图全部让开
else -> PullToRefresh(
isRefreshing = refresh is LoadState.Loading, // ★ 派生,不用自己托管布尔
onRefresh = {
items.refresh() // 页码由 Paging 自己归 1
scope.launch { listState.scrollToItem(0) } // 刷新后回到顶部
},
) {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(
count = items.itemCount,
// ★ 用 Paging 自己发的 key,永远不要再叠一层自己的 key(见 6.1)
key = items.itemKey { it.id },
contentType = items.itemContentType { "row" },
) { index ->
// ★ 可能为 null,? 与 let 一个都不能省
items[index]?.let { video ->
ListItem(
title = video.title,
subtitle = video.author,
onClick = { /* … */ },
)
}
}
// ★ 页脚必须是列表的最后一个 item,且给定稳定 key / contentType
item(key = "paging_footer", contentType = "footer") {
PagingFooter(items, onRetry = { items.retry() })
}
}
}
}
}
@Composable
private fun PagingFooter(items: LazyPagingItems<Video>, onRetry: () -> Unit) {
if (items.itemCount == 0) return // 空列表交给 EmptyHint,页脚不画
val append = items.loadState.append
Box(Modifier.fillMaxWidth().padding(vertical = 16.dp), contentAlignment = Alignment.Center) {
when {
// ① 追加失败:必须可点。自动加载失败后它不会自己再来一次
append is LoadState.Error ->
TonalButton(text = "加载失败,点击重试", onClick = onRetry)
// ② 追加中:页脚用小档指示器,不是整页那一档
append is LoadState.Loading ->
LoadingIndicator(size = LoadingIndicatorSize.Small, label = "正在加载…")
// ③ 到底:给一个明确的结束信号,否则用户会一直往下拉
append.endOfPaginationReached ->
Text("没有更多了", style = MaterialTheme.typography.bodySmall)
// ④ 还没触发预取:什么都不显示。别写「上拉加载更多」这种要用户配合的文案
}
}
}三个容易写错的地方
- 整页状态视图不能塞进
LazyColumn的item { }。LoadingState/ErrorState/EmptyHint自带fillMaxSize(),在无限高的 item 里尺寸算不对、也拿不到居中;它们只该出现在LazyColumn外面(同一条理由见 lists.md 四、)。 - 空态时不要套
PullToRefresh。 它靠nestedScroll接手势,content里必须是可滚动容器,静态的EmptyHint/ErrorState套进去拉不动 —— 首屏失败/为空就用组件自带的按钮,有内容之后才由 PTR 接管刷新。 - 页脚三态尽量等高。 高度在「加载中 / 没有更多」之间跳变,会让列表在底部抖一下。
5.2 下拉刷新:refresh() 而不是重新 load
items.refresh()会新建PagingSource、按getRefreshKey()决定重建位置并触发新的首屏加载 —— 不用你清空列表、不用你把页码归 1。这正是手写方案里最容易漏的「刷新必须重置整套分页状态」。isRefreshing直接从loadState.refresh派生:refresh()一调它就变Loading,不用像 ptr 文档里手写方案那样自己置true(那一条是针对没有状态源的手写分页)。万一实测出现「指示器弹一下就收」——说明那一帧还没翻过来 —— 加一个本地布尔兜底即可:var manual by remember { mutableStateOf(false) },onRefresh里置true,LaunchedEffect里在refresh !is LoadState.Loading时置回false。- 刷新默认留在原地,不是回顶部 —— Paging 要兼容「新内容插在上面」的列表。要回顶就自己滚(上面
scrollToItem(0))。 - 刷新失败要有出口:列表里已经有内容时下拉刷新失败,
loadState.refresh是Error但走的是「有内容」那条分支,界面上什么都不会显示 —— 至少给一次轻提示,别让用户以为刷新成功了。
5.3 items[index] 为什么可能是 null
LazyPagingItems 的 get(index) 有三个语义:已加载返回数据、未加载的占位槽返回 null、越界抛 IndexOutOfBoundsException。所以:
kotlin
items[index]?.let { video -> ListItem(title = video.title, onClick = {}) } // ✅ 三态都安全
items[index]!!.title // ❌ 占位槽 / 抖动帧直接崩
items[index]?.title ?: "" // ⚠️ 不崩,但会闪一帧空行5.4 为什么本项目固定 enablePlaceholders = false
true | false(本项目) | |
|---|---|---|
itemCount | 一开始就是接口报的总数 | 只数已经加载到的条数 |
items[index] | 未加载的槽返回 null,靠 ?.let 跳过 | 未加载的下标根本不存在 |
| 滚动条 | 长度恒定,不因加载而变 | 随加载增长 |
| 代价 | 接口必须给总数;每帧都有 null 槽,?.let 一漏就是空行或崩溃 | 滚动条长度会跳,但列表内容永远「所见即所得」 |
插件列表接口大多不给总数(或者给的总数跟筛选条件不对应),所以统一关掉。关掉之后 itemCount 就等于「已加载条数」,页脚的 itemCount == 0 判断、itemCount > 0 判断全部按它来。
六、常见坑
6.1 又给 items() 传了自己的 key
- 症状:追加下一页时整列表闪一下、滚动位置跳到别处;或者直接崩
IllegalArgumentException: Key "…" was already used。根因:Paging 自己用 key 做 diff,占位槽和真实项共用一套 key 规则;你叠的key = { index -> list[index].id }在数据变化的那一帧会读到过期下标或null,两套 key 打架。 - 正确写法:
key = items.itemKey { it.id },或者干脆不传 key 让 Paging 用下标。永远不要自己算下标到数据的映射。
6.2 忘了 cachedIn(scope)
- 症状:切筛选再切回来、页面重组、
collectAsLazyPagingItems()重启之后,列表从第一页重新拉,滚动位置丢掉。根因:Pager.flow是冷流,重新收集就是重新请求;没有缓存层,已加载的页不会留下来。 - 正确写法:链尾
.cachedIn(scope),scope要么是 ViewModel 的viewModelScope,要么是与页面同寿命的 scope。
6.3 在 PagingSource 里直接抛异常
- 症状:首屏卡在加载中不动、或者整个页面被异常掀翻,页脚永远出不来「加载失败」。根因:
load()抛异常时 Paging 不会把它翻译成LoadState.Error,UI 拿不到失败态,retry()也没有可重试的对象。 - 正确写法:
catch (e: IOException) { LoadResult.Error(e) },HttpException同理;只捕获预期异常。
6.4 LoadResult.Invalid 什么时候返回
- 症状:数据源已经变了(用户删了一条、筛选换了),正在飞的那次
load()回来把过期数据写进列表。根因:invalidate()之后的旧结果没人丢。 - 正确写法:较新版本的 Paging 在
LoadResult里提供了Invalid—— 当这个PagingSource已被invalidate()、而这次请求的结果已过期时返回它,Paging 会丢弃这次结果并按新PagingSource重新加载。注意两点:Invalid不是失败态(不会进LoadState.Error,所以不能拿它兜网络异常);返回它的前提是你能确定结果无效(比如 URL 变了、底层列表版本号对不上)。不确定就别返回,交给invalidate()处理。
6.5 把 PagingData 存进 remember / 用 collectAsState 收集
- 症状:任何无关状态一变(勾选、弹窗开关、输入框),列表整个重建、滚动回顶。根因:
PagingData是它自己的一条流,不是一份值。它进了UiState/remember之后,外层状态一发射就把它重新发一遍,collectAsLazyPagingItems()收到全新的流 → 重置。 - 正确写法:
PagingData只以Flow形态存在(val pagingFlow: Flow<PagingData<T>>),UI 侧唯一入口是collectAsLazyPagingItems();勾选态、弹窗开关这些放自己的StateFlow/remember,两边互不干扰。不要用collectAsState()收PagingData。
6.6 数据变了而 index 还在飞导致越界
- 症状:刷新/删除后偶发
IndexOutOfBoundsException,或者末行闪一下空内容。根因:count = items.itemCount在组合时读一次,布局队列里还留着上一轮的下标;数据收缩后那个下标已经不存在。 - 正确写法:
count用items.itemCount、key用items.itemKey { it.id }、内容一律items[index]?.let { }(Paging 的get自己会做边界处理并触发加载)。不要把index存进remember、也不要传给别的地方延迟使用。
七、RemoteMediator:什么时候值得用
RemoteMediator 把「网络」和「本地库」接起来:网络只负责往本地库里写,UI 始终读本地库。只有这三种情况同时想要时才上它:
- 有本地库(本项目可以用 PluginDb 这类持久化能力);
- 要离线可看:断网进页面还能看到上次的内容;
- 要跨会话缓存:重启插件不重新拉第一页。
用它的代价是:多一张 remote key 表、多一套「什么时候跳过刷新」的判断、loadState 要多读一层。
kotlin
val items: Flow<PagingData<Video>> = Pager(
config = PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false),
remoteMediator = VideoRemoteMediator(api, db), // 往本地库写
pagingSourceFactory = { db.videoDao().pagingSource() }, // 从本地库读
).flow.cachedIn(scope)上它之后有两条专属规矩:initialize() 用 SKIP_INITIAL_REFRESH / LAUNCH_INITIAL_REFRESH 决定「先显示缓存再刷新」还是「先转圈等网络」;loadState 要读 loadState.source.refresh 而不是 loadState.refresh(后者会把 source 和 mediator 合成一个信号,本地库还在写事务时就先翻成 NotLoading,指示器一闪就没了)。
临时搜索接口这类用不上 —— 直接跳过
「输入关键词 → 打一次搜索接口 → 出一页结果」的页面不要引 RemoteMediator:搜出来的东西本来就不该跨会话留着,本地库那张表也只会变成垃圾场。直接 PagingSource + Pager 就够了。
八、测试
PagingSource 不用起 UI 就能测 —— 直接调 load():
kotlin
@Test
fun `load 返回一页,失败返回 Error 而不是抛异常`() = runTest {
val params = PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
val ok = VideoPagingSource(FakeApi(items = List(20) { dto(it) })).load(params) as LoadResult.Page
assertEquals(20, ok.data.size)
assertNull(ok.prevKey) // 只往下拉
assertEquals(2, ok.nextKey)
val empty = VideoPagingSource(FakeApi(items = emptyList())).load(params) as LoadResult.Page
assertNull(empty.nextKey) // ★ 空页 = 到底
val failed = VideoPagingSource(FakeApi(error = IOException("boom"))).load(params)
assertTrue(failed is LoadResult.Error)
}整条流层面用 asSnapshot()(Paging 3.3 起):把 Flow<PagingData<T>> 收成列表,还能模拟滚动把下一页逼出来。只想验证变换(DTO → UI 模型、过滤、插分隔行)就用 TestPager:
kotlin
@Test
fun `滚过两页后至少有 30 条`() = runTest {
assertTrue(viewModel.pagingFlow.asSnapshot { scrollTo(index = 30) }.size >= 30)
}
@Test
fun `TestPager 只取一页,用来断言变换`() = runTest {
val source = listOf(dto(1), dto(2)).asPagingSourceFactory().invoke()
val page = TestPager(PagingConfig(pageSize = 2), source).refresh() as LoadResult.Page
assertEquals(listOf(1L, 2L), page.data.map { it.id })
}这三个 API(asSnapshot / TestPager / asPagingSourceFactory)都在 androidx.paging:paging-testing 里、只进测试源集;用之前确认所引版本里它们就是这个形状,别凭记忆写。
九、什么时候不该用 Paging 3
判断标准一句话:「这个接口能不能只给我某一页(页码或游标)?」能 → Paging 3;不能 → 手写。
| 情况 | 用什么 |
|---|---|
分页接口(page / limit,或 cursor) | Paging 3(本页) |
| 一次性返回全量(配置项、少量静态数据) | 直接拿列表 + lists.md 的 LazyColumn,不需要任何分页 |
| 多个接口的结果要在本地 join / 排序后再给 UI | 手写(PagingData 是单向流水,跨源合并后 key 与插入顺序都失控) |
| 本地数据库当唯一数据源、还要离线可看 | Paging 3 + RemoteMediator(第七节) |
| 总共就十几条、根本不翻页 | 不要引 Paging,LazyColumn 就是最优解 |
退回手写时,lists.md 第二、三节的守卫 / 互斥 / 退避一条都不能省 —— 那些坑不是「另一种写法」,是「没有框架兜底时必须自己扛」。
交付前自查
- [ ] 依赖写在插件模块自己的
gradle/libs.versions.toml,版本取当前稳定版,没凭记忆写数字 - [ ]
PagingSource.load()返回LoadResult.Error而不是抛异常;到底时nextKey = null - [ ]
Pager只建一次(remember或 ViewModel 的val),链尾有.cachedIn(scope),map/filter在它之前 - [ ] 用
collectAsLazyPagingItems()收,不是collectAsState();PagingData没进UiState、没进remember - [ ]
items(count = items.itemCount, key = items.itemKey { … }, contentType = items.itemContentType { … }),没有另叠一套 key;内容一律items[index]?.let { };enablePlaceholders = false - [ ] 首屏三态齐全:
LoadingState/ErrorState(retry())/EmptyHint,且都在LazyColumn外面;页脚是列表最后一个 item、key 与 contentType 都给,三态齐全,失败态可点重试 - [ ] 下拉刷新走
items.refresh()并滚回顶部,isRefreshing由loadState.refresh派生;正常路径上没有任何「加载更多」按钮 - [ ] 接口有频率限制时,递增退避仍自己加(Paging 不管这事)