Paging 3 の概要

このガイドでは、Jetpack Compose で Paging 3 を実装する方法について説明します。Room データベースを使用する場合と使用しない場合の両方の実装について説明します。ページネーションは、大規模なデータセットを一度にすべて読み込むのではなく、ページと呼ばれる管理しやすい小さなチャンクで読み込んで表示することで、大規模なデータセットを管理する戦略です。

無限スクロール フィード(ソーシャル メディアのタイムライン、e コマース商品の大規模なカタログ、大量のメールが届く受信トレイなど)を備えたアプリでは、堅牢なデータ ページネーションが必要です。通常、ユーザーがリストのほんの一部しか表示しないことと、モバイル デバイスの画面サイズが限られていることから、データセット全体を読み込むのは効率的ではありません。システム リソースが無駄になり、ジャンクやアプリのフリーズが発生して、ユーザー エクスペリエンスが低下する可能性があります。この問題を解決するには、遅延読み込みを使用します。Compose の LazyList などのコンポーネントは UI 側で遅延読み込みを処理しますが、ディスクやネットワークからデータを遅延読み込みすることで、パフォーマンスがさらに向上します。

ページング 3 ライブラリは、データのページネーションを処理するうえで推奨されるソリューションです。Paging 2 から移行する場合は、Paging 3 に移行するをご覧ください。

前提条件

先に進む前に、次の内容を理解しておいてください。

  • Android でのネットワーキング(このドキュメントでは Retrofit を使用しますが、Paging 3 は Ktor などの任意のライブラリで動作します)。
  • Compose UI ツールキット。

依存関係を設定する

アプリレベルの build.gradle.kts ファイルに次の依存関係を追加します。

dependencies {
  val paging_version = "3.4.0"

  // Paging Compose
  implementation("androidx.paging:paging-compose:$paging_version")

  // Networking dependencies used in this guide
  val retrofit = "3.0.0"
  val kotlinxSerializationJson = "1.9.0"
  val retrofitKotlinxSerializationConverter = "1.0.0"
  val okhttp = "4.12.0"

  implementation("com.squareup.retrofit2:retrofit:$retrofit")
  implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationJson")
  implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:$retrofitKotlinxSerializationConverter")
  implementation(platform("com.squareup.okhttp3:okhttp-bom:$okhttp"))
  implementation("com.squareup.okhttp3:okhttp")
  implementation("com.squareup.okhttp3:logging-interceptor")
}

Pager クラスを定義する

Pager クラスは、ページネーションのプライマリ エントリ ポイントです。PagingData のリアクティブ ストリームを構築します。Pager をインスタンス化し、ViewModel 内で再利用する必要があります。

Pager では、データを取得して表示する方法を決定するために PagingConfig が必要です。

// Configs for pagination
val PAGING_CONFIG = PagingConfig(
    pageSize = 50, // Items requested from data source
    enablePlaceholders = false,
    initialLoadSize = 50,
    prefetchDistance = 10 // Items from the end that trigger the next fetch
)

Pager は、データベースなし(ネットワークのみ)またはデータベースあり(Room を使用)の 2 つの方法で実装できます。

データベースなしで実装する

データベースを使用しない場合は、オンデマンドのデータ読み込みを処理するために PagingSource<Key, Value> が必要です。この例では、キーは Int で、値は Product です。

PagingSource で 2 つの抽象メソッドを実装する必要があります。

  • load: LoadParams を受け取る suspend 関数。RefreshAppendPrepend リクエストのデータを取得するために使用します。

  • getRefreshKey: ページャーが無効になった場合にデータを再読み込みするために使用されるキーを提供します。このメソッドは、ユーザーの現在のスクロール位置(state.anchorPosition)に基づいてキーを計算します。

次のコード例は、ローカル データベースを使用せずに Paging 3 を使用する場合にデータ取得ロジックを定義するために必要な ProductPagingSource クラスを実装する方法を示しています。

