Paging 3 – Übersicht

In diesem Leitfaden wird beschrieben, wie Sie Paging 3 mit Jetpack Compose implementieren. Dabei werden Implementierungen mit und ohne Room-Datenbank behandelt. Die Paginierung ist eine Strategie zum Verwalten großer Datasets, bei der diese in kleinen, überschaubaren Abschnitten (Seiten) geladen und angezeigt werden, anstatt alles auf einmal zu laden.

Für jede App mit einem Feed mit unendlichem Scrollen (z. B. eine Timeline in sozialen Medien, ein großer Katalog mit E-Commerce-Produkten oder ein umfangreicher E-Mail-Posteingang) ist eine robuste Datenpaginierung erforderlich. Da Nutzer in der Regel nur einen kleinen Teil einer Liste sehen und Mobilgeräte nur eine begrenzte Bildschirmgröße haben, ist es nicht effizient, den gesamten Datensatz zu laden. Das verschwendet Systemressourcen und kann zu Ruckeln oder zum Einfrieren der App führen, was die Nutzerfreundlichkeit beeinträchtigt. Um dieses Problem zu beheben, können Sie Lazy Loading verwenden. Während Komponenten wie LazyList in Compose das Lazy Loading auf der UI-Seite übernehmen, wird die Leistung durch das verzögerte Laden von Daten von der Festplatte oder aus dem Netzwerk weiter verbessert.

Die Paging 3-Bibliothek ist die empfohlene Lösung für die Verarbeitung der Datenpaginierung. Wenn Sie von Paging 2 migrieren, finden Sie unter Zu Paging 3 migrieren eine Anleitung.

Voraussetzungen

Bevor Sie fortfahren, sollten Sie sich mit Folgendem vertraut machen:

  • Netzwerkfunktionen unter Android (in diesem Dokument verwenden wir Retrofit, aber Paging 3 funktioniert mit jeder Bibliothek, z. B. Ktor).
  • Das Compose-UI-Toolkit.

Abhängigkeiten einrichten

Fügen Sie die folgenden Abhängigkeiten der Datei build.gradle.kts auf App-Ebene hinzu.

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-Klasse definieren

Die Klasse Pager ist der primäre Einstiegspunkt für die Paginierung. Erstellt einen reaktiven Stream von PagingData. Sie sollten Pager instanziieren und in Ihrem ViewModel wiederverwenden.

Für Pager ist ein PagingConfig erforderlich, um zu bestimmen, wie Daten abgerufen und präsentiert werden.

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

Sie können Pager auf zwei Arten implementieren: ohne Datenbank (nur Netzwerk) oder mit Datenbank (mit Room).

Ohne Datenbank implementieren

Wenn Sie keine Datenbank verwenden, benötigen Sie ein PagingSource<Key, Value>, um das On-Demand-Laden von Daten zu verarbeiten. In diesem Beispiel ist der Schlüssel Int und der Wert ein Product.

Sie müssen zwei abstrakte Methoden in Ihrem PagingSource implementieren:

  • load: Eine unterbrechbare Funktion, die LoadParams empfängt. Damit können Sie Daten für Refresh-, Append- oder Prepend-Anfragen abrufen.

  • getRefreshKey: Stellt den Schlüssel zum Neuladen von Daten bereit, wenn der Pager ungültig ist. Bei dieser Methode wird der Schlüssel anhand der aktuellen Scrollposition des Nutzers (state.anchorPosition) berechnet.

Das folgende Codebeispiel zeigt, wie die Klasse ProductPagingSource implementiert wird. Sie ist erforderlich, um die Logik zum Abrufen von Daten zu definieren, wenn Paging 3 ohne lokale Datenbank verwendet wird.

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

Erstellen Sie in Ihrer ViewModel-Klasse die 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)

Mit einer Datenbank implementieren

