Hướng dẫn này giải thích cách triển khai Paging 3 bằng Jetpack Compose, bao gồm cả các phương thức triển khai có và không có cơ sở dữ liệu Room. Phân trang là một chiến lược để quản lý các tập dữ liệu lớn bằng cách tải và hiển thị chúng theo các phần nhỏ, dễ quản lý (gọi là trang) thay vì tải mọi thứ cùng một lúc.
Mọi ứng dụng có nguồn cấp dữ liệu cuộn vô hạn (chẳng hạn như dòng thời gian trên mạng xã hội, danh mục lớn gồm các sản phẩm thương mại điện tử hoặc hộp thư đến rộng lớn) đều yêu cầu tính năng phân trang dữ liệu mạnh mẽ. Vì người dùng thường chỉ xem một phần nhỏ của danh sách và thiết bị di động có kích thước màn hình hạn chế, nên việc tải toàn bộ tập dữ liệu không hiệu quả. Việc này làm lãng phí tài nguyên hệ thống và có thể gây ra hiện tượng giật hoặc ứng dụng bị treo, làm giảm trải nghiệm người dùng. Để giải quyết vấn đề này, bạn có thể sử dụng tính năng tải từng phần. Mặc dù các thành phần như LazyList trong Compose xử lý tính năng tải từng phần ở phía giao diện người dùng, nhưng việc tải dữ liệu từng phần từ ổ đĩa hoặc mạng sẽ giúp nâng cao hiệu suất hơn nữa.
Thư viện Paging 3 là giải pháp được đề xuất để xử lý việc phân trang dữ liệu. Nếu bạn đang di chuyển từ Paging 2, hãy xem bài viết Di chuyển sang Paging 3 để được hướng dẫn.
Điều kiện tiên quyết
Trước khi tiếp tục, hãy làm quen với những điều sau:
- Kết nối mạng trên Android (chúng tôi sử dụng Retrofit trong tài liệu này, nhưng Paging 3 hoạt động với mọi thư viện, chẳng hạn như Ktor).
- Bộ công cụ Compose UI.
Thiết lập phần phụ thuộc
Thêm các phần phụ thuộc sau vào tệp build.gradle.kts ở cấp ứng dụng.
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")
}
Xác định lớp Pager
Lớp Pager là điểm truy cập chính để phân trang. Thao tác này tạo một luồng phản ứng của PagingData. Bạn nên tạo thực thể Pager và sử dụng lại trong ViewModel.
Pager yêu cầu PagingConfig để xác định cách tìm nạp và trình bày dữ liệu.
// 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
)
Bạn có thể triển khai Pager theo 2 cách: không có cơ sở dữ liệu (chỉ mạng) hoặc có cơ sở dữ liệu (sử dụng Room).
Triển khai mà không cần cơ sở dữ liệu
Khi không sử dụng cơ sở dữ liệu, bạn cần có một PagingSource<Key, Value> để xử lý việc tải dữ liệu theo yêu cầu. Trong ví dụ này, khoá là Int và giá trị là Product.
Bạn phải triển khai 2 phương thức trừu tượng trong PagingSource:
load: Một hàm tạm ngưng nhậnLoadParams. Sử dụng phương thức này để tìm nạp dữ liệu cho các yêu cầuRefresh,AppendhoặcPrepend.getRefreshKey: Cung cấp khoá dùng để tải lại dữ liệu nếu bộ phân trang không hợp lệ. Phương thức này tính toán khoá dựa trên vị trí cuộn hiện tại của người dùng (state.anchorPosition).
Ví dụ về mã sau đây cho biết cách triển khai lớp ProductPagingSource. Lớp này là cần thiết để xác định logic tìm nạp dữ liệu khi sử dụng Paging 3 mà không có cơ sở dữ liệu cục bộ.
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)
}
}
}
Trong lớp ViewModel, hãy tạo 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)
Triển khai bằng cơ sở dữ liệu
Khi sử dụng Room, cơ sở dữ liệu sẽ tự động tạo lớp PagingSource.
Tuy nhiên, cơ sở dữ liệu không biết thời điểm cần tìm nạp thêm dữ liệu từ mạng. Để xử lý việc này, hãy triển khai RemoteMediator.
Phương thức RemoteMediator.load() cung cấp loadType (Append, Prepend hoặc Refresh) và trạng thái. Phương thức này trả về một MediatorResult cho biết trạng thái thành công hoặc không thành công và liệu đã đến cuối quá trình phân trang hay chưa.
@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)
}
}
}
Trong ViewModel, quá trình triển khai sẽ đơn giản hơn đáng kể vì Room xử lý lớp PagingSource:
val productPager = ProductRepository().fetchProducts().flow.cachedIn(viewModelScope)
Thiết lập mạng
Các ví dụ trước dựa vào một dịch vụ mạng. Phần này cung cấp chế độ thiết lập Retrofit và Serialization được dùng để tìm nạp dữ liệu từ điểm cuối api.example.com/products.
Lớp dữ liệu
Ví dụ về mã sau đây cho thấy cách xác định 2 lớp dữ liệu, ProductResponse và Product, được dùng với kotlinx.serialization để phân tích cú pháp phản hồi JSON được phân trang từ dịch vụ mạng.
@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 = ""
)
Dịch vụ bổ sung tính năng
Ví dụ về mã sau đây cho biết cách xác định giao diện dịch vụ Retrofit (ProductService) cho quá trình triển khai chỉ trên mạng, chỉ định điểm cuối (@GET("/products")) và các tham số phân trang cần thiết (limit) và (skip) mà thư viện Phân trang 3 yêu cầu để tìm nạp các trang dữ liệu.
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()
Sử dụng dữ liệu trong Compose
Sau khi thiết lập Pager, bạn có thể hiển thị dữ liệu trong giao diện người dùng.
Thu thập luồng: Sử dụng
collectAsLazyPagingItems()để chuyển đổi luồng thành một đối tượng mục phân trang lười biếng có nhận biết trạng thái.val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()Đối tượng
LazyPagingItemsthu được cung cấp số lượng mục và quyền truy cập được lập chỉ mục, cho phép phương thứcLazyColumnsử dụng trực tiếp đối tượng này để hiển thị các mục trong danh sách.Liên kết với
LazyColumn: Truyền dữ liệu đến danh sáchLazyColumn. Nếu đang di chuyển từ danh sáchRecyclerView, có thể bạn đã quen với việc sử dụngwithLoadStateHeaderAndFooterđể hiển thị các nút thử lại lỗi hoặc vòng quay tải ở đầu hoặc cuối danh sách.Trong Compose, bạn không cần một bộ chuyển đổi đặc biệt cho việc này. Bạn có thể đạt được hành vi giống hệt như vậy bằng cách thêm có điều kiện một khối
item {}trước hoặc sau khốiitems {}chính, phản ứng trực tiếp với các trạng thái tảiprepend(tiêu đề) vàappend(chân trang).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() }) } } }
Để biết thêm thông tin về cách các tính năng của Compose giúp bạn hiển thị hiệu quả các tập hợp mục, hãy xem bài viết Danh sách và lưới.
Xử lý trạng thái tải
Đối tượng PagingData tích hợp thông tin về trạng thái tải. Bạn có thể dùng thuộc tính này để hiện các biểu tượng tải hoặc thông báo lỗi cho các trạng thái khác nhau (refresh, append hoặc prepend).
Để ngăn các lần kết hợp lại không cần thiết và đảm bảo giao diện người dùng chỉ phản ứng với các quá trình chuyển đổi có ý nghĩa trong vòng đời tải, bạn nên lọc các lượt quan sát trạng thái. Vì loadState thường xuyên cập nhật các thay đổi nội bộ, nên việc đọc trực tiếp loadState cho các thay đổi phức tạp về trạng thái có thể gây ra hiện tượng giật.
Bạn có thể tối ưu hoá việc này bằng cách sử dụng snapshotFlow để theo dõi trạng thái và áp dụng các toán tử Flow như thuộc tính distinctUntilChangedBy. Điều này đặc biệt hữu ích khi hiển thị trạng thái trống hoặc kích hoạt các hiệu ứng phụ, chẳng hạn như Snackbar lỗi:
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"
)
}
}
Khi kiểm tra trạng thái làm mới để hiện vòng quay tải toàn màn hình, hãy dùng derivedStateOf để ngăn việc kết hợp lại không cần thiết.
Ngoài ra, nếu bạn đang sử dụng RemoteMediator (chẳng hạn như trong quá trình triển khai cơ sở dữ liệu Room trước đó), hãy kiểm tra rõ ràng trạng thái tải của nguồn dữ liệu cơ bản (loadState.source.refresh) thay vì thuộc tính tiện lợi loadState.refresh. Thuộc tính tiện lợi có thể báo cáo rằng quá trình tìm nạp mạng đã hoàn tất trước khi cơ sở dữ liệu hoàn tất việc thêm các mục mới vào giao diện người dùng. Việc kiểm tra source đảm bảo giao diện người dùng hoàn toàn đồng bộ hoá với cơ sở dữ liệu cục bộ, ngăn trình tải biến mất quá sớm.
// 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()
}
}
Bạn cũng có thể kiểm tra LoadState.Error để hiển thị các nút thử lại hoặc thông báo lỗi cho người dùng. Bạn nên sử dụng LoadState.Error vì hàm này cho thấy ngoại lệ cơ bản và cho phép hàm retry() tích hợp để khôi phục người dùng.
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() }
)
}
Kiểm thử kết quả triển khai
Việc kiểm thử quá trình triển khai phân trang giúp đảm bảo rằng dữ liệu tải chính xác, các phép biến đổi được áp dụng như mong đợi và giao diện người dùng phản ứng đúng cách với các thay đổi về trạng thái. Thư viện Phân trang 3 cung cấp một cấu phần phần mềm kiểm thử chuyên dụng (androidx.paging:paging-testing) để đơn giản hoá quy trình này.
Trước tiên, hãy thêm phần phụ thuộc kiểm thử vào tệp build.gradle:
testImplementation("androidx.paging:paging-testing:$paging_version")
Kiểm thử lớp dữ liệu
Để kiểm thử trực tiếp PagingSource, hãy sử dụng TestPager. Tiện ích này xử lý cơ chế cơ bản của Phân trang 3 và cho phép bạn xác minh độc lập các trường hợp đặc biệt, chẳng hạn như tải ban đầu (Làm mới), thêm hoặc thêm dữ liệu mà không cần thiết lập đầy đủ 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)
}
Kiểm thử logic và các phép biến đổi ViewModel
Nếu ViewModel của bạn áp dụng các phép biến đổi dữ liệu (chẳng hạn như các thao tác .map) cho luồng PagingData, bạn có thể kiểm thử logic này bằng cách sử dụng asPagingSourceFactory và asSnapshot().
Tiện ích asPagingSourceFactory chuyển đổi một danh sách tĩnh thành PagingSource, giúp việc mô phỏng lớp kho lưu trữ trở nên đơn giản hơn. Tiện ích asSnapshot() sẽ thu thập luồng PagingData thành một List Kotlin tiêu chuẩn, cho phép bạn chạy các câu lệnh khẳng định tiêu chuẩn trên dữ liệu đã chuyển đổi.
@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)
}
Kiểm thử giao diện người dùng để xác minh các trạng thái và quá trình kết hợp lại
Khi kiểm thử giao diện người dùng, hãy xác minh rằng các thành phần Compose của bạn hiển thị dữ liệu chính xác và phản ứng với các trạng thái tải một cách phù hợp. Bạn có thể truyền PagingData tĩnh bằng cách sử dụng PagingData.from() và flowOf() để mô phỏng luồng dữ liệu.
Ngoài ra, bạn có thể sử dụng SideEffect để theo dõi số lần kết hợp lại trong quá trình kiểm thử để đảm bảo các thành phần Compose của bạn không kết hợp lại một cách không cần thiết.
Ví dụ sau đây minh hoạ cách mô phỏng trạng thái tải, chuyển đổi sang trạng thái đã tải và xác minh cả các nút trên giao diện người dùng cũng như số lần kết hợp lại:
@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"
}
}