Panduan ini menjelaskan cara menerapkan Paging 3 dengan Jetpack Compose, yang mencakup penerapan dengan dan tanpa database Room. Penomoran halaman adalah strategi untuk mengelola set data besar dengan memuat dan menampilkannya dalam potongan-potongan kecil yang mudah dikelola, yang disebut halaman, daripada memuat semuanya sekaligus.
Setiap aplikasi yang menampilkan feed scrolling tanpa batas (seperti linimasa media sosial, katalog besar produk e-commerce, atau kotak masuk email yang luas) memerlukan penomoran halaman data yang andal. Karena pengguna biasanya hanya melihat sebagian kecil daftar, dan perangkat seluler memiliki ukuran layar yang terbatas, memuat seluruh set data tidak efisien. Hal ini membuang-buang resource sistem dan dapat menyebabkan jank atau aplikasi macet, sehingga memperburuk pengalaman pengguna. Untuk mengatasinya, Anda dapat menggunakan pemuatan
lambat. Meskipun komponen seperti LazyList di Compose menangani pemuatan lambat
di sisi UI, pemuatan data secara lambat dari disk atau jaringan akan lebih meningkatkan
performa.
Library Paging 3 adalah solusi yang direkomendasikan untuk menangani penomoran halaman data. Jika Anda bermigrasi dari Paging 2, lihat Bermigrasi ke Paging 3 untuk mendapatkan panduan.
Prasyarat
Sebelum melanjutkan, pahami hal-hal berikut:
- Jaringan di Android (kita menggunakan Retrofit dalam dokumen ini, tetapi Paging 3 berfungsi dengan library apa pun, seperti Ktor).
- Toolkit UI Compose.
Menyiapkan dependensi
Tambahkan dependensi berikut ke file build.gradle.kts tingkat aplikasi.
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")
}
Tentukan class Pager
Class Pager adalah titik entri utama untuk penomoran halaman. Metode ini membuat aliran reaktif PagingData. Anda harus membuat instance Pager dan menggunakannya kembali dalam ViewModel.
Pager memerlukan PagingConfig untuk menentukan cara mengambil dan menyajikan
data.
// 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
)
Anda dapat menerapkan Pager dengan dua cara: tanpa database (khusus jaringan) atau dengan database (menggunakan Room).
Mengimplementasikan tanpa database
Saat tidak menggunakan database, Anda memerlukan PagingSource<Key, Value> untuk menangani pemuatan data sesuai permintaan. Dalam contoh ini, kuncinya adalah Int dan nilainya adalah
Product.
Anda harus menerapkan dua metode abstrak di PagingSource:
load: Fungsi penangguhan yang menerimaLoadParams. Gunakan ini untuk mengambil data untuk permintaanRefresh,Append, atauPrepend.getRefreshKey: Menyediakan kunci yang digunakan untuk memuat ulang data jika penomoran halaman dibatalkan. Metode ini menghitung kunci berdasarkan posisi scroll pengguna saat ini (state.anchorPosition).
Contoh kode berikut menunjukkan cara mengimplementasikan class
ProductPagingSource, yang diperlukan untuk menentukan logika pengambilan data
saat menggunakan Paging 3 tanpa database lokal.
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)
}
}
}
Di class ViewModel, buat 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)
Mengimplementasikan dengan database
Saat menggunakan Room, database akan membuat class PagingSource secara otomatis.
Namun, database tidak tahu kapan harus mengambil lebih banyak data dari jaringan. Untuk
menangani hal ini, terapkan RemoteMediator.
Metode RemoteMediator.load() menyediakan loadType (Append, Prepend,
atau Refresh) dan status. Metode ini menampilkan MediatorResult yang menunjukkan keberhasilan atau
kegagalan, dan apakah akhir penomoran halaman telah tercapai.
@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)
}
}
}
Di ViewModel, implementasinya menjadi jauh lebih sederhana karena Room menangani class PagingSource:
val productPager = ProductRepository().fetchProducts().flow.cachedIn(viewModelScope)
Penyiapan jaringan
Contoh sebelumnya mengandalkan layanan jaringan. Bagian ini menyediakan penyiapan Retrofit dan Serialisasi yang digunakan untuk mengambil data dari endpoint api.example.com/products.
Class data
Contoh kode berikut menunjukkan cara menentukan dua class data,
ProductResponse dan Product, yang digunakan dengan
kotlinx.serialization untuk mengurai respons JSON yang di-paging dari
layanan jaringan.
@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 = ""
)
Layanan retrofit
Contoh kode berikut menunjukkan cara menentukan antarmuka layanan Retrofit
(ProductService) untuk implementasi khusus jaringan, yang menentukan
endpoint (@GET("/products")) dan parameter penomoran halaman yang diperlukan (limit)
dan (skip) yang diperlukan oleh library Paging 3 untuk mengambil halaman data.
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()
Menggunakan data di Compose
Setelah menyiapkan Pager, Anda dapat menampilkan data di UI.
Kumpulkan flow: Gunakan
collectAsLazyPagingItems()untuk mengonversi flow menjadi objek item penomoran halaman lambat yang mengetahui status.val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()Objek
LazyPagingItemsyang dihasilkan menyediakan jumlah item dan akses yang diindeks, sehingga dapat langsung digunakan oleh metodeLazyColumnuntuk merender item daftar.Mengikat ke
LazyColumn: Teruskan data ke daftarLazyColumn. Jika Anda bermigrasi dari daftarRecyclerView, Anda mungkin sudah terbiasa menggunakanwithLoadStateHeaderAndFooteruntuk menampilkan spinner pemuatan atau tombol coba lagi error di bagian atas atau bawah daftar Anda.Di Compose, Anda tidak memerlukan adaptor khusus untuk ini. Anda dapat mencapai perilaku yang sama persis dengan menambahkan blok
item {}secara kondisional sebelum atau setelah blokitems {}utama, yang bereaksi langsung terhadap status pemuatanprepend(header) danappend(footer).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() }) } } }
Untuk mengetahui informasi selengkapnya tentang cara fitur Compose memungkinkan Anda menampilkan kumpulan item secara efektif, lihat Daftar dan petak.
Menangani status pemuatan
Objek PagingData mengintegrasikan informasi status pemuatan. Anda dapat menggunakannya
untuk menampilkan spinner pemuatan atau pesan error untuk berbagai status (refresh,
append, atau prepend).
Untuk mencegah rekomposisi yang tidak perlu dan memastikan UI hanya bereaksi terhadap
transisi yang bermakna dalam siklus proses pemuatan, Anda harus memfilter pengamatan
status. Karena loadState sering diupdate dengan perubahan internal,
membacanya secara langsung untuk perubahan status yang kompleks dapat menyebabkan stutter.
Anda dapat mengoptimalkannya dengan menggunakan snapshotFlow untuk mengamati status dan menerapkan
operator Flow seperti properti distinctUntilChangedBy. Hal ini sangat berguna saat menampilkan status kosong atau memicu efek samping, seperti Snackbar error:
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"
)
}
}
Saat memeriksa status pembaruan untuk menampilkan spinner pemuatan layar penuh, gunakan
derivedStateOf untuk mencegah rekomposisi yang tidak perlu.
Selain itu, jika Anda menggunakan RemoteMediator (seperti pada implementasi database Room sebelumnya), periksa secara eksplisit status pemuatan sumber data pokok (loadState.source.refresh), bukan properti loadState.refresh praktis. Properti kemudahan dapat melaporkan bahwa pengambilan
jaringan telah selesai sebelum database selesai menambahkan item baru
ke UI. Memeriksa source menjamin UI sepenuhnya disinkronkan dengan
database lokal, sehingga mencegah pemuat menghilang terlalu dini.
// 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()
}
}
Anda juga dapat memeriksa LoadState.Error untuk menampilkan tombol coba lagi atau pesan
error kepada pengguna. Sebaiknya gunakan LoadState.Error karena menampilkan
pengecualian yang mendasarinya dan mengaktifkan fungsi retry() bawaan untuk pemulihan
pengguna.
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() }
)
}
Menguji implementasi Anda
Menguji penerapan penomoran halaman memastikan data dimuat dengan benar, transformasi diterapkan sesuai harapan, dan UI bereaksi dengan benar terhadap perubahan status. Library Paging 3 menyediakan artefak pengujian khusus
(androidx.paging:paging-testing) untuk menyederhanakan proses ini.
Pertama, tambahkan dependensi pengujian ke file build.gradle Anda:
testImplementation("androidx.paging:paging-testing:$paging_version")
Menguji lapisan data
Untuk menguji PagingSource secara langsung, gunakan TestPager. Utilitas ini
menangani mekanisme dasar Paging 3 dan memungkinkan Anda memverifikasi secara independen
kasus ekstrem, seperti pemuatan awal (Refresh), penambahan, atau penambahan data di awal
tanpa memerlukan penyiapan Pager lengkap.
@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)
}
Menguji logika dan transformasi ViewModel
Jika ViewModel Anda menerapkan transformasi data (seperti operasi .map) ke alur PagingData, Anda dapat menguji logika ini menggunakan asPagingSourceFactory dan asSnapshot().
Ekstensi asPagingSourceFactory mengonversi daftar statis menjadi
PagingSource, sehingga memudahkan peniruan lapisan repositori. Ekstensi
asSnapshot() mengumpulkan aliran PagingData ke List Kotlin standar, sehingga Anda dapat menjalankan pernyataan standar pada data yang diubah.
@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)
}
Pengujian UI untuk memverifikasi status dan rekomposisi
Saat menguji UI, verifikasi bahwa komponen Compose Anda merender data dengan benar dan bereaksi terhadap status pemuatan dengan tepat. Anda dapat meneruskan PagingData
statis menggunakan PagingData.from() dan flowOf() untuk menyimulasikan aliran data.
Selain itu, Anda dapat menggunakan SideEffect untuk melacak jumlah rekomposisi selama pengujian guna memastikan komponen Compose Anda tidak melakukan rekomposisi yang tidak perlu.
Contoh berikut menunjukkan cara menyimulasikan status pemuatan, bertransisi ke status dimuat, dan memverifikasi node UI serta jumlah rekomposisi:
@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"
}
}