class ProductPagingSource : PagingSource<Int, Product>() {
    override fun getRefreshKey(state: PagingState<Int, Product>): Int {

// This is called when the Pager needs to load new data after invalidation
      // (for example, when the user scrolls quickly or the data stream is
      // manually refreshed).

      // It tries to calculate the page key (offset) that is closest to the
      // item the user was last viewing (`state.anchorPosition`).

        return ((state.anchorPosition ?: 0) - state.config.initialLoadSize / 2).coerceAtLeast(0)
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Product> {
        return when (params) {
                // Case 1: The very first load or a manual refresh. Start from
                // offset 0.
            is LoadParams.Refresh<Int> -> {
                fetchProducts(0, params.loadSize)
            }
                // Case 2: User scrolled to the end of the list. Load the next
                // 'page' using the stored key.
            is LoadParams.Append<Int> -> {
                fetchProducts(params.key, params.loadSize)
            }
                // Case 3: Loading backward. Not supported in this
                // implementation.
            is LoadParams.Prepend<Int> -> LoadResult.Invalid()
        }
    }
// Helper function to interact with the API service and map the response
//  into a [LoadResult.Page] or [LoadResult.Error].
    private suspend fun fetchProducts(key: Int, limit: Int): LoadResult<Int, Product> {
        return try {
            val response = productService.fetchProducts(limit, key)

            LoadResult.Page(
                data = response.products,
                prevKey = null,
                nextKey = (key + response.products.size).takeIf { nextKey ->
                    nextKey < response.total
                }
            )
        } catch (e: Exception) {
                // Captures network failures or JSON parsing errors to display
                // in the UI.
            LoadResult.Error(e)
        }
    }
}

ViewModel クラスで Pager を作成します。

val productPager = Pager(
    //  Configuration: Defines page size, prefetch distance, and placeholders.
    config = PAGING_CONFIG,
    //  Initial State: Start loading data from the very first index (offset 0).
    initialKey = 0,
    //  Factory: Creates a new instance of the PagingSource whenever the
    // data is invalidated (for example, calling pagingSource.invalidate()).
    pagingSourceFactory = { ProductPagingSource() }
).flow.cachedIn(viewModelScope)

データベースで実装する

Room を使用すると、データベースによって PagingSource クラスが自動的に生成されます。ただし、データベースはネットワークからデータを取得するタイミングを認識していません。この処理を行うには、RemoteMediator を実装します。

RemoteMediator.load() メソッドは、loadTypeAppendPrepend、または Refresh)と状態を提供します。成功または失敗と、ページネーションの終わりに達したかどうかを示す MediatorResult を返します。

@OptIn(ExperimentalPagingApi::class)
@OptIn(ExperimentalPagingApi::class)
class ProductRemoteMediator : RemoteMediator<Int, Product>() {
    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, Product>
    ): MediatorResult {
        return try {
            // Get the count of loaded items to calculate the skip value
            val skip = when (loadType) {
                LoadType.REFRESH -> 0
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND -> {
                    InMemoryDatabaseProvider.INSTANCE.productDao().getCount()
                }
            }

            val response = productService.fetchProducts(
                state.config.pageSize,
                skip
            )

            InMemoryDatabaseProvider.INSTANCE.productDao().apply {
                insertAll(response.products)
            }

            MediatorResult.Success(
                endOfPaginationReached = response.skip + response.limit >= response.total
            )
        } catch (e: Exception) {
            MediatorResult.Error(e)
        }
    }
}

ViewModel では、Room が PagingSource クラスを処理するため、実装が大幅に簡素化されます。

val productPager = ProductRepository().fetchProducts().flow.cachedIn(viewModelScope)

ネットワーク設定

上記の例は、ネットワーク サービスに依存しています。このセクションでは、api.example.com/products エンドポイントからデータを取得するために使用される Retrofit とシリアル化の設定について説明します。

データクラス

次のコード例は、kotlinx.serialization で使用してネットワーク サービスからページ分割された JSON レスポンスを解析する 2 つのデータクラス ProductResponseProduct を定義する方法を示しています。

