Paging Library im Überblick
Paging 3 lädt große Listen schrittweise und sauber. Du lernst, wann es passt und welche Fehler du vermeiden solltest.
Wenn deine App Listen mit vielen Einträgen zeigt, ist nicht die RecyclerView oder LazyColumn das eigentliche Problem. Das Problem ist, wann und wie viele Daten du lädst. Paging 3 hilft dir dabei, große lokale oder entfernte Listen schrittweise als Datenstrom bereitzustellen, ohne die UI mit Ladezuständen, Seitenlogik und Wiederholungen zu überladen.
Was ist das?
Die Paging Library ist eine Jetpack-Bibliothek für Listen, die nicht komplett auf einmal geladen werden sollen. Statt tausend, zehntausend oder noch mehr Elemente sofort aus einer API oder Datenbank zu holen, lädt Paging kleine Abschnitte, sogenannte Seiten. Die UI zeigt die vorhandenen Elemente an und fordert automatisch mehr Daten an, wenn der Nutzer weiter scrollt.
Im Android-Kontext passt Paging 3 besonders gut zu Kotlin Flow, Repository-Klassen und Jetpack Compose. Deine Datenebene liefert einen Flow<PagingData<T>>. Die UI sammelt diesen Stream ein und zeigt ihn zum Beispiel in einer LazyColumn. Damit bleibt die Verantwortung sauber getrennt: Die UI rendert, das Repository stellt Daten bereit, und die Paging-Komponenten kümmern sich um Nachladen, Fehlerzustände und Aktualisierung.
Der wichtigste Gedanke für Einsteiger ist: Paging ist keine schönere Schleife über eine Liste. Paging ist ein Modell für fortlaufende Datenströme. Eine Liste ist dabei nicht mehr nur ein fertiges List<Article>, sondern ein Strom von Listenzuständen. Dieser Strom kann neue Seiten, Ladefehler, Refreshes und leere Zustände enthalten. Genau deshalb ist Paging für reale Apps nützlich, in denen Daten aus Netzwerken, Datenbanken oder einer Mischung aus beidem kommen.
Du brauchst Paging nicht für jede Liste. Eine Einstellungsseite mit zehn Optionen, eine kleine Favoritenliste oder ein statisches Menü profitieren kaum davon. Sinnvoll wird Paging, wenn die Datenmenge groß ist, wenn eine API seitenweise liefert, wenn lokale Tabellen wachsen können oder wenn Ladezeit und Speicherverbrauch spürbar werden.
Wie funktioniert es?
Das Grundmodell besteht aus drei Rollen. Erstens gibt es eine Quelle, die Daten seitenweise liefern kann. Bei einer Netzwerk-API ist das oft eine PagingSource, die für einen Schlüssel, etwa eine Seitennummer oder einen Cursor, Daten lädt. Bei einer lokalen Room-Datenbank kann Room selbst eine passende PagingSource liefern. Zweitens gibt es einen Pager, der diese Quelle konfiguriert. Dort legst du etwa fest, wie groß eine Seite ist und wann nachgeladen wird. Drittens gibt es die UI, die PagingData beobachtet und die Einträge rendert.
In modernen Android-Architekturen sitzt der Pager meistens im Repository. Das Repository gehört zur Datenebene und verbirgt Details der Datenquelle. Das ViewModel ruft das Repository auf, hält den Flow über cachedIn(viewModelScope) stabil und gibt ihn an die UI weiter. Compose sammelt den Flow dann mit Paging-Compose ein. So bleibt der Datenstrom an den Lebenszyklus des ViewModels gebunden und wird nicht bei jeder kleinen Rekombination neu gestartet.
Paging 3 arbeitet eng mit Kotlin Flow zusammen. Flow beschreibt asynchrone Daten, die über Zeit entstehen. Das passt gut zu Listen, denn die UI bekommt nicht nur einmal ein Ergebnis, sondern wiederholt neue Zustände: erstes Laden, nächste Seite, Fehler beim Anhängen, erneuter Versuch, Refresh nach Pull-to-Refresh oder leere Daten. Du solltest deshalb nicht versuchen, Paging sofort in eine normale Liste umzuwandeln. Wenn du PagingData zu früh sammelst und selbst in eine mutable Liste kopierst, verlierst du einen großen Teil des Nutzens.
Ein wichtiger Begriff ist der Ladezustand. Paging unterscheidet zum Beispiel zwischen einem Refresh, also dem Laden des ersten oder neuen Gesamtzustands, und Append, also dem Anhängen weiterer Elemente am Ende. Für die UI ist das wichtig: Beim ersten Laden zeigst du vielleicht eine große Ladeanzeige. Beim Nachladen reicht oft ein kleiner Spinner am Listenende. Bei Fehlern willst du ebenfalls unterscheiden: Ein Fehler beim ersten Laden blockiert die ganze Liste, ein Fehler beim Nachladen kann als wiederholbarer Eintrag am Ende erscheinen.
Bei Offline-First-Architekturen kommt oft RemoteMediator dazu. Dann liest die UI aus der lokalen Datenbank, während der Mediator im Hintergrund aus dem Netzwerk nachlädt und die Datenbank aktualisiert. Das ist sauberer als direkt aus dem Netzwerk in die UI zu paginieren, wenn deine App auch bei schlechter Verbindung brauchbar bleiben soll. Für den Überblick reicht die Regel: Nur Netzwerk ist einfacher, Netzwerk plus lokale Datenbank ist stabiler für Apps, die Daten zwischenspeichern müssen.
In der Praxis
Stell dir eine App vor, die Beiträge aus einer API anzeigt. Die API liefert eine Seite nach der anderen. Die UI soll nicht wissen, ob Seite 1, 2 oder 3 geladen wird. Sie soll nur eine Liste anzeigen und auf Ladezustände reagieren. Eine typische Struktur sieht so aus:
class ArticlePagingSource(
private val api: ArticleApi
) : PagingSource<Int, Article>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
val page = params.key ?: 1
return try {
val response = api.loadArticles(page = page, size = params.loadSize)
LoadResult.Page(
data = response.items,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.items.isEmpty()) null else page + 1
)
} catch (error: IOException) {
LoadResult.Error(error)
} catch (error: HttpException) {
LoadResult.Error(error)
}
}
override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
val anchor = state.anchorPosition ?: return null
val page = state.closestPageToPosition(anchor)
return page?.prevKey?.plus(1) ?: page?.nextKey?.minus(1)
}
}
class ArticleRepository(
private val api: ArticleApi
) {
fun articles(): Flow<PagingData<Article>> {
return Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 5,
enablePlaceholders = false
),
pagingSourceFactory = { ArticlePagingSource(api) }
).flow
}
}
class ArticleViewModel(
repository: ArticleRepository
) : ViewModel() {
val articles = repository
.articles()
.cachedIn(viewModelScope)
}
In Compose würdest du diesen Flow mit collectAsLazyPagingItems() sammeln und die Elemente in einer LazyColumn darstellen. Die genaue UI kann je nach App anders aussehen, aber das Muster bleibt gleich: Repository baut den Pager, ViewModel cached den Stream, UI rendert die Einträge und wertet Ladezustände aus.
Die wichtigste Entscheidungsregel lautet: Setze Paging ein, wenn die Datenquelle natürlich seitenweise ist oder die Liste realistisch groß werden kann. Nutze es nicht nur deshalb, weil eine Liste vorhanden ist. Paging bringt Struktur, aber auch zusätzliche Konzepte. Für kleine, feste Datenmengen ist eine normale Liste leichter zu lesen, leichter zu testen und oft ausreichend.
Eine typische Stolperfalle ist instabile Sortierung. Wenn deine API bei jedem Request eine andere Reihenfolge liefert, entstehen beim Nachladen schnell doppelte Einträge, fehlende Einträge oder sichtbare Sprünge. Paging erwartet, dass der Schlüssel zur Seite und die Sortierung zusammenpassen. Bei einer API mit Seitennummern sollte also klar sein, nach welchem Feld sortiert wird. Bei Cursor-basierten APIs sollte der Cursor eindeutig zur nächsten Datenmenge führen. Auch lokale Datenbankabfragen brauchen eine stabile ORDER BY-Klausel, sonst kann Paging bei Änderungen in der Tabelle unruhig wirken.
Eine zweite Stolperfalle ist das Neuerzeugen des Paging-Flows an der falschen Stelle. Wenn du den Pager direkt in einem Composable erstellst, kann er durch Rekombpositionen häufiger neu entstehen als geplant. Das führt zu unnötigen Requests, verlorenen Scrollzuständen und schwer erklärbarem Verhalten. Halte den Stream im ViewModel und verwende cachedIn(viewModelScope), damit die UI denselben Paging-Strom weiterverwenden kann.
Beim Testen musst du nicht jede interne Paging-Klasse prüfen. Sinnvoller ist, die eigenen Grenzen zu testen: Liefert deine PagingSource bei erfolgreicher API-Antwort die richtigen Daten und Schlüssel? Wird bei leerer Antwort nextKey korrekt auf null gesetzt? Werden Netzwerkfehler als LoadResult.Error gemeldet? In Code-Reviews solltest du außerdem prüfen, ob Paging wirklich in der Datenebene sitzt, ob Ladezustände in der UI sichtbar behandelt werden und ob Sortierung sowie Schlüssel stabil sind.
Fazit
Paging 3 ist die passende Wahl, wenn große Listen schrittweise geladen werden müssen und du Datenströme sauber durch Repository, ViewModel und UI führen willst. Prüfe dein Verständnis praktisch: Baue eine kleine paginierte Liste, setze Breakpoints in load(), beobachte Refresh und Append getrennt, simuliere einen Fehler und schreibe mindestens einen Test für die Schlüssel deiner PagingSource. So erkennst du schnell, ob du Paging nur eingebaut hast oder ob du das Datenmodell dahinter wirklich kontrollierst.