Android Coden
Android 7 min lesen

Flow Error Handling in Kotlin

Du lernst, Flow-Fehler gezielt zu behandeln. So bleiben Apps stabil, ohne echte Bugs zu verdecken.

Flow Error Handling bedeutet, dass du Fehler in einem Kotlin-Flow bewusst behandelst: Du entscheidest, wann ein Datenstrom einen Fallback liefern darf, wann ein Vorgang wiederholt wird und wann ein Fehler sichtbar bleiben muss. In Android-Apps ist das wichtig, weil Flows häufig UI-Zustände, Datenbankänderungen, Netzwerkantworten oder Suchergebnisse liefern und ein unklar behandelter Fehler schnell zu leeren Screens, endlosen Ladeanzeigen oder schwer auffindbaren Bugs führt.

Was ist das?

Ein Flow ist ein asynchroner Datenstrom. Er kann mehrere Werte nacheinander ausgeben, pausieren, wieder weiterlaufen oder durch eine Exception abbrechen. Flow Error Handling beschreibt die Werkzeuge und Regeln, mit denen du diesen Abbruch kontrollierst. Die wichtigsten Bausteine in diesem Thema sind catch, retryWhen und onCompletion.

Das mentale Modell ist einfach: Ein Flow läuft von oben nach unten durch seine Operatoren. Wenn oberhalb eines Operators eine Exception entsteht, kann ein darunter liegender Operator darauf reagieren. catch fängt Fehler aus dem vorgelagerten Teil der Kette ab. retryWhen entscheidet, ob der vorgelagerte Flow nach einem Fehler erneut gestartet wird. onCompletion wird ausgeführt, wenn der Flow endet, egal ob regulär, durch Abbruch oder durch Fehler.

In modernem Android-Code findest du Flow Error Handling oft im Repository oder Use Case, nicht direkt in einer Compose-Funktion. Die UI soll meist nur einen klaren Zustand anzeigen: lädt, Daten vorhanden, leer oder Fehler. Die Entscheidung, ob ein Netzwerkfehler wiederholt wird oder ob eine lokale Cache-Antwort als Ersatz genügt, gehört näher an die Datenquelle. So bleibt dein ViewModel lesbar und deine Compose-Oberfläche reagiert nur auf StateFlow oder einen ähnlichen UI-State.

Der zentrale Punkt lautet: Du willst Stream-Fehler abfangen, ohne schwere Fehler zu verstecken. Ein Timeout beim Laden einer Liste ist ein erwartbarer Laufzeitfehler. Ein NullPointerException, weil deine Mapping-Logik falsche Annahmen macht, ist wahrscheinlich ein Bug. Wenn du beide gleich behandelst und überall nur einen generischen Fehlerzustand sendest, wird deine App zwar scheinbar stabiler, aber deine Codequalität sinkt.

Wie funktioniert es?

catch ist der bekannteste Operator für Flow Error Handling. Er wird in die Flow-Kette eingefügt und bekommt die Exception, die im vorherigen Teil der Kette geworfen wurde. Innerhalb von catch kannst du einen Ersatzwert mit emit(...) senden, den Fehler loggen oder die Exception erneut werfen. Wichtig ist die Position: catch fängt nicht automatisch Fehler, die nach ihm in späteren Operatoren oder im Collector entstehen.

Das ist für Anfänger oft überraschend. Wenn du zuerst catch setzt und danach in map einen Fehler erzeugst, wird dieser Fehler nicht von diesem catch behandelt. Baue die Kette daher bewusst: Datenquelle, Transformationen, Wiederholungslogik, Fehlerbehandlung, dann Weitergabe an den Collector oder an stateIn.

retryWhen wird vor catch eingesetzt, wenn ein Fehler nicht sofort endgültig sein soll. Der Operator bekommt die Exception und die Nummer des aktuellen Versuchs. Du gibst true zurück, wenn der Flow erneut gestartet werden soll, und false, wenn der Fehler weiterlaufen darf. Damit ist retryWhen gut für vorübergehende Probleme geeignet, etwa instabile Verbindung, HTTP-Timeout oder kurzzeitig nicht erreichbare Dienste. Du solltest Wiederholungen aber immer begrenzen. Ein unbegrenztes Retry kann Akku, Datenvolumen und Server unnötig belasten und die UI in einem unehrlichen Ladezustand halten.

onCompletion ist kein Ersatz für catch. Es ist eher mit einem Abschluss-Hook vergleichbar. Du kannst dort Ladeindikatoren zurücksetzen, Metriken erfassen oder Ressourcen aufräumen. Der Operator bekommt eine optionale Ursache: Ist sie null, wurde der Flow regulär abgeschlossen. Ist sie nicht null, gab es einen Fehler oder Abbruch. Trotzdem solltest du in onCompletion nicht so tun, als sei ein Fehler behandelt. Für Fehlerzustände ist catch klarer, testbarer und leichter im Code-Review zu erkennen.

In Android-Architektur passt diese Trennung gut zu den üblichen Schichten. Ein DAO-Flow aus Room liefert fortlaufend lokale Daten. Ein Repository kombiniert lokale und entfernte Daten. Ein ViewModel wandelt daraus einen UI-State. Fehler aus Netzwerkaufrufen behandelst du oft im Repository, weil dort Wissen über Cache, Retry und Domain-Regeln liegt. Fehler, die nur die Darstellung betreffen, gehören eher in die UI-Schicht, sollten aber selten als Flow-Fehler modelliert werden.