@Serializable
data class ProductResponse(
    val products: List<Product>,
    val total: Int,
    val skip: Int,
    val limit: Int
)

@Serializable
data class Product(
    val id: Int,
    var title: String = "",
    // ... other fields (description, price, etc.)
    val thumbnail: String = ""
)

Retrofit サービス

次のコード例は、ネットワークのみの実装の Retrofit サービス インターフェース(ProductService)を定義する方法を示しています。ここでは、Paging 3 ライブラリがデータページを取得するために必要なエンドポイント(@GET("/products"))と必要なページネーション パラメータ(limit)と(skip)を指定しています。

interface ProductService {
    @GET("/products")
    suspend fun fetchProducts(
        @Query("limit") limit: Int,
        @Query("skip") skip: Int
    ): ProductResponse
}

// Setup logic (abbreviated)
val jsonConverter = Json { ignoreUnknownKeys = true }
val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com")
    .addConverterFactory(jsonConverter.asConverterFactory("application/json".toMediaType()))
    // ... client setup
    .build()

Compose でデータを使用する

Pager を設定したら、UI にデータを表示できます。

  1. フローを収集する: collectAsLazyPagingItems() を使用して、フローを状態認識型の遅延ページング アイテム オブジェクトに変換します。

    val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()
    

    結果の LazyPagingItems オブジェクトは、アイテム数とインデックス付きアクセスを提供するため、リストアイテムのレンダリング用に LazyColumn メソッドで直接使用できます。

  2. LazyColumn にバインド: データを LazyColumn リストに渡します。RecyclerView リストから移行する場合、withLoadStateHeaderAndFooter を使用してリストの上部または下部に読み込みスピナーやエラー再試行ボタンを表示する方法をご存じかもしれません。

    Compose では、このための特別なアダプターは必要ありません。メインの items {} ブロックの前後に item {} ブロックを条件付きで追加し、prepend(ヘッダー)と append(フッター)の読み込み状態に直接反応させることで、まったく同じ動作を実現できます。

    LazyColumn {
        // --- HEADER (Equivalent to loadStateHeader) ---
        // Reacts to 'prepend' states when scrolling towards the top
        if (productPagingData.loadState.prepend is LoadState.Loading) {
            item {
                Box(modifier = Modifier.fillMaxWidth(), contentAlignment =
                Alignment.Center) {
                    CircularProgressIndicator(modifier = Modifier.padding(16.dp))
                }
            }
        }
        if (productPagingData.loadState.prepend is LoadState.Error) {
            item {
                ErrorHeader(onRetry = { productPagingData.retry() })
            }
        }
    
        // --- MAIN LIST ITEMS ---
        items(count = productPagingData.itemCount) { index ->
            val product = productPagingData[index]
            if (product != null) {
                UserPagingListItem(product = product)
            }
        }
    
        // --- FOOTER (Equivalent to loadStateFooter) ---
        // Reacts to 'append' states when scrolling towards the bottom
        if (productPagingData.loadState.append is LoadState.Loading) {
            item {
                Box(modifier = Modifier.fillMaxWidth(), contentAlignment =
                Alignment.Center) {
                    CircularProgressIndicator(modifier = Modifier.padding(16.dp))
                }
            }
        }
        if (productPagingData.loadState.append is LoadState.Error) {
            item {
               ErrorFooter(onRetry = { productPagingData.retry() })
            }
        }
    }
    

Compose の機能を使用してアイテムのコレクションを効果的に表示する方法について詳しくは、リストとグリッドをご覧ください。

読み込み状態を処理する

PagingData オブジェクトは、読み込み状態の情報を統合します。これを使用して、さまざまな状態(refreshappendprepend)の読み込みスピナーやエラー メッセージを表示できます。

不要な再コンポーズを防ぎ、UI が読み込みライフサイクルの意味のある遷移にのみ反応するようにするには、状態のオブザベーションをフィルタする必要があります。loadState は内部変更によって頻繁に更新されるため、複雑な状態変更のために直接読み取ると、スタッターが発生する可能性があります。

