Android Coden
Android 10 min lesen

Große Datenmengen in Android-Apps verarbeiten

Du lernst, große Datenmengen in Android-Apps kontrolliert zu laden. Fokus: Streaming, Pagination und Speicher.

Große Datenmengen sind in Android-Apps normal: Chat-Verläufe, Produktlisten, Kartenpunkte, Log-Dateien, Medien, Suchergebnisse oder lokale Caches. Die zentrale Regel lautet: Lade nicht mehr Daten in den Speicher, als der Nutzer oder die konkrete Funktion gerade braucht. Wenn du diese Regel ernst nimmst, werden deine Apps stabiler, schneller und besser testbar.

Was ist das?

Large Data Handling bedeutet, große Datenmengen kontrolliert zu laden, zu halten, zu verarbeiten und wieder freizugeben. Es geht nicht darum, möglichst viele Daten auf einmal zu bekommen. Es geht darum, Daten passend zum Bedarf der App zu bewegen. Der Nutzer sieht vielleicht zehn Listeneinträge auf dem Bildschirm. Deine App muss dafür nicht 50.000 Datensätze als Liste im RAM halten.

Im Android-Kontext ist das besonders wichtig, weil mobile Geräte begrenzte Ressourcen haben. Speicher, CPU, Akku und Netzwerk sind nicht beliebig verfügbar. Android kann Prozesse beenden, wenn sie zu viel Speicher nutzen oder lange im Hintergrund liegen. Auch eine moderne App mit Kotlin, Jetpack Compose, Repository-Schicht und sauberer Architektur kann langsam oder instabil werden, wenn sie Datenmengen ungefiltert durch alle Ebenen reicht.

Du solltest dir ein einfaches mentales Modell merken: Daten fließen durch deine App. Sie kommen aus Netzwerk, Datenbank, Datei oder Cache. Danach wandern sie über Repository, Use Case oder ViewModel zur UI. An jeder Station kann deine App zu viel speichern, zu viel kopieren oder zu früh sammeln. Large Data Handling fragt deshalb immer: Welche Daten brauche ich jetzt, in welcher Form, für wie lange?

Drei Begriffe sind dabei besonders wichtig. Streaming bedeutet, dass Daten schrittweise geliefert und verarbeitet werden. Pagination bedeutet, dass lange Listen in Seiten oder Pakete aufgeteilt werden. Memory beschreibt den Arbeitsspeicher, den deine App tatsächlich belegt. Diese Begriffe gehören zusammen: Streaming und Pagination sind typische Mittel, um Memory-Verbrauch zu begrenzen.

Für Lernende ist der wichtigste Schritt der Perspektivwechsel. Du schreibst nicht nur Code, der “die Daten holt”. Du schreibst Code, der Daten kontrolliert durch eine App führt. Das ist ein Unterschied. Eine Anfänger-Lösung ruft oft eine API auf, baut eine große Liste und gibt sie an die UI weiter. Eine robustere Lösung lädt nur die nächste Seite, beobachtet Änderungen als Flow und hält UI-State klein genug, damit er zur Oberfläche passt.

Wie funktioniert es?

Large Data Handling beginnt meist in der Data Layer. Diese Schicht entscheidet, woher Daten kommen und in welcher Form andere Teile der App sie bekommen. Ein Repository sollte nicht ungeprüft riesige Datenmengen als List<T> ausgeben, nur weil die Datenquelle das erlaubt. Es sollte ein Modell anbieten, das zum Anwendungsfall passt: ein Stream, eine Seite, ein Suchergebnis mit Limit oder ein lokal gecachter Ausschnitt.

Kotlin Flow passt gut zu Streaming, weil ein Flow Werte über die Zeit ausgeben kann. Das ist hilfreich, wenn Daten aus einer lokalen Datenbank kommen, wenn ein Cache aktualisiert wird oder wenn ein Offline-First-Ansatz verwendet wird. Du kannst zum Beispiel zuerst lokale Daten anzeigen und später aktualisierte Daten aus dem Netzwerk nachreichen. Wichtig ist: Ein Flow ist kein Freifahrtschein für große Mengen. Wenn du innerhalb des Flows eine komplette Tabelle in eine riesige Liste lädst, hast du das Speicherproblem nur anders verpackt.

