Android Coden
Android 7 min lesen

Flow Backpressure in Android

Wenn ein Flow zu schnell sendet, braucht dein Consumer eine Strategie. Du lernst buffer, conflate und collectLatest gezielt einzusetzen.

Flow Backpressure beschreibt den Moment, in dem ein Flow mehr Werte produziert, als dein Code beim Sammeln verarbeiten kann. In Android-Apps passiert das häufiger, als es zuerst klingt: Texteingaben kommen schnell, Standortdaten ändern sich laufend, Datenbankabfragen liefern neue Zustände, und eine Compose-Oberfläche muss daraus stabile UI machen. Mit buffer, conflate und collectLatest steuerst du, ob Werte zwischengespeichert, übersprungen oder laufende Arbeit abgebrochen wird.

Was ist das?

Ein Kotlin Flow ist ein asynchroner Datenstrom. Ein Producer erzeugt Werte, ein Consumer sammelt sie mit collect oder einer verwandten Funktion. Backpressure entsteht, wenn der Producer schneller sendet, als der Consumer die Werte verarbeiten kann. Das Problem ist nicht nur theoretisch: Wenn dein Collector für jeden Wert eine teure Operation startet, etwa Formatierung, Mapping, Datenbankzugriff oder UI-nahe Arbeit, kann sich Verarbeitung aufstauen.

Das mentale Modell ist eine Warteschlange zwischen zwei Arbeitsbereichen. Auf der einen Seite entstehen Werte. Auf der anderen Seite wird jeder Wert verarbeitet. Wenn beide gleich schnell sind, bleibt alles ruhig. Wenn die Verarbeitung länger dauert, musst du entscheiden, was mit den zusätzlichen Werten passieren soll. Sollen sie warten? Sollen ältere Werte verworfen werden? Soll eine laufende Verarbeitung gestoppt werden, weil ein neuer Wert wichtiger ist?

In modernem Android ist diese Entscheidung Teil sauberer Architektur. Ein Repository kann Daten als Flow liefern. Ein ViewModel formt daraus UI-State. Compose sammelt diesen State und rendert ihn. Wenn du Backpressure ignorierst, bekommst du nicht automatisch einen Crash, aber oft ein schlechteres Verhalten: verzögerte UI, unnötige Arbeit, alte Suchergebnisse, hoher Akkuverbrauch oder Tests, die zufällig langsam werden. Gute Flow-Nutzung bedeutet daher nicht, möglichst viele Operatoren zu kennen, sondern die passende Strategie für die Bedeutung deiner Daten zu wählen.

Wie funktioniert es?

Standardmäßig ist ein Flow kooperativ und sequenziell: Der Producer wartet häufig darauf, dass der Collector mit dem aktuellen Wert fertig ist. Das ist sicher und leicht zu verstehen, kann aber zu langsam sein, wenn Produktion und Verarbeitung unabhängig voneinander laufen könnten. Backpressure-Operatoren verändern diese Beziehung.

buffer legt einen Puffer zwischen Upstream und Downstream. Der Producer darf mehrere Werte vorproduzieren, während der Consumer noch arbeitet. Das kann Durchsatz verbessern, wenn beide Seiten parallel laufen können. Du bezahlst dafür mit Speicherbedarf und mit der Gefahr, dass ältere Werte noch verarbeitet werden, obwohl sie für die UI nicht mehr relevant sind. buffer passt, wenn jeder Wert fachlich wichtig ist, etwa bei Log-Ereignissen, Upload-Schritten oder einer Liste von Aufgaben, die vollständig abgearbeitet werden muss.

conflate ist anders. Es sagt: Wenn der Consumer nicht hinterherkommt, behalte nur den neuesten Wert. Zwischenwerte dürfen wegfallen. Das passt gut zu Zuständen, nicht zu Ereignissen. Ein Ladefortschritt von 12, 13, 14, 15 Prozent muss in der UI nicht zwingend jede einzelne Zahl anzeigen. Der aktuelle Wert reicht. Bei Bestellaktionen, Chat-Nachrichten oder Analytics-Events wäre conflate dagegen gefährlich, weil ausgelassene Werte Bedeutung tragen können.

collectLatest wirkt am Collector-Ende. Wenn ein neuer Wert kommt, während der vorherige noch verarbeitet wird, wird die alte Verarbeitung abgebrochen und mit dem neuen Wert neu begonnen. Das ist nützlich, wenn nur das Ergebnis der neuesten Eingabe zählt. Typische Beispiele sind Suchfelder, Filter in Listen oder das Laden einer Vorschau. Wenn der Nutzer weiter tippt, soll die App nicht jede alte Anfrage zu Ende führen und danach womöglich alte Ergebnisse anzeigen.

Wichtig ist dabei die Abbruchfähigkeit. collectLatest kann nur sinnvoll abbrechen, wenn die laufende Arbeit Coroutine-freundlich ist. Suspend-Funktionen wie Netzwerkaufrufe über passende Libraries, delay, Flow-Operatoren und viele Jetpack-APIs reagieren auf Cancellation. Blockierender Code ohne Suspension kann dagegen weiterlaufen und den erwarteten Effekt abschwächen. Deshalb gehört Backpressure immer auch zur Coroutine-Disziplin: Nutze passende Dispatcher, halte ViewModels frei von schwerer Main-Thread-Arbeit, und mache lange Operationen abbrechbar.