Auch Coroutine-Abbruch spielt eine Rolle. Flows laufen in Coroutines, und Android nutzt Coroutine-Scopes wie viewModelScope, damit Arbeit an Lebenszyklen gebunden ist. Eine Cancellation ist nicht automatisch ein fachlicher Fehler. Wenn ein Screen verlassen wird, darf ein laufender Flow abgebrochen werden. Achte deshalb darauf, Cancellation nicht blind in einen Fehlerzustand umzuwandeln. Sonst zeigt deine App vielleicht eine Fehlermeldung, obwohl der Nutzer nur navigiert hat.

In der Praxis

Nimm an, du lädst Artikel aus einem Repository und willst in deinem ViewModel einen stabilen UI-State bereitstellen. Netzwerkfehler dürfen zwei Mal wiederholt werden. Danach soll die UI einen Fehlerzustand sehen. Gleichzeitig soll eine Ladeanzeige sauber beendet werden, auch wenn der Flow fehlschlägt.

sealed interface ArticleUiState {
    data object Loading : ArticleUiState
    data class Content(val items: List<Article>) : ArticleUiState
    data class Error(val message: String) : ArticleUiState
}

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

    val uiState: StateFlow<ArticleUiState> =
        repository.observeArticles()
            .map<List<Article>, ArticleUiState> { articles ->
                ArticleUiState.Content(articles)
            }
            .retryWhen { cause, attempt ->
                val mayRetry = cause is IOException && attempt < 2
                if (mayRetry) {
                    delay(1_000)
                }
                mayRetry
            }
            .catch { cause ->
                if (cause is CancellationException) throw cause

                emit(
                    ArticleUiState.Error(
                        message = "Artikel konnten nicht geladen werden."
                    )
                )
            }
            .onCompletion { cause ->
                if (cause != null && cause !is CancellationException) {
                    Log.w("ArticleViewModel", "Article flow completed with error", cause)
                }
            }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = ArticleUiState.Loading
            )
}

An diesem Beispiel siehst du mehrere Entscheidungen. retryWhen steht vor catch, damit erwartbare IOExceptions zuerst wiederholt werden können. Erst wenn keine Wiederholung mehr stattfinden soll, erreicht der Fehler catch. Dort wird ein UI-Fehlerzustand gesendet. Eine CancellationException wird erneut geworfen, weil ein Coroutine-Abbruch kein normaler Ladefehler ist. onCompletion dient hier nur zum Logging des Abschlusses, nicht zur Korrektur des Datenstroms.

Eine typische Stolperfalle ist ein zu breites catch. Wenn du jede Exception in ArticleUiState.Error umwandelst, kann ein echter Programmierfehler verschwinden. Beispiel: Deine Mapping-Funktion erwartet, dass ein Feld immer gesetzt ist, aber die Annahme stimmt nicht. Wenn daraus ein Fehler entsteht und du ihn still in eine generische UI-Meldung verwandelst, fehlt dir im Debugging ein wichtiges Signal. Behandle deshalb erwartbare Fehler gezielt und lass unerwartete Fehler mindestens sichtbar werden, etwa durch Logging, Crash-Reporting oder erneutes Werfen in Bereichen, in denen die App nicht sinnvoll fortfahren kann.

Eine brauchbare Entscheidungsregel lautet: Fange dort ab, wo du eine sinnvolle fachliche Antwort geben kannst. Wenn das Repository einen Cache liefern kann, darf es einen Netzwerkfehler behandeln. Wenn das ViewModel nur eine Meldung anzeigen kann, sollte es einen klaren Fehlerzustand erzeugen. Wenn du keine sinnvolle Antwort hast, ist Verschleiern keine Fehlerbehandlung.

Beim Testen kannst du Flow Error Handling gut isolieren. Erstelle ein Fake-Repository, das zuerst eine IOException wirft und danach Daten liefert. Prüfe, ob retryWhen wirklich erneut sammelt. Erstelle einen zweiten Test, in dem der Fehler dauerhaft bleibt, und prüfe, ob der UI-State zu Error wird. Für catch ist wichtig, auch die negative Seite zu testen: Ein Fehler nach dem catch sollte nicht versehentlich als behandelt gelten. So lernst du die Positionsregel der Flow-Operatoren nicht nur theoretisch, sondern im Verhalten deines Codes.

Im Code-Review solltest du bei Flow Error Handling auf drei Fragen achten. Erstens: Wird nur das abgefangen, was fachlich erwartet wird? Zweitens: Sind Wiederholungen begrenzt und nachvollziehbar? Drittens: Wird Abschlusslogik in onCompletion von Fehlerbehandlung in catch getrennt? Wenn du diese Fragen sauber beantworten kannst, ist der Flow meist robust genug für echte App-Nutzung.

Fazit

Flow Error Handling hilft dir, Android-Apps stabiler zu machen, ohne wichtige Fehlersignale zu verlieren. Nutze retryWhen für begrenzte Wiederholungen bei vorübergehenden Problemen, catch für gezielte Ersatzwerte oder Fehlerzustände und onCompletion für Abschluss- oder Aufräumlogik. Prüfe dein Verständnis aktiv: Setze Breakpoints in jeden Operator, schreibe einen Test mit einem fehlschlagenden Fake-Flow und lies im Code-Review bewusst die Reihenfolge der Operatoren. Genau dort entscheidet sich, ob dein Flow Fehler kontrolliert behandelt oder sie nur verdeckt.

Quellen (3)
Redaktion

Geschrieben von

Redaktion

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