关于 paging
面向 Android 与 Compose 开发者的 Paging 3 实践参考,聚焦分页流、缓存、列表身份、离线优先加载和测试中容易出错的规则。
主要功能
- 01区分界面状态流与分页数据流
- 02正确放置 cachedIn
- 03为 LazyColumn 指定稳定键
- 04配置 RemoteMediator 离线优先分页
- 05使用 paging-testing 测试分页
适用场景
- 01将分页接口放入领域模块
- 02实现筛选或搜索分页
- 03避免列表插入页面时状态错位
- 04测试数据源与 ViewModel 分页流
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ rcosteira79/ android-skills --skill paging安装后在 Claude Code 中输入/paging确认已加载。
详细介绍
Android 和 Compose 的 Paging 3
改编自 Meet-Miyani/compose-skill 的 Paging 参考资料。采用 MIT 许可证。相关内容: android-skills:android-data-layer(非分页存储库)、android-skills:android-retrofit / android-skills:kmp-ktor(为 PagingSource 提供网络数据)、android-skills:compose(LazyColumn)。
本参考介绍当前版本信息、容易出错的放置与标识规则,以及 RemoteMediator 离线优先模式的时序,不讲解 PagingSource / Pager / collectAsLazyPagingItems 的基础知识。
版本(当前信息)
截至 androidx.paging 3.5.0,paging-common、paging-compose 和 paging-testing 均为 Kotlin Multiplatform(Android、JVM、iOS、Native、JS/Wasm);只有 paging-runtime(以及 -guava/-rxjava)仍仅支持 Android。因此,在整洁架构的 domain 模块中,只需依赖 androidx.paging:paging-common——它提供 PagingSource / PagingData / LoadResult,且不依赖 Android,因此 PagingSource 接口和用例可以放在 domain 中。(请在发布页面确认当前版本和 KMP 支持情况。)
双流规则——PagingData 自身就是一个 Flow,绝不能作为字段放入 UiState
将 uiState: StateFlow<UiState> 和 pagingItems: Flow<PagingData<T>> 作为独立属性公开。
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
val pagingItems: Flow<PagingData<RepoUi>> =
Pager(PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false)) { repo.repoPagingSource() }
.flow
.map { data -> data.map { it.toUi() } } // transforms BEFORE cachedIn
.cachedIn(viewModelScope)
如果将 PagingData 放入 UiState,每次非分页状态变化(选择项、筛选条件标签、Snackbar)都会重新发出 UiState,并向 collectAsLazyPagingItems() 提供一个全新的 Flow<PagingData>——它会丢弃缓存、重新启动加载,并将滚动位置重置到顶部。
cachedIn 的放置位置
cachedIn(viewModelScope) 应放在调用链的末尾:位于所有 flatMapLatest(筛选/搜索)之后,也位于所有 map / filter / insertSeparators 之后。放在 cachedIn 之后的任何操作都会在每个收集器的作用域中重新运行(重新转换缓存数据,导致分隔符不同步);将 cachedIn 放在 flatMapLatest lambda 内部,则会在每次筛选条件变化时泄漏一个新的缓存。
val pagingItems = combine(_query.debounce(300).distinctUntilChanged(), _status.distinctUntilChanged(), ::Pair)
.flatMapLatest { (q, s) -> Pager(PagingConfig(20)) { repo.repoPagingSource(q, s) }.flow.map { it.map(RepoDto::toUi) } }
.cachedIn(viewModelScope)
LazyColumn 中必须使用 itemKey
items(
count = pagingItems.itemCount,
key = pagingItems.itemKey { it.id }, // stable domain id
contentType = pagingItems.itemContentType { "repo" },
) { idx -> pagingItems[idx]?.let { RepoRow(it) } }
如果不使用 itemKey,LazyColumn 会按索引重用槽位——前置一页数据后,所有现有条目的标识都会偏移,因此行状态 / rememberSaveable / 动画会关联到错误的槽位,滚动位置也会明显跳动。(pagingItems[i] 既会读取数据,也会在接近边缘时触发加载;peek(i) 只读取数据,不会触发加载。应在事件处理器或 LaunchedEffect 中调用 retry() / refresh(),绝不要在可组合项主体中调用。)
RemoteMediator(离线优先)
使用离线优先分页时,Room DAO 是 PagingSource,而 RemoteMediator 负责驱动网络刷新和数据库写入。请将这些写入放在 db.withTransaction { } 中,并在 LoadType.REFRESH 时清除本地状态。只有在 loadState.source.refresh 和 itemCount == 0 同时满足时,才显示全屏加载指示器——不要使用合并后的 loadState.refresh;它会在 Room 完成写入之前变为 NotLoading,导致加载指示器提前一帧消失。
测试——使用 TestPager / asSnapshot,绝不要用 .first()
分页流是热流,永远不会完成,因此对其调用 .first() / .toList() 会一直挂起。请使用 paging-testing:
@Test fun `source returns first page`() = runTest {
val pager = TestPager(PagingConfig(10), RepoPagingSource(FakeGitHubService(repo1, repo2), "kotlin"))
val page = pager.refresh() as PagingSource.LoadResult.Page
assertEquals(2, page.data.size); assertNull(page.prevKey); assertEquals(2, page.nextKey)
}
@Test fun `viewmodel paging flow exposes first pages`() = runTest {
val items = RepoListViewModel(FakeRepoRepository()).pagingItems.asSnapshot { scrollTo(index = 30) }
assertTrue(items.size >= 30)
}