Omówienie biblioteki Paging 3

Z tego przewodnika dowiesz się, jak wdrożyć bibliotekę Paging 3 w Jetpack Compose. Omówimy w nim implementacje z bazą danych Room i bez niej. Stronicowanie to strategia zarządzania dużymi zbiorami danych, która polega na wczytywaniu i wyświetlaniu ich w małych, łatwych do zarządzania częściach, zwanych stronami, zamiast wczytywania wszystkiego naraz.

Każda aplikacja z kanałem z przewijaniem nieskończonym (np. oś czasu w mediach społecznościowych, duży katalog produktów e-commerce lub obszerna skrzynka odbiorcza poczty e-mail) wymaga solidnego podziału danych na strony. Użytkownicy zwykle wyświetlają tylko niewielką część listy, a urządzenia mobilne mają ograniczone rozmiary ekranu, więc wczytywanie całego zbioru danych nie jest efektywne. Marnuje to zasoby systemu i może powodować zacinanie się lub zawieszanie aplikacji, co pogarsza wrażenia użytkownika. Aby rozwiązać ten problem, możesz użyć leniwej wczytywania. Komponenty takie jak LazyList w Compose obsługują leniwe ładowanie po stronie interfejsu, ale leniwe ładowanie danych z dysku lub sieci dodatkowo zwiększa wydajność.

Biblioteka Paging 3 to zalecane rozwiązanie do obsługi stronicowania danych. Jeśli migrujesz z biblioteki Paging 2, zapoznaj się z tym przewodnikiem.

Wymagania wstępne

Zanim przejdziesz dalej, zapoznaj się z tymi informacjami:

  • Sieć na Androidzie (w tym dokumencie używamy Retrofit, ale biblioteka Paging 3 działa z dowolną biblioteką, np. Ktor).
  • Zestaw narzędzi interfejsu Compose.

Konfigurowanie zależności

Dodaj te zależności do pliku build.gradle.kts na poziomie aplikacji.

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")
}

Zdefiniuj klasę Pager

Klasa Pager jest głównym punktem wejścia do paginacji. Tworzy strumień reaktywny PagingData. Powinieneś utworzyć instancję Pager i używać jej ponownie w ramach ViewModel.

Pager wymaga PagingConfig, aby określić sposób pobierania i prezentowania danych.

// 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 możesz wdrożyć na 2 sposoby: bez bazy danych (tylko sieć) lub z bazą danych (za pomocą Room).

Wdrażanie bez bazy danych

Jeśli nie używasz bazy danych, potrzebujesz PagingSource<Key, Value> do obsługi ładowania danych na żądanie. W tym przykładzie kluczem jest Int, a wartością jest aProduct.

W klasie PagingSource musisz zaimplementować 2 metody abstrakcyjne:

  • load: funkcja zawieszania, która otrzymuje LoadParams. Użyj tego parametru, aby pobrać dane dla żądań Refresh, Append lub Prepend.

  • getRefreshKey: zawiera klucz używany do ponownego wczytywania danych, jeśli stronicowanie jest nieprawidłowe. Ta metoda oblicza klucz na podstawie bieżącej pozycji przewijania użytkownika (state.anchorPosition).

Poniższy przykład kodu pokazuje, jak zaimplementować klasę ProductPagingSource, która jest niezbędna do zdefiniowania logiki pobierania danych podczas korzystania z biblioteki Paging 3 bez lokalnej bazy danych.

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)
        }
    }
}

Na zajęciach ViewModel utwórz 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)

Implementacja z bazą danych

Podczas korzystania z Room baza danych automatycznie generuje klasę PagingSource. Baza danych nie wie jednak, kiedy pobrać więcej danych z sieci. Aby sobie z tym poradzić, zaimplementuj RemoteMediator.

Metoda RemoteMediator.load() zwraca loadType (Append, Prepend lub Refresh) i stan. Zwraca wartość MediatorResult wskazującą powodzenie lub niepowodzenie oraz informację, czy osiągnięto koniec paginacji.

@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 implementacja jest znacznie prostsza, ponieważ Room obsługuje klasę PagingSource:

val productPager = ProductRepository().fetchProducts().flow.cachedIn(viewModelScope)

Konfiguracja sieci

W poprzednich przykładach wykorzystano usługę sieciową. W tej sekcji znajdziesz konfigurację Retrofit i Serialization używaną do pobierania danych z punktu końcowego api.example.com/products.

Klasy danych

Poniższy przykład kodu pokazuje, jak zdefiniować 2 klasy danych, ProductResponseProduct, które są używane z kotlinx.serialization do analizowania podzielonej na strony odpowiedzi JSON z usługi sieciowej.

@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 = ""
)

Usługa modernizacji

Poniższy przykład kodu pokazuje, jak zdefiniować interfejs usługi Retrofit (ProductService) dla implementacji tylko sieciowej, określając punkt końcowy (@GET("/products")) i niezbędne parametry stronicowania (limit) i (skip) wymagane przez bibliotekę Paging 3 do pobierania stron danych.

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()

Korzystanie z danych w Compose