In Compose begegnet dir Backpressure oft indirekt. Ein ViewModel stellt StateFlow bereit, Compose sammelt ihn lifecycle-bewusst, und jede Änderung kann Recomposition auslösen. Du solltest hier nicht blind jeden Zwischenwert in die UI drücken. Für UI-State ist der neueste gültige Zustand meist wichtiger als die komplette Historie. Für einmalige Ereignisse, zum Beispiel Navigation oder Snackbar-Anfragen, brauchst du dagegen eine andere Modellierung, weil Überspringen dort Fehler erzeugen kann.

In der Praxis

Stell dir ein Suchfeld vor. Der Nutzer tippt schnell: “k”, “ko”, “kot”, “kotl”, “kotli”, “kotlin”. Ohne Backpressure-Strategie kann dein Code für jede Eingabe eine Suche starten. Wenn ältere Suchen später fertig werden als neue, kann die UI veraltete Treffer anzeigen. Außerdem verschwendest du Rechenzeit und Netzwerkbandbreite.

Im ViewModel könntest du die Eingaben als MutableStateFlow modellieren und nur die neueste Suche ausführen:

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

    private val query = MutableStateFlow("")

    val results: StateFlow<SearchUiState> =
        query
            .debounce(300)
            .distinctUntilChanged()
            .flatMapLatest { text ->
                if (text.length < 2) {
                    flowOf(SearchUiState.Empty)
                } else {
                    flow {
                        emit(SearchUiState.Loading)
                        val hits = repository.searchArticles(text)
                        emit(SearchUiState.Content(hits))
                    }
                }
            }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = SearchUiState.Empty
            )

    fun onQueryChanged(newQuery: String) {
        query.value = newQuery
    }
}

Hier ist flatMapLatest eng verwandt mit der Idee von collectLatest: Alte Arbeit wird verworfen, sobald eine neue Eingabe wichtiger ist. Für eine direkte Verarbeitung im Collector sähe das Muster so aus:

viewModel.queryFlow
    .collectLatest { text ->
        showLoading()
        val results = repository.searchArticles(text)
        render(results)
    }

Dieses Muster ist richtig, wenn alte Ergebnisse keinen Wert mehr haben. Es wäre falsch, wenn jede Eingabe gespeichert werden muss, etwa bei einem Audit-Log. Dann würdest du eher mit buffer arbeiten oder eine robuste Queue nutzen.

Eine einfache Entscheidungsregel hilft im Alltag:

Wenn jeder Wert fachlich zählt, verwende kein conflate und sei vorsichtig mit collectLatest. Prüfe buffer, begrenze den Puffer bewusst und denke über Fehlerbehandlung nach. Wenn nur der neueste Zustand zählt, ist conflate oft passend. Wenn eine laufende Aufgabe durch eine neue Eingabe ersetzt werden soll, ist collectLatest oder ein latest-Operator wie flatMapLatest meistens die bessere Wahl.

Eine typische Stolperfalle ist die Verwechslung von Zustand und Ereignis. Ein UI-Zustand wie “aktueller Filter” darf ältere Werte verlieren. Ein Ereignis wie “Zahlung gestartet” darf nicht verloren gehen. Wenn du bei Ereignissen conflate nutzt, kann deine App seltene, schwer reproduzierbare Fehler bekommen. Eine weitere Stolperfalle ist ein zu großer oder unbedachter buffer. Er kann Symptome verstecken: Die App wirkt kurz flüssiger, verarbeitet aber im Hintergrund alte Werte weiter. Bei langsamen Geräten oder schlechter Verbindung fällt das später als Verzögerung oder erhöhter Ressourcenverbrauch auf.

Für Qualität und Tests solltest du Backpressure-Verhalten explizit prüfen. In Unit-Tests kannst du einen Flow erzeugen, der schnell mehrere Werte sendet, und einen Collector simulieren, der langsam verarbeitet. Dann erwartest du je nach Strategie unterschiedliche Ergebnisse: Bei buffer kommen alle relevanten Werte an, bei conflate nur ausgewählte Zustände, bei collectLatest nur die zuletzt abgeschlossene Arbeit. In Code-Reviews lohnt sich eine klare Frage: “Ist es fachlich korrekt, dass Zwischenwerte verloren gehen oder laufende Arbeit abgebrochen wird?” Diese Frage ist oft wichtiger als die genaue Operator-Reihenfolge.

Auch Debugging hilft. Logge nicht nur Werte, sondern Zeitpunkte und Coroutine-Namen, wenn du ein Timing-Problem untersuchst. Prüfe, ob alte Ergebnisse nach neuen Ergebnissen in der UI landen. Achte darauf, ob Arbeit im viewModelScope sauber beendet wird, wenn der Screen verschwindet. Android-Qualität entsteht hier durch kleine, konkrete Entscheidungen: weniger unnötige Arbeit, keine veralteten UI-Zustände und nachvollziehbares Verhalten unter Last.

Fazit

Flow Backpressure zwingt dich zu einer fachlichen Entscheidung: Warten, überspringen oder abbrechen. buffer ist sinnvoll, wenn Werte vollständig verarbeitet werden müssen und parallele Verarbeitung hilft. conflate passt zu Zuständen, bei denen nur der neueste Wert zählt. collectLatest passt zu Arbeit, die durch neuere Eingaben ersetzt wird. Prüfe das Gelernte an einem kleinen Flow-Test oder an einem Suchfeld im ViewModel: Erzeuge schnelle Eingaben, verlangsame den Collector künstlich, und kontrolliere im Debugger oder Test, welche Werte wirklich verarbeitet werden.

Quellen (5)
Redaktion

Geschrieben von

Redaktion

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