本指南說明如何使用 Jetpack Compose 實作 Paging 3,涵蓋使用和不使用 Room 資料庫的實作方式。分頁是一種策略,可將大型資料集載入並顯示為易於管理的小區塊 (稱為頁面),而非一次載入所有內容。
凡是提供無限捲動動態消息的應用程式 (例如社群媒體時間軸、大型電子商務產品目錄或大量電子郵件收件匣),都需要健全的資料分頁功能。使用者通常只會查看清單的一小部分,而且行動裝置的螢幕尺寸有限,因此載入整個資料集並不有效率。這會浪費系統資源,並可能導致應用程式卡頓或凍結,進而影響使用者體驗。如要解決這個問題,可以使用延遲載入。雖然 Compose 中的 LazyList 等元件會在 UI 端處理延遲載入,但從磁碟或網路延遲載入資料,可進一步提升效能。
建議使用 Paging 3 程式庫處理資料分頁。如要從 Paging 2 遷移,請參閱「遷移至 Paging 3」指南。
必要條件
請先詳閱下列文章,再繼續操作:
設定依附元件
在應用程式層級的 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的暫停函式。用來擷取Refresh、Append或Prepend要求的資料。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 (Append、Prepend 或 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 和序列化設定。
資料類別
下列程式碼範例說明如何定義兩個資料類別 ProductResponse 和 Product,這兩個類別會與 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 中顯示資料。
收集流程:使用
collectAsLazyPagingItems()將流程轉換為可感知狀態的延遲分頁項目物件。val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()產生的
LazyPagingItems物件會提供項目計數和索引存取權,因此LazyColumn方法可直接使用該物件,以便算繪清單項目。繫結至
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 物件會整合載入狀態資訊。您可以使用這項功能,針對不同狀態 (refresh、append 或 prepend) 顯示載入微調器或錯誤訊息。
為避免不必要的重組,並確保 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 作業),您可以使用 asPagingSourceFactory 和 asSnapshot() 測試這項邏輯。
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"
}
}