Paging 3'e genel bakış

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: LoadParams alan bir askıya alma işlevi. Refresh, Append veya Prepend istekleriyle 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.

  1. 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 LazyPagingItems nesnesi, öğe sayıları ve dizine eklenmiş erişim sağlar. Bu sayede, liste öğelerini oluşturmak için LazyColumn yöntemi tarafından doğrudan kullanılabilir.

  2. LazyColumn ile bağlama: Verileri bir LazyColumn listesine aktarın. RecyclerView listesinden 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çin withLoadStateHeaderAndFooter kullanmaya alışkın olabilirsiniz.

    Compose'da bunun için özel bir adaptöre ihtiyacınız yoktur. item {} bloğunu ana items {} bloğunuzdan önce veya sonra koşullu olarak ekleyerek, doğrudan prepend (üstbilgi) ve append (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"
    }
}