Pagination löst ein anderes, sehr häufiges Problem: lange Listen. Statt alle Einträge zu laden, fragt die App einen begrenzten Ausschnitt ab. Das kann seitenbasiert sein, etwa page=3&size=30, oder cursorbasiert, etwa after=lastItemId. Cursorbasierte Pagination ist oft stabiler, wenn Daten während des Scrollens neu dazukommen oder gelöscht werden. Für dich als Android-Entwickler zählt aber zuerst das Prinzip: Die UI fordert nach, wenn sie mehr braucht.

In Jetpack Compose erscheint dieses Thema oft in Listen mit LazyColumn. Der Name “Lazy” bedeutet, dass Compose nicht alle sichtbaren Elemente gleichzeitig komponiert, sondern nur den relevanten Bereich. Das löst aber nicht automatisch deine Datenprobleme. Wenn du einer LazyColumn vorher eine Liste mit 100.000 Objekten gibst, liegt diese Liste trotzdem im Speicher. Die UI ist dann zwar beim Rendering sparsamer, aber deine Datenhaltung kann weiterhin teuer sein.

Auch ViewModels spielen eine wichtige Rolle. Ein ViewModel sollte UI-State halten, aber nicht zum dauerhaften Lager für beliebig große Datenmengen werden. Wenn du in einem StateFlow eine riesige Liste speicherst, wird sie bei jeder Änderung leicht kopiert, verglichen oder neu verteilt. Das kann zu Rucklern führen. Besser ist oft ein kleiner State: Ladezustand, aktueller Filter, sichtbare Items oder eine paginierte Datenquelle.

Ein weiterer Punkt ist Transformation. Viele Speicherprobleme entstehen nicht beim Laden, sondern beim Umformen. Wenn du eine große Liste lädst, sie dann filterst, danach sortierst und danach in UI-Modelle mappst, hast du unter Umständen mehrere große Zwischenlisten erzeugt. Bei kleinen Datenmengen ist das egal. Bei großen Datenmengen kann es den Unterschied zwischen flüssiger App und Absturz ausmachen.

Offline-First-Architekturen machen Large Data Handling noch wichtiger. Lokale Datenbanken können sehr viele Einträge speichern. Das heißt aber nicht, dass du sie alle in den Arbeitsspeicher holen solltest. Gute Offline-First-Apps fragen lokale Daten gezielt ab, beobachten relevante Ausschnitte und synchronisieren im Hintergrund. Die lokale Datenbank ist der Speicherort. Der RAM ist nur ein Arbeitsbereich.

Performance hängt hier direkt mit Nutzererlebnis zusammen. Zu große Datenmengen führen zu langen Ladezeiten, hohem Speicherverbrauch, blockierter UI, stärkerem Akkuverbrauch und schlechter Reaktion beim Scrollen. Android-Apps müssen daher Datenmenge, Threading und Lebenszyklus zusammen denken. Datenarbeit gehört nicht auf den Main Thread, und Datenströme sollten an den Lifecycle der Oberfläche gebunden werden, damit nicht weiter gesammelt wird, wenn die UI nicht mehr aktiv ist.

In der Praxis

Stell dir eine App vor, die Artikel aus einer API lädt. Eine schnelle, aber riskante Lösung wäre ein Endpunkt wie /articles, der alle Artikel zurückgibt. Das Repository macht daraus eine List<Article>, das ViewModel speichert diese Liste in einem StateFlow, und Compose rendert sie in einer LazyColumn. Das funktioniert mit 50 Einträgen. Mit 50.000 Einträgen wird es problematisch: hoher Speicherverbrauch, lange JSON-Verarbeitung, große UI-States und langsame Tests.

Eine bessere Regel lautet: Jede Liste, die theoretisch wachsen kann, braucht eine Begrenzung. Diese Begrenzung kann eine Seitengröße, ein Cursor, ein Suchfilter, ein Zeitraum oder ein anderer fachlicher Ausschnitt sein. Du solltest diese Entscheidung nicht erst in der UI treffen. Sie gehört in die Data Layer und muss in Repository-Methoden sichtbar werden.

