mcpField
P
paging

paging

github.com/rcosteira79开发165126 次安装6 周前更新
代码仓库

关于 paging

面向 Android 与 Compose 开发者的 Paging 3 实践参考,聚焦分页流、缓存、列表身份、离线优先加载和测试中容易出错的规则。

主要功能

  1. 01区分界面状态流与分页数据流
  2. 02正确放置 cachedIn
  3. 03为 LazyColumn 指定稳定键
  4. 04配置 RemoteMediator 离线优先分页
  5. 05使用 paging-testing 测试分页

适用场景

  1. 01将分页接口放入领域模块
  2. 02实现筛选或搜索分页
  3. 03避免列表插入页面时状态错位
  4. 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)
}

评论

支持 Markdown