Wenn Sie Room verwenden, wird die Klasse PagingSource automatisch von der Datenbank generiert. Die Datenbank weiß jedoch nicht, wann weitere Daten aus dem Netzwerk abgerufen werden sollen. Implementieren Sie dazu eine RemoteMediator.

Die Methode RemoteMediator.load() gibt den loadType (Append, Prepend oder Refresh) und den Status zurück. Es wird ein MediatorResult zurückgegeben, das angibt, ob der Vorgang erfolgreich war oder fehlgeschlagen ist und ob das Ende der Paginierung erreicht wurde.

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

In Ihrem ViewModel wird die Implementierung erheblich vereinfacht, da Room die PagingSource-Klasse übernimmt:

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

Netzwerkeinrichtung

Die vorherigen Beispiele basieren auf einem Netzwerkdienst. In diesem Abschnitt wird die Retrofit- und Serialisierungseinrichtung beschrieben, die zum Abrufen von Daten vom api.example.com/products-Endpunkt verwendet wird.

Datenklassen

Das folgende Codebeispiel zeigt, wie die beiden Datenklassen ProductResponse und Product definiert werden, die mit kotlinx.serialization verwendet werden, um die paginierte JSON-Antwort des Netzwerkdienstes zu parsen.

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

Nachrüstservice

Das folgende Codebeispiel zeigt, wie die Retrofit-Dienstschnittstelle (ProductService) für die reine Netzwerkimplementierung definiert wird. Dabei werden der Endpunkt (@GET("/products")) und die erforderlichen Paging-Parameter (limit) und (skip) angegeben, die von der Paging 3-Bibliothek zum Abrufen von Datenseiten benötigt werden.

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

Daten in Compose verwenden

Nachdem Sie die Pager eingerichtet haben, können Sie die Daten in Ihrer Benutzeroberfläche anzeigen.

  1. Ablauf erfassen: Verwenden Sie collectAsLazyPagingItems(), um den Ablauf in ein zustandsbezogenes Lazy-Paging-Elementobjekt umzuwandeln.

    val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()
    

    Das resultierende LazyPagingItems-Objekt enthält die Anzahl der Elemente und den indexierten Zugriff, sodass es direkt von der LazyColumn-Methode zum Rendern der Listenelemente verwendet werden kann.

  2. An LazyColumn binden: Übergeben Sie die Daten an eine LazyColumn-Liste. Wenn Sie von der Liste RecyclerView migrieren, sind Sie möglicherweise mit der Verwendung von withLoadStateHeaderAndFooter vertraut, um Ladesymbole oder Schaltflächen zum Wiederholen von Fehlern oben oder unten in der Liste anzuzeigen.

    In Compose benötigen Sie dafür keinen speziellen Adapter. Sie können genau dasselbe Verhalten erzielen, indem Sie vor oder nach dem Hauptblock items {} bedingt einen item {}-Block hinzufügen und direkt auf die Ladestatus prepend (Kopfzeile) und append (Fußzeile) reagieren.

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

Weitere Informationen dazu, wie Sie mit den Funktionen von Compose Sammlungen von Elementen effektiv darstellen können, finden Sie unter Listen und Grids.

Ladezustände verarbeiten

Das PagingData-Objekt enthält Informationen zum Ladestatus. Damit können Sie für verschiedene Status (refresh, append oder prepend) Ladesymbole oder Fehlermeldungen anzeigen.

Um unnötige Neuzusammenstellungen zu vermeiden und dafür zu sorgen, dass die Benutzeroberfläche nur auf sinnvolle Übergänge im Ladezyklus reagiert, sollten Sie Ihre Statusbeobachtungen filtern. Da loadState häufig mit internen Änderungen aktualisiert wird, kann das direkte Lesen bei komplexen Statusänderungen zu Rucklern führen.