Po skonfigurowaniu Pager możesz wyświetlać dane w interfejsie.

  1. Zbierz przepływ: użyj collectAsLazyPagingItems(), aby przekształcić przepływ w obiekt elementów leniwego stronicowania z zachowaniem stanu.

    val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()
    

    Powstały obiekt LazyPagingItems zawiera liczbę elementów i indeksowany dostęp, dzięki czemu może być bezpośrednio używany przez metodę LazyColumn do renderowania elementów listy.

  2. Powiąż z LazyColumn: przekaż dane do listy LazyColumn. Jeśli migrujesz z listy RecyclerView, możesz znać sposób używania withLoadStateHeaderAndFooter do wyświetlania spinnerów wczytywania lub przycisków ponawiania błędów u góry lub u dołu listy.

    W Compose nie potrzebujesz do tego specjalnej przejściówki. Możesz uzyskać dokładnie to samo zachowanie, warunkowo dodając blok item {} przed lub po głównym bloku items {}, reagując bezpośrednio na stany wczytywania prepend (nagłówek) i append (stopka).

    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() })
            }
        }
    }
    

Więcej informacji o tym, jak funkcje Compose pozwalają skutecznie wyświetlać kolekcje elementów, znajdziesz w artykule Listy i siatki.

Obsługa stanów wczytywania

Obiekt PagingData zawiera informacje o stanie wczytywania. Możesz użyć tego parametru, aby wyświetlać wskaźniki ładowania lub komunikaty o błędach w różnych stanach (refresh, append lub prepend).

Aby zapobiec niepotrzebnym ponownym kompozycjom i zapewnić, że interfejs będzie reagować tylko na istotne przejścia w cyklu życia ładowania, należy filtrować obserwacje stanu. loadState jest często aktualizowany w wyniku zmian wewnętrznych, więc bezpośrednie odczytywanie go w przypadku złożonych zmian stanu może powodować zacinanie się.

Możesz to zoptymalizować, używając snapshotFlow do obserwowania stanu i stosując operatory Flow, takie jak właściwość distinctUntilChangedBy. Jest to szczególnie przydatne podczas wyświetlania pustych stanów lub wywoływania efektów ubocznych, takich jak pasek powiadomień o błędzie:

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"
            )
        }
}

Podczas sprawdzania stanu odświeżania w celu wyświetlenia pełnoekranowego wskaźnika wczytywania używaj derivedStateOf, aby zapobiec niepotrzebnym ponownym kompozycjom.

Jeśli używasz RemoteMediator (np. w implementacji bazy danych Room), wyraźnie sprawdzaj stan wczytywania źródła danych (loadState.source.refresh), a nie właściwość loadState.refresh. Właściwość wygody może zgłosić, że pobieranie z sieci zostało zakończone, zanim baza danych zakończy dodawanie nowych elementów do interfejsu. Zaznaczenie tego pola source gwarantuje, że interfejs będzie w pełni zsynchronizowany z lokalną bazą danych, co zapobiegnie zbyt wczesnemu zniknięciu wskaźnika ładowania.

// 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()
    }
}

Możesz też sprawdzić, czy występuje LoadState.Error, aby wyświetlić użytkownikowi przyciski ponownej próby lub komunikaty o błędach. Zalecamy używanie LoadState.Error, ponieważ ujawnia on podstawowy wyjątek i umożliwia korzystanie z wbudowanej funkcji retry() na potrzeby przywracania danych przez użytkownika.

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() }
   )
}

Testowanie implementacji

Testowanie implementacji paginacji zapewnia prawidłowe wczytywanie danych, stosowanie przekształceń zgodnie z oczekiwaniami oraz prawidłowe reagowanie interfejsu na zmiany stanu. Biblioteka Paging 3 udostępnia specjalny artefakt testowy (androidx.paging:paging-testing), który upraszcza ten proces.

Najpierw dodaj zależność testową do pliku build.gradle:

testImplementation("androidx.paging:paging-testing:$paging_version")

Testowanie warstwy danych

Aby bezpośrednio przetestować PagingSource, użyj TestPager. To narzędzie obsługuje podstawowe mechanizmy biblioteki Paging 3 i umożliwia niezależne weryfikowanie przypadków brzegowych, takich jak początkowe wczytywanie (odświeżanie), dołączanie lub dodawanie danych bez konieczności pełnej konfiguracji 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)
}

Testowanie logiki i przekształceń ViewModel

Jeśli ViewModel stosuje do przepływu PagingData przekształcenia danych (np. operacje .map), możesz przetestować tę logikę za pomocą asPagingSourceFactoryasSnapshot().

Rozszerzenie asPagingSourceFactory przekształca statyczną listę w PagingSource, co ułatwia tworzenie wersji próbnej warstwy repozytorium. Rozszerzenie asSnapshot() zbiera strumień PagingData w standardowym strumieniu Kotlin List, co umożliwia uruchamianie standardowych asercji na przekształconych danych.

@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)
}

Testy interfejsu, które weryfikują stany i ponowne kompozycje

Podczas testowania interfejsu sprawdź, czy komponenty Compose prawidłowo renderują dane i odpowiednio reagują na stany ładowania. Możesz przekazywać statyczne PagingData za pomocą PagingData.from()flowOf(), aby symulować strumienie danych. Możesz też użyć SideEffect, aby śledzić liczbę ponownych kompozycji podczas testów i upewnić się, że komponenty Compose nie są ponownie komponowane bez potrzeby.

Poniższy przykład pokazuje, jak symulować stan ładowania, przejść do stanu załadowania i sprawdzić zarówno węzły interfejsu, jak i liczbę ponownych kompozycji:

@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"
    }
}