この処理を最適化するには、snapshotFlow を使用して状態を監視し、distinctUntilChangedBy プロパティなどの Flow 演算子を適用します。これは、空の状態を表示したり、エラー スナックバーなどの副作用をトリガーしたりする場合に特に便利です。

val snackbarHostState = remember { SnackbarHostState() }

LaunchedEffect(productPagingData.loadState) {
    snapshotFlow { productPagingData.loadState }
        // Filter out updates that don't change the refresh state
        .distinctUntilChangedBy { it.refresh }
        // Only react when the state is an Error
        .filter { it.refresh is LoadState.Error }
        .collect { loadState ->
            val error = (loadState.refresh as LoadState.Error).error
            snackbarHostState.showSnackbar(
                message = "Data failed to load: ${error.localizedMessage}",
                actionLabel = "Retry"
            )
        }
}

全画面の読み込みスピナーを表示するために更新状態を確認する場合は、derivedStateOf を使用して不要な再コンポーズを防ぎます。

さらに、RemoteMediator(前述の Room データベース実装など)を使用している場合は、便宜的な loadState.refresh プロパティではなく、基盤となるデータソース(loadState.source.refresh)の読み込み状態を明示的に検査します。コンビニエンス プロパティは、データベースが新しいアイテムを UI に追加する前に、ネットワーク フェッチが完了したと報告する可能性があります。source をチェックすると、UI がローカル データベースと完全に同期され、ローダーが早すぎるタイミングで消えるのを防ぐことができます。

// Safely check the refresh state for a full-screen spinner
// without triggering unnecessary recompositions
val isRefreshing by remember {
    derivedStateOf { productPagingData.loadState.source.refresh is LoadState.Loading }
}
if (isRefreshing) {
    // Show UI for refreshing (for example, full screen spinner)
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        CircularProgressIndicator()
    }
}

LoadState.Error をチェックして、再試行ボタンやエラー メッセージをユーザーに表示することもできます。LoadState.Error を使用することをおすすめします。これは、基盤となる例外を公開し、ユーザー復元用の組み込み retry() 関数を有効にするためです。

if (refreshState is LoadState.Error) {
   val e = refreshState as LoadState.Error

   // This composable should ideally replace the entire list if the initial load
   // fails.
   ErrorScreen(
       message = "Data failed to load: ${e.error.localizedMessage}",
       onClickRetry = { productPagingData.retry() }
   )
}

実装をテストする

ページネーションの実装をテストすることで、データが正しく読み込まれ、変換が想定どおりに適用され、UI が状態の変化に適切に反応することを確認できます。ページング 3 ライブラリには、このプロセスを簡略化するための専用のテスト アーティファクト(androidx.paging:paging-testing)が用意されています。

まず、テストの依存関係を build.gradle ファイルに追加します。

testImplementation("androidx.paging:paging-testing:$paging_version")

データレイヤをテストする

PagingSource を直接テストするには、TestPager を使用します。このユーティリティは、Paging 3 の基盤となるメカニズムを処理し、完全な Pager の設定を必要とせずに、初期読み込み(更新)、データの追加、データの先頭への追加などのエッジケースを個別に検証できます。

@Test
fun testProductPagingSource() = runTest {
    val pagingSource = ProductPagingSource(mockApiService)

    // Create a TestPager to interact with the PagingSource
    val pager = TestPager(
        config = PAGING_CONFIG,
        pagingSource = pagingSource
    )

    // Trigger an initial load
    val result = pager.refresh() as PagingSource.LoadResult.Page

    // Assert the data size and edge cases like next/prev keys
    assertEquals(50, result.data.size)
    assertNull(result.prevKey)
    assertEquals(50, result.nextKey)
}

ViewModel ロジックと変換をテストする

ViewModelPagingData フローにデータ変換(.map オペレーションなど)を適用する場合は、asPagingSourceFactoryasSnapshot() を使用してこのロジックをテストできます。

