paging について
Android と Compose の開発者向け Paging 3 リファレンスです。ページングフロー、キャッシュ、リスト項目の識別、オフライン優先の読み込み、テストで見落としやすいルールを扱います。
主な機能
- 01UI 状態とページングフローの分離
- 02cachedIn の適切な配置
- 03LazyColumn での安定キー指定
- 04RemoteMediator によるオフライン優先ページング
- 05paging-testing を使ったテスト
活用シーン
- 01ドメインモジュールへのページングインターフェース配置
- 02フィルターや検索付きページングの実装
- 03ページ挿入時の行状態のずれ防止
- 04PagingSource と 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)。
このリファレンスでは、現行バージョンの事実、間違えやすい配置と ID のルール、そして 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 専用のままです。そのため、クリーンアーキテクチャのドメインモジュールでは、androidx.paging:paging-common のみに依存してください。Android への依存なしで PagingSource / PagingData / LoadResult が提供されるため、PagingSource インターフェースやユースケースをドメインに配置できます。(現行バージョンと KMP のサポート状況はリリースページで確認してください。)
2 つの Flow のルール — 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 に含めると、ページング以外の状態変更(選択、フィルターチップ、スナックバー)によって UiState が再度 emit され、そのたびに新しい Flow<PagingData> が collectAsLazyPagingItems() に渡されます。するとキャッシュが破棄され、読み込みが再開し、スクロール位置が先頭に戻ります。
cachedIn の配置
cachedIn(viewModelScope) はチェーンの最後に置きます。flatMapLatest(フィルター/検索)の後、さらにすべての map / filter / insertSeparators の後です。cachedIn の後にチェーンを追加すると、各コレクターのスコープで再実行されます(キャッシュされたデータが再変換され、区切りの同期がずれます)。flatMapLatest のラムダ内に cachedIn を置くと、フィルターを変更するたびに新しいキャッシュが残り続けます。
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 はインデックスでスロットを再利用します。ページが先頭に追加されると、既存の各アイテムの ID がずれるため、行の状態/rememberSaveable /アニメーションが誤ったスロットを対象にし、スクロール位置が目に見えてジャンプします。(pagingItems[i] は読み取りと同時に端の近くで読み込みを開始します。peek(i) は読み込みを開始せずに読み取ります。retry() / refresh() はイベントハンドラーまたは LaunchedEffect から呼び出し、コンポーザブル本体からは呼び出さないでください。)
RemoteMediator(オフラインファースト)
オフラインファーストのページングでは、Room DAO が PagingSource となり、RemoteMediator がネットワークの更新と DB への書き込みを行います。これらの書き込みは db.withTransaction { } で囲み、LoadType.REFRESH ではローカルの状態をクリアします。全画面ローダーの表示条件は loadState.source.refresh と itemCount == 0 にします。loadState.refresh の合成状態は使わないでください。Room の書き込みが完了する前に NotLoading に切り替わり、スピナーが 1 フレーム早く消えてしまうためです。
テスト — .first() ではなく TestPager / asSnapshot
ページングのフローはホットで完了しないため、.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)
}