Pagination in Android-Apps
Pagination lädt große Datenmengen schrittweise und hält Listen in Android-Apps schnell, stabil und bedienbar.
Pagination bedeutet, dass du große Datenmengen nicht komplett auf einmal lädst, sondern in kleineren Abschnitten nachforderst. In Android-Apps betrifft das vor allem lange Listen: Nachrichten, Suchergebnisse, Chatverläufe, Produkte, Logeinträge oder lokale Datenbanken mit vielen Zeilen. Das Ziel ist klar: Die UI bleibt reaktionsfähig, der Speicherverbrauch bleibt kontrollierbar, und deine App lädt nur die Daten, die der Nutzer voraussichtlich wirklich braucht.
Was ist das?
Pagination ist ein Ladeverfahren für Listen und Datenströme. Statt eine API mit „gib mir alles“ aufzurufen, fragst du nach einem begrenzten Teil: zum Beispiel nach 20 Einträgen ab Seite 1, danach nach Seite 2, danach nach Seite 3. Dieser Abschnitt heißt oft Page. Eine andere Variante arbeitet mit einem Cursor. Dann sagt der Server nicht „nächste Seite ist 3“, sondern gibt dir eine Markierung zurück, etwa nextCursor. Diese Markierung verwendest du beim nächsten Request, um genau an der richtigen Stelle weiterzuladen.
Das mentale Modell ist: Deine Liste ist nicht ein fertiger Block, sondern ein Fenster auf eine größere Datenmenge. Dieses Fenster wächst, wenn der Nutzer weiter scrollt oder wenn deine App aus einem anderen Grund mehr Daten braucht. Bei einer Infinite List wirkt es für den Nutzer so, als sei die Liste endlos. Technisch verwaltest du aber viele kleine Ladeoperationen, Zustände und Grenzen.
Im Android-Kontext ist Pagination ein Thema der Datenarchitektur. Die UI sollte nicht selbst wissen müssen, ob Daten aus Seite 4, einem Cursor oder einem lokalen Cache kommen. Diese Verantwortung gehört in die Data Layer: Repository, Remote Data Source, lokale Datenquelle und gegebenenfalls ein Mapper zwischen API-Modell und UI-Modell. Compose rendert dann nur den aktuellen Zustand: vorhandene Items, Ladeanzeige, Fehler, leere Liste oder Ende der Liste.
Pagination ist wichtig, weil mobile Geräte andere Bedingungen haben als ein Backend-Server. Netzwerk kann langsam sein, Verbindungen brechen ab, Speicher ist begrenzt, und eine blockierte Main Thread führt sofort zu sichtbaren Rucklern. Wenn du 10.000 Datensätze in einem Request lädst, bezahlst du dafür mehrfach: längere Wartezeit, mehr Datenvolumen, mehr Parsing-Arbeit, mehr Speicher und mehr Arbeit beim Rendern. Pagination reduziert diese Kosten, wenn du sie sauber modellierst.
Wie funktioniert es?
Es gibt zwei Grundformen, die du unterscheiden solltest: page-basierte Pagination und cursor-basierte Pagination.
Bei page-basierter Pagination sendest du meist Parameter wie page=1 und pageSize=20. Der Server liefert 20 Elemente und oft Zusatzinfos wie totalPages, totalItems oder hasNextPage. Dieses Modell ist leicht zu verstehen und passt gut zu stabilen, sortierten Daten. Es kann aber problematisch werden, wenn sich die Daten während des Scrollens verändern. Wenn ein neuer Eintrag oben eingefügt wird, kann Seite 2 plötzlich andere Elemente enthalten als kurz zuvor. Dann drohen Duplikate oder Lücken.
Cursor-basierte Pagination arbeitet positionsorientierter. Der Server liefert neben den Items einen Cursor für die nächste Anfrage. Deine App fragt dann nicht „Seite 2“ ab, sondern „alles nach diesem Cursor“. Das passt besonders gut zu Feeds, Chats und Zeitachsen, bei denen sich Daten häufig ändern. Der Cursor kann eine ID, ein Zeitstempel oder ein serverinternes Token sein. Für dich als App-Entwickler ist wichtig: Du behandelst den Cursor als undurchsichtigen Wert. Du speicherst und sendest ihn weiter, aber du baust keine eigene Logik darauf, wenn die API das nicht ausdrücklich erlaubt.
In einer modernen Android-App besteht Pagination aus mehreren Zuständen. Du brauchst mindestens: vorhandene Items, aktueller Ladezustand, Fehlerzustand, Information darüber, ob weitere Daten verfügbar sind, und den nächsten Schlüssel. Dieser Schlüssel ist je nach API eine Seitennummer oder ein Cursor. Außerdem brauchst du eine Regel, wann nachgeladen wird. In einer Compose-Liste kann das passieren, wenn der Nutzer in die Nähe des Listenendes scrollt. Du lädst also nicht erst, wenn wirklich das letzte Element sichtbar ist, sondern ein paar Einträge vorher. So fühlt sich die Liste flüssiger an.
Architektonisch sollte die UI ein Ereignis auslösen, etwa loadNextPage(). Das ViewModel prüft, ob gerade bereits geladen wird oder ob das Ende erreicht ist. Das Repository ruft die Datenquelle auf. Die Datenquelle spricht mit Netzwerk oder Datenbank. Danach aktualisiert das ViewModel den State. Diese Trennung ist kein Selbstzweck. Sie verhindert, dass dein Composable plötzlich Netzwerkdetails kennt, und macht Fehler leichter testbar.
Offline-First-Architektur verschiebt den Schwerpunkt noch etwas. Dann ist die lokale Datenbank häufig die Quelle, aus der die UI liest. Das Netzwerk aktualisiert die Datenbank im Hintergrund oder bei Bedarf. Pagination kann dabei sowohl lokal als auch remote vorkommen: Die UI liest in Blöcken aus Room, und ein Remote Loader fordert weitere Daten vom Server an, wenn lokal nicht genug Daten vorhanden sind. Wichtig ist, dass du nicht zwei konkurrierende Wahrheiten baust. Wenn lokale Daten und Remote-Daten gemischt werden, brauchst du klare Regeln für Sortierung, Aktualisierung, Löschung und Duplikaterkennung.
Qualität entsteht bei Pagination vor allem durch saubere Zustände. Ein einzelnes Boolean wie isLoading reicht in kleinen Beispielen, wird aber schnell ungenau. Du musst unterscheiden können: initiales Laden, Nachladen am Ende, Refresh, Fehler beim ersten Laden, Fehler beim Nachladen und Ende erreicht. Für den Nutzer macht das einen Unterschied. Ein Fehler beim ersten Laden kann die ganze Ansicht blockieren. Ein Fehler beim Nachladen sollte die vorhandene Liste nicht löschen, sondern eine Retry-Möglichkeit am Ende anzeigen.
In der Praxis
Stell dir eine App vor, die Artikel aus einer API lädt. Die API verwendet page-basierte Pagination. Du willst je Anfrage 20 Artikel laden und in Compose anzeigen. Das Beispiel ist bewusst klein gehalten, zeigt aber die wichtigsten Entscheidungen: State im ViewModel, Schutz vor doppelten Requests und Nachladen kurz vor dem Listenende.
data class Article(
val id: String,
val title: String
)
data class ArticleListState(
val items: List<Article> = emptyList(),
val nextPage: Int = 1,
val isInitialLoading: Boolean = false,
val isAppending: Boolean = false,
val endReached: Boolean = false,
val errorMessage: String? = null
)
interface ArticleRepository {
suspend fun loadArticles(page: Int, pageSize: Int): List<Article>
}
class ArticleListViewModel(
private val repository: ArticleRepository
) : ViewModel() {
private val _state = MutableStateFlow(ArticleListState())
val state: StateFlow<ArticleListState> = _state.asStateFlow()
fun loadNextPage() {
val current = _state.value
if (current.isInitialLoading || current.isAppending || current.endReached) return
viewModelScope.launch {
val firstPage = current.items.isEmpty()
_state.update {
it.copy(
isInitialLoading = firstPage,
isAppending = !firstPage,
errorMessage = null
)
}
try {
val newItems = repository.loadArticles(
page = current.nextPage,
pageSize = 20
)
_state.update {
it.copy(
items = it.items + newItems,
nextPage = it.nextPage + 1,
isInitialLoading = false,
isAppending = false,
endReached = newItems.isEmpty()
)
}
} catch (exception: IOException) {
_state.update {
it.copy(
isInitialLoading = false,
isAppending = false,
errorMessage = "Daten konnten nicht geladen werden."
)
}
}
}
}
}
In Compose kann dein Screen den State beobachten und das Nachladen auslösen, wenn der Nutzer nahe am Ende angekommen ist. Entscheidend ist, dass die UI nicht selbst die Seitennummer erhöht. Sie meldet nur: „Ich brauche mehr Daten.“ Die Geschäftsregel bleibt im ViewModel.
@Composable
fun ArticleListScreen(
viewModel: ArticleListViewModel
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
LaunchedEffect(Unit) {
viewModel.loadNextPage()
}
val shouldLoadMore by remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
val totalItems = listState.layoutInfo.totalItemsCount
lastVisibleItem != null &&
lastVisibleItem.index >= totalItems - 5
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) {
viewModel.loadNextPage()
}
}
LazyColumn(state = listState) {
items(
items = state.items,
key = { article -> article.id }
) { article ->
Text(
text = article.title,
modifier = Modifier.padding(16.dp)
)
}
if (state.isAppending) {
item(key = "append-loading") {
CircularProgressIndicator(
modifier = Modifier.padding(16.dp)
)
}
}
state.errorMessage?.let { message ->
item(key = "append-error") {
Text(
text = message,
modifier = Modifier.padding(16.dp)
)
}
}
}
}
Die wichtigste Entscheidungsregel lautet: Pagination darf nie nur über die Scrollposition gesteuert werden. Die Scrollposition ist ein Signal, aber nicht die Wahrheit. Die Wahrheit liegt in deinem State. Wenn isAppending bereits true ist, wird kein zweiter Request gestartet. Wenn endReached gesetzt ist, wird nicht weiter geladen. Wenn ein Fehler passiert, bleiben vorhandene Items erhalten.
Eine typische Stolperfalle ist mehrfaches Nachladen derselben Seite. Das passiert oft, wenn LaunchedEffect oder Scroll-Beobachtung mehrfach feuert und dein ViewModel keinen Schutz eingebaut hat. Das Ergebnis sind doppelte Items, unnötige API-Last und schwer nachvollziehbare UI-Fehler. Eine zweite Stolperfalle ist eine instabile Sortierung. Wenn deine API keine stabile Reihenfolge garantiert, kannst du selbst mit sauberem Client-Code Duplikate oder Lücken sehen. In Code-Reviews solltest du deshalb prüfen: Gibt es eine eindeutige Sortierung? Gibt es stabile Keys in LazyColumn? Wird das Ende der Liste sauber erkannt? Bleibt die Liste bei einem Append-Fehler sichtbar?
Bei cursor-basierter Pagination sieht der State ähnlich aus, aber nextPage: Int wird durch nextCursor: String? ersetzt. Ein null-Cursor kann bedeuten, dass keine weiteren Daten vorhanden sind. Du solltest diese Bedeutung aber aus der API-Dokumentation ableiten und nicht raten. Manche APIs liefern zusätzlich hasMore, andere verwenden ein leeres Ergebnis, wieder andere senden gar keinen Cursor mehr. Dein Repository sollte daraus ein klares internes Modell machen, damit das ViewModel nicht mit API-Sonderfällen voll läuft.
Für Offline-First-Apps kommt noch eine weitere Praxisregel dazu: Die UI sollte möglichst aus der lokalen Quelle lesen, während ein separater Mechanismus fehlende Seiten nachlädt. Dadurch bleibt die App auch bei schlechtem Netz bedienbar. Du musst dann aber Konflikte ernst nehmen. Wenn du lokal Seite 1, 2 und 4 hast, aber Seite 3 fehlt, darf die UI nicht so tun, als sei die Datenmenge vollständig. Ein sauberer Remote-Key-Ansatz speichert, welche Cursor oder Seiten bereits geladen wurden und was als Nächstes angefragt werden soll.
Testen kannst du Pagination gut mit Fake-Repositories. Simuliere drei erfolgreiche Seiten, eine leere vierte Seite und einen Fehler beim zweiten Request. Prüfe dann, ob endReached korrekt gesetzt wird, ob Items nicht verschwinden und ob ein Retry nicht wieder bei Seite 1 startet, wenn die Liste bereits Daten enthält. Für Compose ist zusätzlich wichtig, dass du stabile Item-Keys nutzt. Ohne stabile Keys können Zustände in Listenelementen verrutschen, wenn neue Daten eingefügt oder aktualisiert werden.
Beim Debugging hilft es, die Seiten- oder Cursorwerte sichtbar zu loggen. Du willst nachvollziehen können: Welche Anfrage wurde gestartet? Welche Antwort kam zurück? Wurde der nächste Schlüssel aktualisiert? Warum wurde nicht nachgeladen? Diese Fragen sind oft schneller beantwortet als ein allgemeines „die Liste lädt nicht“. Achte aber darauf, keine sensiblen Token oder personenbezogenen Daten in Logs zu schreiben, besonders nicht in Release-Builds.
Fazit
Pagination ist eine Kerntechnik für Android-Apps, die große Listen zuverlässig anzeigen müssen. Du teilst Daten in Pages oder Cursor-Schritte auf, hältst die UI schlank und verschiebst die Ladeentscheidung in ViewModel und Data Layer. Prüfe dein Verständnis aktiv: Baue eine kleine Liste mit Fake-Datenquelle, simuliere langsames Netzwerk, Fehler und leere Ergebnisse, und kontrolliere im Debugger, ob dein State jeden Übergang sauber abbildet. In einem Code-Review solltest du besonders auf doppelte Requests, stabile Sortierung, klare Ende-Erkennung und den Umgang mit Append-Fehlern achten.