Flow-Debugging in Android
Du lernst, fehlende oder doppelte Flow-Emissions zu finden. Der Fokus liegt auf Collectors, Lifecycle und Sharing.
Flow-Debugging bedeutet, dass du systematisch prüfst, warum ein Kotlin Flow in deiner Android-App zu viele, zu wenige oder scheinbar gar keine Werte liefert. Der Kern ist selten nur der Operator selbst. Meist musst du klären, wann ein Flow gesammelt wird, wie lange der Collector lebt, ob mehrere Collectors aktiv sind und welche Sharing-Policy bei stateIn oder shareIn gilt.
Was ist das?
Ein Flow beschreibt eine asynchrone Folge von Werten. Diese Werte nennt man Emissions. Ein Collector ist der Code, der diese Werte sammelt, also auf sie reagiert. Im Android-Alltag nutzt du Flows häufig im Repository, im Use Case oder im ViewModel, um Daten aus Datenbank, Netzwerk, DataStore oder UI-State an die Oberfläche zu bringen. In Compose wird daraus oft ein State, der die UI neu zeichnet.
Flow-Debugging ist die praktische Diagnose dieser Datenkette. Du fragst nicht nur: „Kommt der richtige Wert aus dem Repository?“ Du fragst auch: „Ist gerade überhaupt ein Collector aktiv? Wird der Flow mehrfach gesammelt? Wird eine kalte Quelle bei jedem Collector neu gestartet? Wird ein geteilter Flow beendet, sobald die UI kurz nicht sichtbar ist?“ Diese Fragen wirken am Anfang technisch, sind aber entscheidend für echte Apps.
Der wichtigste Denkrahmen ist: Ein Flow produziert nicht automatisch dauerhaft Werte für alle. Ein kalter Flow startet seine Arbeit erst, wenn jemand ihn sammelt. Jeder neue Collector kann eine neue Ausführung auslösen. Ein heißer oder geteilter Flow kann Werte halten oder weiterverteilen, aber nur so, wie du ihn konfigurierst. Wenn du diese Unterschiede übersiehst, suchst du oft an der falschen Stelle.
Im Roadmap-Kontext passt Flow-Debugging genau zwischen Coroutines, Flow, Lifecycle und Architektur. Du hast vielleicht schon gelernt, wie du flow { }, map, combine, catch, stateIn oder collectLatest verwendest. Beim Debugging geht es nun darum, dieses Wissen unter realen Bedingungen zu prüfen: App im Hintergrund, Screen-Rotation, erneutes Navigieren, mehrere UI-Elemente, langsames Netzwerk und Tests mit kontrollierter Zeit.
In modernen Android-Apps ist das besonders wichtig, weil Lifecycle und UI eng zusammenarbeiten. Eine Activity oder ein Fragment kann gestartet, gestoppt und zerstört werden. Eine Compose-Funktion kann mehrfach neu zusammengesetzt werden. Ein ViewModel überlebt Konfigurationswechsel, aber nicht jede Collection in der UI tut das. Wenn du nicht sauber trennst, wo ein Flow erstellt, geteilt und gesammelt wird, bekommst du Fehler, die nur in bestimmten Navigationswegen oder auf langsameren Geräten sichtbar werden.
Wie funktioniert es?
Beim Debuggen von Flow-Problemen gehst du am besten von außen nach innen. Zuerst prüfst du den Collector. Wo wird gesammelt? In Compose sollte UI-State typischerweise lifecycle-bewusst gesammelt werden, etwa mit einer lifecycle-aware API aus dem Android-Umfeld. In klassischen Views nutzt du häufig repeatOnLifecycle, damit die Collection nur in einem passenden Zustand läuft und beim Stoppen automatisch abgebrochen wird. Wenn ein Flow im falschen Scope gesammelt wird, kann er zu früh enden oder länger laufen als die UI.
Danach prüfst du die Lebensdauer der Quelle. Ein Flow im Repository ist oft kalt. Das heißt: Jeder Collector startet die Arbeit neu. Wenn diese Arbeit eine Datenbank beobachtet, ist das oft okay. Wenn sie aber einen Netzwerkrequest auslöst, kann ein zweiter Collector denselben Request erneut starten. Dann siehst du doppelte Emissions oder doppelte Log-Ausgaben. Das Problem liegt dann nicht beim Netzwerkclient, sondern bei der Tatsache, dass du zwei aktive Sammler hast oder die Quelle nicht passend teilst.
Die Sharing-Policy entscheidet, wie ein Flow zwischen mehreren Collectors wiederverwendet wird. Mit stateIn wandelst du einen Flow häufig in einen StateFlow um, der im ViewModel einen aktuellen UI-Zustand hält. Dabei ist SharingStarted wichtig. WhileSubscribed startet die upstream Collection, solange mindestens ein Collector aktiv ist, und beendet sie nach einer optionalen Verzögerung. Eagerly startet sofort. Lazily startet beim ersten Collector und bleibt danach aktiv. Jede Variante hat Folgen. Wenn du fehlende Emissions siehst, kann eine Quelle beendet worden sein, weil kurz kein Collector aktiv war. Wenn du unnötige Arbeit siehst, kann die Quelle zu früh oder dauerhaft laufen.
Ein weiterer Punkt ist der Unterschied zwischen Zustand und Ereignis. StateFlow hält immer den neuesten Zustand. Ein neuer Collector bekommt diesen aktuellen Wert sofort. Das ist ideal für UI-State wie Ladezustand, Datenliste oder Fehlermeldung als Teil des Screens. Für einmalige Ereignisse wie Navigation oder Snackbar ist das heikler, weil ein neuer Collector nach einer Rotation denselben letzten Wert erneut sehen kann. Dann wirkt es wie eine doppelte Emission, obwohl der Flow sich korrekt verhält. Das Debugging muss also auch prüfen, ob die gewählte Flow-Art zum fachlichen Problem passt.
Für Anfänger ist ein klares Modell hilfreich: Stell dir jede Collection wie ein Abonnement vor. Das Abonnement beginnt an einer bestimmten Stelle im Code und endet, wenn der Scope abgebrochen wird oder der Lifecycle-Zustand nicht mehr passt. Ein kalter Flow ist wie ein Rezept, das bei jedem Abonnement neu gekocht wird. Ein geteilter Flow ist eher wie eine Küche, die für mehrere Personen denselben Topf nutzt. Wenn du nicht weißt, wie viele Abonnements gerade aktiv sind, kannst du Emissions kaum zuverlässig erklären.
Beim Debuggen selbst helfen einfache Markierungen. Du kannst mit onStart, onEach, onCompletion und catch sichtbar machen, wann ein Flow startet, welche Werte durchlaufen und wann er endet. Diese Operatoren solltest du gezielt und zeitweise einsetzen. Schreibe nicht wahllos Logs in alle Schichten. Markiere lieber eine konkrete Strecke: Repository-Flow, Mapping im ViewModel, Collection in der UI. So erkennst du, ob der Wert nicht erzeugt, nicht weitergegeben oder nicht gesammelt wird.
Achte auch auf Operatoren, die Werte bewusst verwerfen oder ersetzen. distinctUntilChanged unterdrückt gleiche Folgezustände. collectLatest bricht die Verarbeitung eines alten Werts ab, wenn ein neuer Wert kommt. debounce verzögert Emissions. flatMapLatest wechselt bei neuen Eingaben auf eine neue Quelle. Diese Operatoren sind nützlich, können aber beim Debugging wie ein Verlust wirken. Wenn ein erwarteter Wert nicht in der UI ankommt, prüfe, ob ein Operator ihn bewusst gefiltert oder durch einen neueren Wert ersetzt hat.
In Compose kommt eine eigene Fehlerquelle dazu: Composables können häufig neu ausgeführt werden. Du solltest eine Flow-Collection nicht als beiläufige Nebenwirkung direkt im Body starten. Nutze APIs, die Compose- und Lifecycle-Regeln beachten, oder binde Flows über State an die UI. Wenn du in einer Composable bei jeder Recomposition eine neue Coroutine startest, erzeugst du sehr schnell mehrere Collectors. Das Ergebnis sind doppelte Requests, doppelte Toasts oder Log-Ausgaben, die schwer zu lesen sind.
In der Praxis
Nimm einen Screen, der eine Liste von Artikeln zeigt. Das Repository liefert einen Flow aus der lokalen Datenbank. Das ViewModel kombiniert diesen Flow mit einem Ladezustand und stellt daraus UI-State bereit. Die Compose-UI sammelt diesen State. Ein typischer Fehler wäre, den Repository-Flow an zwei Stellen direkt zu sammeln: einmal für die Liste und einmal für einen Zähler. Wenn der Flow kalt ist und upstream teure Arbeit ausführt, passiert diese Arbeit doppelt.
Eine bessere Struktur ist, den UI-State im ViewModel einmal aufzubauen und als StateFlow zu teilen. Die UI sammelt dann nur diesen Zustand. Für Debugging-Zwecke kannst du die Start- und Endpunkte sichtbar machen:
class ArticlesViewModel(
private val repository: ArticleRepository
) : ViewModel() {
val uiState: StateFlow<ArticlesUiState> =
repository.observeArticles()
.onStart { Log.d("FlowDebug", "Repository flow started") }
.onEach { articles ->
Log.d("FlowDebug", "Articles emitted: ${articles.size}")
}
.map { articles ->
ArticlesUiState(
articles = articles,
count = articles.size,
isEmpty = articles.isEmpty()
)
}
.onCompletion { cause ->
Log.d("FlowDebug", "Repository flow completed: $cause")
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 5_000
),
initialValue = ArticlesUiState()
)
}
@Composable
fun ArticlesRoute(
viewModel: ArticlesViewModel
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
ArticlesScreen(
articles = state.articles,
count = state.count,
isEmpty = state.isEmpty
)
}
An diesem Beispiel kannst du mehrere Dinge prüfen. Wenn der Log-Eintrag Repository flow started bei jeder kleinen UI-Änderung erneut erscheint, sammelt die UI wahrscheinlich nicht stabil oder du erzeugst das ViewModel beziehungsweise den Flow an der falschen Stelle. Wenn der Eintrag nur beim Öffnen des Screens erscheint, aber nach kurzer Navigation zurück wieder erscheint, ist das bei WhileSubscribed erwartbar. Die Quelle wurde gestoppt, weil kein Collector mehr aktiv war, und später neu gestartet.
Die stopTimeoutMillis von 5 Sekunden ist hier eine bewusste Entscheidung. Sie verhindert, dass der upstream Flow sofort stoppt, wenn der Screen sehr kurz keinen Collector hat, etwa während einer Navigation oder eines Lifecycle-Wechsels. Zu kurz gewählt kann diese Zeit dazu führen, dass du fehlende oder neu geladene Zustände beobachtest. Zu lang gewählt hält sie Arbeit länger aktiv, als dein Screen sie braucht. Beim Debugging geht es nicht darum, eine magische Zahl zu finden, sondern die Wirkung dieser Policy zu verstehen.
Eine praktische Entscheidungsregel lautet: Sammle UI-State in der UI, teile fachlichen Screen-State im ViewModel und starte teure upstream Arbeit nicht mehrfach ohne Absicht. Wenn zwei UI-Bausteine denselben Zustand brauchen, gib ihnen denselben bereits berechneten State weiter. Lass nicht beide Bausteine denselben Repository-Flow selbst sammeln. Dadurch bleibt klar, wer der Collector ist und wie viele Collectors du erwartest.
Eine typische Stolperfalle ist das Debuggen nur über die sichtbare UI. Wenn ein Wert nicht angezeigt wird, nimmst du schnell an, dass der Flow ihn nicht emittiert hat. Vielleicht wurde der Wert aber korrekt emittiert, danach durch map in denselben UI-State umgewandelt und von distinctUntilChanged nicht weitergereicht. Oder Compose hat den Wert erhalten, aber dein UI-Zweig zeigt ihn wegen einer Bedingung nicht an. Deshalb solltest du an mehreren Stellen prüfen: Quelle, Transformation, StateFlow und UI-Collection.
Eine zweite Stolperfalle sind doppelte Events nach Rotation oder Rücknavigation. Beispiel: Du modellierst eine Snackbar als Feld im UiState. Nach einer Rotation sammelt die UI den letzten State erneut und zeigt die Snackbar noch einmal. Das ist keine mysteriöse doppelte Emission, sondern eine Folge davon, dass StateFlow den letzten Wert wieder ausliefert. Für echten Zustand ist das richtig. Für einmalige UI-Aktionen brauchst du ein klares Verbrauchsmodell oder eine andere Ereignisstrategie. Beim Code-Review solltest du deshalb fragen: Ist dieser Flow ein Zustand, eine Datenquelle oder ein Ereigniskanal?
Tests helfen, dein Verständnis zu festigen. Du kannst ein ViewModel mit einem Fake-Repository testen, das kontrollierte Emissions sendet. Dann prüfst du, ob der uiState die erwarteten Zustände in der richtigen Reihenfolge liefert. Für Lifecycle-Fragen sind instrumentierte Tests oder gezielte UI-Tests nützlich, weil du Start, Stop und erneutes Sammeln sichtbar machen kannst. Wichtig ist nicht, jeden Flow mit großen Tests zu umstellen. Wichtig ist, kritische Stellen zu prüfen: Sharing-Policy, Initialwert, Fehlerzustand und mehrfaches Sammeln.
Beim Debugger solltest du vorsichtig sein. Breakpoints in Flow-Ketten verändern Timing. Gerade bei debounce, collectLatest oder parallelen Coroutines kann ein angehaltener Thread das Verhalten verzerren. Logs mit Zeitstempel, klare Testdaten und kleine isolierte Tests sind oft aussagekräftiger. Wenn du doch Breakpoints nutzt, setze sie an Collector-Grenzen und nicht überall in der Kette. So siehst du, ob eine Collection aktiv ist, ohne den ganzen Ablauf unnötig zu stören.
Für deine tägliche Arbeit kannst du dir eine kurze Checkliste merken. Erstens: Wie viele Collectors erwarte ich? Zweitens: In welchem Scope laufen sie? Drittens: Ist der Flow kalt, heiß oder geteilt? Viertens: Passt die Sharing-Policy zur UI-Lebensdauer? Fünftens: Werden Werte durch Operatoren gefiltert, ersetzt oder wiederholt? Diese fünf Fragen führen dich meistens schneller zur Ursache als das zufällige Ändern von Operatoren.
Fazit
Flow-Debugging ist die Fähigkeit, Emissions, Collectors und Lifecycle gemeinsam zu betrachten. Fehlende Werte entstehen oft nicht dort, wo du sie zuerst vermutest, sondern durch eine beendete Collection, eine unpassende Sharing-Policy oder einen Operator, der Werte bewusst verändert. Doppelte Werte kommen häufig von mehreren Collectors, kalten Flows mit teurer upstream Arbeit oder falsch modellierten Events. Prüfe das Gelernte aktiv: Baue einen kleinen Screen mit StateFlow, logge Start, Emissions und Completion, ändere SharingStarted, rotiere den Screen und schreibe mindestens einen Test mit einem Fake-Repository. Wenn du danach im Code-Review erklären kannst, wer wann sammelt und warum genau diese Emissions ankommen, hast du das zentrale Debugging-Modell verstanden.