Skip to content

官方 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 == 0LoadingState
首屏失败loadState.refresh is LoadState.Error 且 itemCount == 0ErrorState,onRetry = { items.retry() }
成功但一条没有itemCount == 0 且 refresh 已结束EmptyHint
追加下一页加载中loadState.append is LoadState.LoadingLoadingIndicator(Small 档,放页脚)
追加失败loadState.append is LoadState.ErrorTonalButton「加载失败,点击重试」
到底了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 ​

truefalse(本项目)
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 不管这事)