Paging 3 總覽

本指南說明如何使用 Jetpack Compose 實作 Paging 3,涵蓋使用和不使用 Room 資料庫的實作方式。分頁是一種策略,可將大型資料集載入並顯示為易於管理的小區塊 (稱為頁面),而非一次載入所有內容。

凡是提供無限捲動動態消息的應用程式 (例如社群媒體時間軸、大型電子商務產品目錄或大量電子郵件收件匣),都需要健全的資料分頁功能。使用者通常只會查看清單的一小部分,而且行動裝置的螢幕尺寸有限,因此載入整個資料集並不有效率。這會浪費系統資源,並可能導致應用程式卡頓或凍結,進而影響使用者體驗。如要解決這個問題,可以使用延遲載入。雖然 Compose 中的 LazyList 等元件會在 UI 端處理延遲載入,但從磁碟或網路延遲載入資料,可進一步提升效能。

建議使用 Paging 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)。

不使用資料庫實作

如果未使用資料庫,則需要 PagingSource<Key, Value> 來處理隨選資料載入作業。在本範例中,鍵為 Int,值為 Product

您必須在 PagingSource 中實作兩個抽象方法:

  • load:接收 LoadParams 的暫停函式。用來擷取 RefreshAppendPrepend 要求的資料。

  • getRefreshKey:提供用於重新載入資料的金鑰 (如果分頁器失效)。這個方法會根據使用者目前的捲動位置 (state.anchorPosition) 計算金鑰。

下列程式碼範例說明如何實作 ProductPagingSource 類別。使用 Paging 3 時,如果沒有本機資料庫,就必須實作這個類別,才能定義資料擷取邏輯。

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() 方法會提供 loadType (AppendPrependRefresh) 和狀態。這個方法會傳回 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 和序列化設定。

資料類別

下列程式碼範例說明如何定義兩個資料類別 ProductResponseProduct,這兩個類別會與 kotlinx.serialization 搭配使用,剖析來自網路服務的分頁 JSON 回應。

@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 服務介面 (ProductService),並指定端點 (@GET("/products")) 和 Paging 3 程式庫擷取資料頁面時所需的必要分頁參數 (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 運算子,藉此進行最佳化。顯示空白狀態或觸發副作用 (例如錯誤 Snackbar) 時,這項功能特別實用:

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.source.refresh) 的載入狀態,而非便利的 loadState.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 能正確回應狀態變化。為簡化這項程序,Paging 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 邏輯和轉換

如果您的 ViewModel 會對 PagingData 流程套用資料轉換 (例如 .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"
    }
}