asPagingSourceFactory 拡張機能は静的リストを PagingSource に変換し、リポジトリ レイヤのモックを簡単にします。asSnapshot() 拡張機能は PagingData ストリームを標準の Kotlin List に収集し、変換されたデータに対して標準のアサーションを実行できるようにします。

@Test
fun testViewModelTransformations() = runTest {
    // 1. Mock your initial data using asPagingSourceFactory
    val mockProducts = listOf(Product(1, "A"), Product(2, "B"))
    val pagingSourceFactory = mockProducts.asPagingSourceFactory()

    // 2. Pass the mocked factory to your ViewModel or Pager
    val pager = Pager(
        config = PagingConfig(pageSize = 10),
        pagingSourceFactory = pagingSourceFactory
    )

    // 3. Apply your ViewModel transformations (for example, mapping to a UI
    //    model)
    val transformedFlow = pager.flow.map { pagingData ->
        pagingData.map { product -> product.title.uppercase() }
    }

    // 4. Extract the data as a List using asSnapshot()
    val snapshot: List<String> = transformedFlow.asSnapshot(this)

    // 5. Verify the transformation
    assertEquals(listOf("A", "B"), snapshot)
}

状態と再コンポーズを検証する UI テスト

UI をテストする際は、Compose コンポーネントがデータを正しくレンダリングし、読み込み状態に適切に反応することを確認します。PagingData.from()flowOf() を使用して静的 PagingData を渡すことで、データ ストリームをシミュレートできます。また、SideEffect を使用してテスト中に再コンポーズの回数を追跡し、Compose コンポーネントが不必要に再コンポーズされていないことを確認することもできます。

次の例は、読み込み状態をシミュレートし、読み込み済みの状態に移行して、UI ノードと再コンポーズ回数の両方を確認する方法を示しています。

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testProductList_loadingAndDataStates() {
    val context = InstrumentationRegistry.getInstrumentation().targetContext

    // Create a MutableStateFlow to emit different PagingData states over time
    val pagingDataFlow = MutableStateFlow(PagingData.empty<Product>())
    var recompositionCount = 0

    composeTestRule.setContent {
        val lazyPagingItems = pagingDataFlow.collectAsLazyPagingItems()

        // Track recompositions of this composable
        SideEffect { recompositionCount++ }

        ProductListScreen(lazyPagingItems = lazyPagingItems)
    }

    // 1. Simulate initial loading state
    pagingDataFlow.value = PagingData.empty(
        sourceLoadStates = LoadStates(
            refresh = LoadState.Loading,
            prepend = LoadState.NotLoading(endOfPaginationReached = false),
            append = LoadState.NotLoading(endOfPaginationReached = false)
        )
    )

    // Verify that the loading indicator is displayed
    composeTestRule.onNodeWithTag("LoadingSpinner").assertIsDisplayed()

    // 2. Simulate data loaded state
    val mockItems = listOf(
        Product(id = 1, title = context.getString(R.string.product_a_title)),
        Product(id = 2, title = context.getString(R.string.product_b_title))
    )

    pagingDataFlow.value = PagingData.from(
        data = mockItems,
        sourceLoadStates = LoadStates(
            refresh = LoadState.NotLoading(endOfPaginationReached = false),
            prepend = LoadState.NotLoading(endOfPaginationReached = false),
            append = LoadState.NotLoading(endOfPaginationReached = false)
        )
    )

    // Wait for the UI to settle and verify the items are displayed
    composeTestRule.waitForIdle()
    composeTestRule.onNodeWithText(context.getString(R.string.product_a_title)).assertIsDisplayed()
    composeTestRule.onNodeWithText(context.getString(R.string.product_b_title)).assertIsDisplayed()

    // 3. Verify recomposition counts
    // Assert that recompositions are within expected limits (for example,
    // initial composition + updating to load state + updating to data state)
    assert(recompositionCount <= 3) {
        "Expected less than or equal to 3 recompositions, but got $recompositionCount"
    }
}