Bu kılavuzda, Jetpack Compose ile Paging 3'ün nasıl uygulanacağı açıklanmaktadır. Room veritabanı olan ve olmayan uygulamalar ele alınmaktadır. Sayfalama, büyük veri kümelerini tek seferde yüklemek yerine küçük ve yönetilebilir parçalar (sayfalar) halinde yükleyip görüntüleyerek yönetme stratejisidir.
Sonsuz kaydırma feed'i (ör. sosyal medya zaman çizelgesi, büyük bir e-ticaret ürünleri kataloğu veya kapsamlı bir e-posta gelen kutusu) içeren tüm uygulamalarda sağlam veri sayfalandırması gerekir. Kullanıcılar genellikle bir listenin yalnızca küçük bir bölümünü görüntülediği ve mobil cihazların ekran boyutları sınırlı olduğu için veri kümesinin tamamını yüklemek verimli değildir. Sistem kaynaklarını boşa harcar ve kullanıcı deneyimini kötüleştirerek takılmaya veya uygulamanın donmasına neden olabilir. Bu sorunu çözmek için geç yükleme özelliğini kullanabilirsiniz. Compose'daki LazyList gibi bileşenler, kullanıcı arayüzü tarafında geç yüklemeyi işlerken verilerin diskten veya ağdan geç yüklenmesi performansı daha da artırır.
Veri sayfalandırmasını işlemek için Paging 3 kitaplığı önerilen çözümdür. Paging 2'den geçiş yapıyorsanız rehberlik için Paging 3'e geçiş başlıklı makaleyi inceleyin.
Ön koşullar
Devam etmeden önce aşağıdakiler hakkında bilgi edinin:
- Android'de ağ oluşturma (Bu belgede Retrofit kullanıyoruz ancak Paging 3, Ktor gibi herhangi bir kitaplıkla çalışır).
- Compose kullanıcı arayüzü araç seti.
Bağımlılıkları ayarlama
Uygulama düzeyindeki build.gradle.kts dosyanıza aşağıdaki bağımlılıkları ekleyin.
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 sınıfını tanımlama
Pager sınıfı, sayfalama için birincil giriş noktasıdır. PagingData öğesinin reaktif akışını oluşturur. Pager öğesini oluşturmanız ve ViewModel içinde yeniden kullanmanız gerekir.
Pager, verilerin nasıl getirileceğini ve sunulacağını belirlemek için PagingConfig gerektirir.
// 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 iki şekilde uygulanabilir: veritabanı olmadan (yalnızca ağ) veya veritabanıyla (Room kullanılarak).
Veritabanı olmadan uygulama
Veritabanı kullanmadığınızda, isteğe bağlı veri yüklemeyi işlemek için PagingSource<Key, Value> gerekir. Bu örnekte anahtar Int, değer ise Product'dir.
PagingSource içinde iki soyut yöntem uygulamanız gerekir:
load:LoadParamsalan bir askıya alma işlevi.Refresh,AppendveyaPrependistekleriyle ilgili verileri getirmek için bu kodu kullanın.getRefreshKey: Sayfalayıcı geçersiz kılınırsa verileri yeniden yüklemek için kullanılan anahtarı sağlar. Bu yöntem, anahtarı kullanıcının mevcut kaydırma konumuna (state.anchorPosition) göre hesaplar.
Aşağıdaki kod örneğinde, Paging 3'ü yerel veritabanı olmadan kullanırken veri getirme mantığını tanımlamak için gerekli olan ProductPagingSource sınıfının nasıl uygulanacağı gösterilmektedir.
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 sınıfınızda Pager oluşturun:
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)
Veritabanıyla uygulama
Room kullanılırken veritabanı PagingSource sınıfını otomatik olarak oluşturur.
Ancak veritabanı, ağdan daha fazla verinin ne zaman alınacağını bilmez. Bunu ele almak için RemoteMediator uygulayın.
RemoteMediator.load() yöntemi, loadType (Append, Prepend veya Refresh) ve eyaleti sağlar. Başarı veya başarısızlığı ve sayfalama sonuna ulaşılıp ulaşılmadığını belirten bir MediatorResult döndürür.
@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 içinde, Room PagingSource sınıfını işlediği için uygulama önemli ölçüde basitleşir:
val productPager = ProductRepository().fetchProducts().flow.cachedIn(viewModelScope)
Ağ kurulumu
Önceki örneklerde bir ağ hizmeti kullanılıyordu. Bu bölümde, api.example.com/products uç noktasından veri getirmek için kullanılan Retrofit ve Serialization kurulumu sağlanmaktadır.
Veri sınıfları
Aşağıdaki kod örneğinde, ağ hizmetinden gelen sayfalandırılmış JSON yanıtını ayrıştırmak için kotlinx.serialization ile kullanılan ProductResponse ve Product olmak üzere iki veri sınıfının nasıl tanımlanacağı gösterilmektedir.
@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 = ""
)
Güçlendirme hizmeti
Aşağıdaki kod örneğinde, yalnızca ağ için uygulamada Retrofit hizmet arayüzünün (ProductService) nasıl tanımlanacağı gösterilmektedir. Bu örnekte, Paging 3 kitaplığının veri sayfalarını getirmek için ihtiyaç duyduğu uç nokta (@GET("/products")) ve gerekli sayfalama parametreleri (limit) ve (skip) belirtilmektedir.
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'da veri kullanma
Pager ayarlarınızı yaptıktan sonra verileri kullanıcı arayüzünüzde gösterebilirsiniz.
Akışı topla: Akışı duruma duyarlı tembel sayfalama öğeleri nesnesine dönüştürmek için
collectAsLazyPagingItems()kullanın.val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()Elde edilen
LazyPagingItemsnesnesi, öğe sayıları ve dizine eklenmiş erişim sağlar. Bu sayede, liste öğelerini oluşturmak içinLazyColumnyöntemi tarafından doğrudan kullanılabilir.LazyColumnile bağlama: Verileri birLazyColumnlistesine aktarın.RecyclerViewlistesinden taşıma yapıyorsanız listenizin üst veya alt kısmında yükleme animasyonlarını ya da hata yeniden deneme düğmelerini göstermek içinwithLoadStateHeaderAndFooterkullanmaya alışkın olabilirsiniz.Compose'da bunun için özel bir adaptöre ihtiyacınız yoktur.
item {}bloğunu anaitems {}bloğunuzdan önce veya sonra koşullu olarak ekleyerek, doğrudanprepend(üstbilgi) veappend(altbilgi) yükleme durumlarına tepki vererek aynı davranışı elde edebilirsiniz.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'un özellikleriyle öğe koleksiyonlarını nasıl etkili bir şekilde görüntüleyebileceğiniz hakkında daha fazla bilgi için Listeler ve ızgaralar başlıklı makaleyi inceleyin.
Yükleme durumlarını yönetme
PagingData nesnesi, yükleme durumu bilgilerini entegre eder. Bu işlevi, farklı durumlar (refresh, append veya prepend) için yükleme animasyonları ya da hata mesajları göstermek amacıyla kullanabilirsiniz.
Gereksiz yeniden oluşturmaları önlemek ve kullanıcı arayüzünün yalnızca yükleme yaşam döngüsündeki anlamlı geçişlere tepki vermesini sağlamak için durum gözlemlerinizi filtrelemeniz gerekir. loadState, dahili değişikliklerle sık sık güncellendiğinden karmaşık durum değişiklikleri için doğrudan okunması takılmalara neden olabilir.
Durumu gözlemlemek için snapshotFlow'ı kullanarak ve distinctUntilChangedBy özelliği gibi akış operatörleri uygulayarak bunu optimize edebilirsiniz. Bu özellik, özellikle boş durumları görüntülerken veya hata Snackbar'ı gibi yan etkileri tetiklerken yararlıdır:
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"
)
}
}
Tam ekran yükleme animasyonu göstermek için yenileme durumunu kontrol ederken gereksiz yeniden oluşturmaları önlemek üzere derivedStateOf kullanın.
Ayrıca, RemoteMediator kullanıyorsanız (örneğin, daha önce Room veritabanı uygulamasında olduğu gibi) loadState.refresh kolaylık özelliği yerine temel veri kaynağının (loadState.source.refresh) yükleme durumunu açıkça inceleyin. Kolaylık özelliği, veritabanı yeni öğeleri kullanıcı arayüzüne eklemeyi bitirmeden önce ağ getirme işleminin tamamlandığını bildirebilir. source kontrolü, kullanıcı arayüzünün yerel veritabanıyla tamamen senkronize olmasını sağlar ve yükleyicinin çok erken kaybolmasını önler.
// 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()
}
}
Yeniden deneme düğmelerini veya hata mesajlarını kullanıcıya göstermek için LoadState.Error değerini de kontrol edebilirsiniz. Temel istisnayı ortaya çıkardığı ve kullanıcı kurtarma için yerleşik retry() işlevini etkinleştirdiği için LoadState.Error kullanmanızı öneririz.
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() }
)
}
Uygulamanızı test etme
Sayfalara ayırma uygulamanızı test etmek, verilerin doğru şekilde yüklendiğinden, dönüşümlerin beklendiği gibi uygulandığından ve kullanıcı arayüzünün durum değişikliklerine doğru şekilde tepki verdiğinden emin olmanızı sağlar. Paging 3 kitaplığı, bu süreci basitleştirmek için özel bir test yapısı (androidx.paging:paging-testing) sağlar.
Öncelikle, test bağımlılığını build.gradle dosyanıza ekleyin:
testImplementation("androidx.paging:paging-testing:$paging_version")
Veri katmanını test etme
PagingSource öğenizi doğrudan test etmek için TestPager kullanın. Bu yardımcı program, Paging 3'ün temel mekanizmalarını yönetir ve tam bir Pager kurulumuna gerek kalmadan ilk yüklemeler (yenileme), verileri ekleme veya verilerin başına ekleme gibi uç durumları bağımsız olarak doğrulamanıza olanak tanır.
@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 mantığını ve dönüşümleri test etme
ViewModel, PagingData akışına veri dönüşümleri (ör. .map işlemleri) uyguluyorsa bu mantığı asPagingSourceFactory ve asSnapshot() kullanarak test edebilirsiniz.
asPagingSourceFactory uzantısı, statik bir listeyi PagingSource öğesine dönüştürerek depo katmanının taklit edilmesini kolaylaştırır. asSnapshot() uzantısı, PagingData akışını standart bir Kotlin List içinde toplar. Böylece, dönüştürülmüş veriler üzerinde standart onaylamalar çalıştırabilirsiniz.
@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)
}
Durumları ve yeniden oluşturmaları doğrulamak için kullanıcı arayüzü testleri
Kullanıcı arayüzünü test ederken Compose bileşenlerinizin verileri doğru şekilde oluşturduğunu ve yükleme durumlarına uygun şekilde tepki verdiğini doğrulayın. Veri akışlarını simüle etmek için PagingData kullanarak statik PagingData.from() ve flowOf() iletebilirsiniz.
Ayrıca, Compose bileşenlerinizin gereksiz yere yeniden oluşturulmadığından emin olmak için testleriniz sırasında yeniden oluşturma sayılarını izlemek üzere SideEffect kullanabilirsiniz.
Aşağıdaki örnekte, yükleme durumunun nasıl simüle edileceği, yüklenen duruma nasıl geçileceği ve hem kullanıcı arayüzü düğümlerinin hem de yeniden oluşturma sayısının nasıl doğrulanacağı gösterilmektedir:
@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"
}
}