Ein einfaches Repository kann zum Beispiel so aussehen:

data class Article(
    val id: String,
    val title: String,
    val summary: String
)

data class ArticlePage(
    val items: List<Article>,
    val nextCursor: String?
)

interface ArticleApi {
    suspend fun getArticles(
        cursor: String?,
        limit: Int
    ): ArticlePage
}

class ArticleRepository(
    private val api: ArticleApi
) {
    suspend fun loadFirstPage(): ArticlePage {
        return api.getArticles(cursor = null, limit = 30)
    }

    suspend fun loadNextPage(cursor: String): ArticlePage {
        return api.getArticles(cursor = cursor, limit = 30)
    }
}

Dieses Beispiel ist bewusst klein. Es zeigt aber die wichtige Grenze: Das Repository bietet keine Methode loadAllArticles() an. Es macht die Begrenzung zum Teil der API. Dadurch wird es schwerer, aus Versehen alles zu laden.

Im ViewModel kannst du dann einen UI-State halten, der nur die bereits geladenen Items enthält und weitere Seiten nachlädt, wenn die UI sie braucht:

data class ArticlesUiState(
    val items: List<Article> = emptyList(),
    val nextCursor: String? = null,
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

class ArticlesViewModel(
    private val repository: ArticleRepository
) : ViewModel() {

    private val _state = MutableStateFlow(ArticlesUiState())
    val state: StateFlow<ArticlesUiState> = _state.asStateFlow()

    fun loadInitial() {
        if (_state.value.items.isNotEmpty()) return

        viewModelScope.launch {
            _state.update { it.copy(isLoading = true, errorMessage = null) }

            runCatching { repository.loadFirstPage() }
                .onSuccess { page ->
                    _state.update {
                        it.copy(
                            items = page.items,
                            nextCursor = page.nextCursor,
                            isLoading = false
                        )
                    }
                }
                .onFailure { error ->
                    _state.update {
                        it.copy(
                            isLoading = false,
                            errorMessage = error.message
                        )
                    }
                }
        }
    }

    fun loadMore() {
        val current = _state.value
        val cursor = current.nextCursor ?: return
        if (current.isLoading) return

        viewModelScope.launch {
            _state.update { it.copy(isLoading = true, errorMessage = null) }

            runCatching { repository.loadNextPage(cursor) }
                .onSuccess { page ->
                    _state.update {
                        it.copy(
                            items = it.items + page.items,
                            nextCursor = page.nextCursor,
                            isLoading = false
                        )
                    }
                }
                .onFailure { error ->
                    _state.update {
                        it.copy(
                            isLoading = false,
                            errorMessage = error.message
                        )
                    }
                }
        }
    }
}

Auch diese Variante ist noch nicht für jeden Produktionsfall vollständig. Bei sehr großen Listen würdest du zusätzliche Strategien prüfen, etwa lokale Speicherung, Paging-Bibliotheken, gezielte Datenbankabfragen oder das Entfernen alter Bereiche aus dem UI-State. Für das Lernziel reicht aber der Kern: Das ViewModel sammelt nicht blind alle Daten der Welt, sondern erweitert den sichtbaren Bestand schrittweise.

In Compose würdest du dieses ViewModel mit einer LazyColumn verbinden. Dabei solltest du nicht bei jedem Recompose neue Ladeaufrufe starten. Ein typischer Ansatz ist, initiales Laden über LaunchedEffect(Unit) anzustoßen und weiteres Laden an die Scroll-Position oder an sichtbare Items zu koppeln. Wichtig ist, doppelte Anfragen zu verhindern. Das isLoading-Flag im Beispiel ist dafür ein einfacher Schutz.

Eine typische Stolperfalle ist der Satz: “Die API gibt mir alles, also nehme ich alles.” Das ist selten eine gute Idee. Wenn du den Server nicht ändern kannst, kannst du trotzdem lokal begrenzen: nur benötigte Felder parsen, Ergebnisse filtern, Daten in eine lokale Datenbank schreiben und danach kleine Ausschnitte abfragen. Schlechter wäre, die komplette Antwort dauerhaft in einem Singleton, ViewModel oder globalen Cache zu halten.

Eine zweite Stolperfalle liegt bei Flow. Viele Lernende schreiben flow.toList() oder sammeln einen Flow ohne klare Lebenszyklusbindung. Damit verlierst du den Streaming-Vorteil. Ein Flow sollte Werte liefern, während die UI oder der Use Case sie braucht. In Android bindest du das Sammeln in der UI typischerweise an den Lifecycle, damit keine unnötige Arbeit weiterläuft, wenn der Screen nicht sichtbar ist.

Eine dritte Stolperfalle ist das Mapping großer Datenmengen auf dem Main Thread. Wenn du viele Datensätze sortierst, gruppierst oder in UI-Modelle verwandelst, kann das die Oberfläche blockieren. Achte darauf, teure Arbeit in der passenden Schicht und auf einem geeigneten Dispatcher auszuführen. Noch besser ist es, die Datenmenge vorher zu reduzieren, statt große Mengen nachträglich schönzurechnen.

Als Entscheidungsregel kannst du dir merken: Wenn eine Datenmenge durch Nutzerverhalten, Serverbestand oder Zeit wachsen kann, behandle sie von Anfang an als groß. Plane dann eine Begrenzung ein. Frage dich im Code-Review konkret: Gibt es hier ein Limit? Gibt es einen Cursor oder eine Seite? Wird ein Flow wirklich gestreamt? Wird eine große Liste mehrfach kopiert? Wird Arbeit auf dem Main Thread vermieden?

Du kannst dein Verständnis praktisch prüfen, indem du eine kleine Beispiel-App baust, die 10.000 lokale Datensätze simuliert. Implementiere zuerst eine naive Liste und beobachte Speicher, Startzeit und Scroll-Verhalten. Danach baust du Pagination ein und vergleichst erneut. Nutze den Android Studio Profiler, Log-Ausgaben für Ladegrenzen und Tests für Repository-Verhalten. Ein guter Test prüft nicht nur, ob Daten geladen werden, sondern auch, ob nur die angeforderte Seite geladen wird.

Für Tests ist besonders die Data Layer interessant. Du kannst ein Fake-API schreiben, das zählt, welche Cursor und Limits angefragt wurden. Dann testest du, dass loadFirstPage() wirklich mit cursor = null und limit = 30 arbeitet. Außerdem kannst du testen, dass loadMore() keine zweite Anfrage startet, solange bereits geladen wird. Solche Tests wirken klein, verhindern aber echte Fehler in Listen, die später in Produktion viele Nutzer und große Datenbestände sehen.

In Offline-First-Apps solltest du zusätzlich prüfen, ob deine Datenbankabfragen begrenzt sind. Eine Query ohne Limit kann lokal genauso gefährlich sein wie ein unbeschränkter Netzwerkaufruf. Wenn die UI nur die neuesten 50 Nachrichten zeigt, sollte die Query genau diesen Ausschnitt liefern. Synchronisation darf im Hintergrund mehr Daten verwalten, aber die Oberfläche muss nicht alles gleichzeitig im Arbeitsspeicher tragen.

Fazit

Large Data Handling ist eine Grundfähigkeit für stabile Android-Apps: Du behandelst große Datenmengen nicht als eine riesige Liste, sondern als begrenzten, kontrollierten Datenfluss. Streaming, Pagination und bewusster Memory-Umgang helfen dir, UI, Repository und ViewModel sauber zu halten. Prüfe dieses Thema aktiv in deinem eigenen Code: Setze Limits, beobachte Speicher im Profiler, teste Ladegrenzen und frage im Code-Review immer, ob die App wirklich nur die Daten lädt, die sie für die aktuelle Aufgabe braucht.

Quellen (4)
Redaktion

Geschrieben von

Redaktion

Das Redaktionsteam recherchiert und schreibt Artikel zu aktuellen Themen rund um Tech, Lifestyle und Ratgeber.