Sie können dies optimieren, indem Sie snapshotFlow verwenden, um den Status zu beobachten, und Flow-Operatoren wie das Attribut distinctUntilChangedBy anwenden. Das ist besonders nützlich, wenn Sie leere Status anzeigen oder Nebeneffekte wie eine Snackbar mit einer Fehlermeldung auslösen möchten:

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

Wenn Sie den Aktualisierungsstatus prüfen, um einen Vollbild-Lade-Spinner anzuzeigen, verwenden Sie derivedStateOf, um unnötige Neukompositionen zu vermeiden.

Wenn Sie ein RemoteMediator verwenden (wie in der Room-Datenbankimplementierung oben), sollten Sie außerdem den Ladestatus der zugrunde liegenden Datenquelle (loadState.source.refresh) explizit prüfen und nicht die praktische loadState.refresh-Property. Die Convenience-Property meldet möglicherweise, dass der Netzwerkabruf abgeschlossen ist, bevor die Datenbank die neuen Elemente in der Benutzeroberfläche hinzugefügt hat. Durch die Prüfung von source wird sichergestellt, dass die Benutzeroberfläche vollständig mit der lokalen Datenbank synchronisiert ist. So wird verhindert, dass der Ladevorgang zu früh beendet wird.

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

Sie können auch nach LoadState.Error suchen, um dem Nutzer Schaltflächen zum Wiederholen oder Fehlermeldungen anzuzeigen. Wir empfehlen die Verwendung von LoadState.Error, da dadurch die zugrunde liegende Ausnahme verfügbar gemacht wird und die integrierte Funktion retry() für die Nutzerwiederherstellung verwendet werden kann.

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

Implementierung testen

Wenn Sie Ihre Paginierungsimplementierung testen, können Sie sichergehen, dass Daten richtig geladen werden, Transformationen wie erwartet angewendet werden und die Benutzeroberfläche richtig auf Statusänderungen reagiert. Die Paging 3-Bibliothek bietet ein spezielles Testartefakt (androidx.paging:paging-testing), um diesen Prozess zu vereinfachen.

Fügen Sie zuerst die Testabhängigkeit in Ihre build.gradle-Datei ein:

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

Datenschicht testen

Wenn Sie Ihre PagingSource direkt testen möchten, verwenden Sie TestPager. Dieses Tool übernimmt die zugrunde liegenden Mechanismen von Paging 3 und ermöglicht es Ihnen, Grenzfälle wie das anfängliche Laden (Aktualisieren), das Anhängen oder das Voranstellen von Daten unabhängig zu überprüfen, ohne dass eine vollständige Pager-Einrichtung erforderlich ist.

@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-Logik und ‑Transformationen testen

Wenn in Ihrem ViewModel Datentransformationen (z. B. .map-Vorgänge) auf den PagingData-Flow angewendet werden, können Sie diese Logik mit asPagingSourceFactory und asSnapshot() testen.

Die Erweiterung asPagingSourceFactory konvertiert eine statische Liste in ein PagingSource, wodurch sich die Repository-Ebene einfacher simulieren lässt. Die Erweiterung asSnapshot() erfasst den Stream PagingData in einem Standard-Kotlin-List. So können Sie Standardassertions für die transformierten Daten ausführen.

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

UI-Tests zum Überprüfen von Status und Neuzusammensetzungen

Prüfen Sie beim Testen der Benutzeroberfläche, ob Ihre Compose-Komponenten die Daten korrekt rendern und angemessen auf Ladestatus reagieren. Sie können statische PagingData mit PagingData.from() und flowOf() übergeben, um Datenstreams zu simulieren. Außerdem können Sie mit einem SideEffect die Anzahl der Neuzusammenstellungen während Ihrer Tests verfolgen, um sicherzustellen, dass Ihre Compose-Komponenten nicht unnötig neu zusammengestellt werden.

Das folgende Beispiel zeigt, wie Sie einen Ladestatus simulieren, in einen geladenen Status übergehen und sowohl die UI-Knoten als auch die Anzahl der Neukompositionen prüfen:

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