Coroutine Exceptions in Kotlin verstehen
Du lernst, wie Exceptions in Coroutines weitergegeben werden. Der Artikel zeigt Regeln für Failure, Propagation und Supervision.
Coroutine Exceptions entscheiden darüber, ob ein einzelner fehlgeschlagener Hintergrundtask nur eine Fehlermeldung im UI auslöst oder ob gleich eine ganze Gruppe laufender Arbeiten abgebrochen wird. Wenn du Coroutines in Android nutzt, reicht es deshalb nicht, try/catch mechanisch um Code zu legen. Du brauchst ein klares Modell dafür, wie Fehler durch Jobs, Scopes und strukturierte Nebenläufigkeit wandern.
Was ist das?
Coroutine Exceptions sind Exceptions, die während der Ausführung einer Coroutine entstehen. Das kann ein Netzwerkfehler sein, ein Parsing-Fehler, eine fehlgeschlagene Datenbankoperation oder ein Programmierfehler wie ein unerwarteter NullPointerException. Der wichtige Punkt ist nicht nur, dass ein Fehler passiert. Entscheidend ist, wo er passiert, wer ihn beobachtet und welche anderen Coroutines dadurch beendet werden.
In Kotlin sind Coroutines über Jobs strukturiert. Ein CoroutineScope besitzt einen Kontext, und dieser Kontext enthält in typischen Fällen einen Job. Startest du in diesem Scope weitere Coroutines, entstehen Kind-Jobs. Diese Struktur ist gewollt: Arbeit soll nicht unkontrolliert im Hintergrund weiterlaufen, wenn der übergeordnete Lebenszyklus vorbei ist. In Android ist das besonders wichtig, weil ein ViewModel, ein Screen oder eine Repository-Funktion nicht beliebig lange Ressourcen halten darf.
Das Fehlermodell hängt direkt an dieser Struktur. Wenn eine Kind-Coroutine mit einer normalen Exception fehlschlägt, meldet sie diesen Fehler an ihren Parent. Der Parent wird dadurch ebenfalls abgebrochen. Danach werden meist auch die anderen Kinder dieses Parents abgebrochen. Dieses Verhalten nennt man Failure Propagation: Der Fehler wird nach oben weitergereicht und führt zum Abbruch der zusammengehörenden Arbeit.
Das ist zunächst streng, aber sinnvoll. Wenn mehrere Coroutines gemeinsam ein Ergebnis vorbereiten, kann ein einzelner Fehler bedeuten, dass das Gesamtergebnis nicht mehr gültig ist. Lädt eine Funktion zum Beispiel parallel Profil, Berechtigungen und Einstellungen, dann kann ein fehlgeschlagener Pflichtteil das gesamte Laden scheitern lassen. Strukturierte Nebenläufigkeit gibt dir hier eine klare Regel: Zusammen gestartete Arbeit wird zusammen beendet.
In realen Android-Apps willst du aber nicht immer dieses harte Verhalten. Manchmal sollen mehrere Aufgaben unabhängig laufen. Wenn das Laden eines optionalen Empfehlungsbereichs fehlschlägt, soll der Hauptinhalt trotzdem sichtbar bleiben. Genau hier kommen Supervision-Konzepte ins Spiel. Ein SupervisorJob oder supervisorScope sorgt dafür, dass ein fehlschlagendes Kind nicht automatisch seine Geschwister beendet. Supervision bedeutet also nicht, Fehler zu verstecken. Es bedeutet, die Ausbreitung eines Fehlers bewusst zu begrenzen.
Wie funktioniert es?
Das Grundmodell besteht aus drei Begriffen: Job, Parent-Child-Beziehung und Exception-Ausbreitung. Jede gestartete Coroutine hat einen Job. Wenn du mit launch innerhalb eines bestehenden Scopes startest, wird der neue Job normalerweise Kind des Scope-Jobs. Wenn das Kind fehlschlägt, wird der Parent informiert. Ohne Supervision führt das zur Cancellation des Parents. Cancellation ist dabei kein normaler Fachfehler, sondern ein Steuermechanismus: Die Coroutine soll aufhören, weil ihr Kontext nicht mehr gültig ist oder weil die zusammengehörende Arbeit abgebrochen wurde.
Du solltest zwischen launch und async unterscheiden. Eine mit launch gestartete Coroutine liefert kein Ergebnis zurück. Wenn sie mit einer Exception fehlschlägt und die Exception nicht innerhalb der Coroutine behandelt wird, wird der Fehler an die Job-Struktur weitergegeben. Eine mit async gestartete Coroutine kapselt ihr Ergebnis in einem Deferred. Die Exception wird beim Aufruf von await() erneut ausgelöst. Trotzdem ist async nicht frei von strukturierter Nebenläufigkeit: Auch hier kann ein Fehler den Parent beeinflussen, wenn du ihn nicht passend behandelst oder keinen Supervisor-Kontext nutzt.
Ein häufiger Denkfehler ist: „Ich fange die Exception später schon ab.“ Bei async kann das stimmen, wenn du wirklich an der richtigen Stelle await() in einem try/catch nutzt. Bei launch funktioniert dieses spätere Abholen nicht, weil es kein Ergebnisobjekt gibt, über das du die Exception konsumierst. Dann musst du die Exception im Block selbst behandeln oder den Scope so aufbauen, dass ein zentraler Handler sinnvoll ist. Ein CoroutineExceptionHandler kann bei nicht behandelten Exceptions in Root-Coroutines helfen, ist aber kein Ersatz für saubere Fehlerbehandlung in deiner Fachlogik.
In Android begegnest du diesem Thema besonders oft im ViewModel. viewModelScope ist an den Lebenszyklus des ViewModels gebunden. Wenn das ViewModel gelöscht wird, werden seine Coroutines abgebrochen. Das schützt dich vor Leaks und unnötiger Arbeit. Innerhalb von viewModelScope.launch lädst du Daten, aktualisierst StateFlow oder reagierst auf UI-Events. Wenn dort eine Exception ungefangen bleibt, kann die gestartete Coroutine abbrechen. Je nach Struktur kann das auch weitere Arbeit betreffen.
Das passende mentale Modell lautet: Eine Coroutine ist kein isolierter Thread, der irgendwo nebenbei läuft. Sie ist Teil eines Aufgabenbaums. Fehler laufen in diesem Baum normalerweise nach oben. Cancellation läuft von oben nach unten. Wenn der Parent abgebrochen wird, werden seine Kinder abgebrochen. Wenn ein Kind fehlschlägt, kann es den Parent zum Abbruch bringen. Supervision verändert genau die Richtung Kind zu Parent: Ein Kind darf scheitern, ohne den Supervisor und die anderen Kinder automatisch mitzureißen.
Wichtig ist auch die Unterscheidung zwischen erwartbaren Fehlern und Programmierfehlern. Ein HTTP-404, ein Timeout oder ein leerer Cache sind erwartbare Situationen. Sie gehören oft in einen UI-Zustand wie Error, Offline oder Empty. Ein Fehler durch eine verletzte Annahme im Code ist etwas anderes. Den solltest du nicht pauschal verschlucken, weil du sonst echte Defekte schwer findest. Gute Coroutine-Fehlerbehandlung macht erwartbare Fehler sichtbar und lässt unerwartete Fehler nicht unbemerkt verschwinden.
In der Praxis
Nehmen wir ein ViewModel, das zwei Bereiche eines Screens lädt: ein Benutzerprofil und optionale Empfehlungen. Das Profil ist nötig, Empfehlungen sind Zusatzinhalt. Wenn die Empfehlungen fehlschlagen, soll der Screen trotzdem nutzbar bleiben. Hier ist Supervision sinnvoll.
class ProfileViewModel(
private val repository: ProfileRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun loadProfileScreen(userId: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
supervisorScope {
val profileJob = launch {
try {
val profile = repository.loadProfile(userId)
_uiState.update { it.copy(profile = profile) }
} catch (exception: IOException) {
_uiState.update {
it.copy(errorMessage = "Profil konnte nicht geladen werden.")
}
throw exception
}
}
val recommendationsJob = launch {
try {
val recommendations = repository.loadRecommendations(userId)
_uiState.update { it.copy(recommendations = recommendations) }
} catch (exception: IOException) {
_uiState.update { it.copy(recommendations = emptyList()) }
}
}
profileJob.join()
recommendationsJob.join()
}
_uiState.update { it.copy(isLoading = false) }
}
}
}
data class ProfileUiState(
val isLoading: Boolean = false,
val profile: Profile? = null,
val recommendations: List<Recommendation> = emptyList(),
val errorMessage: String? = null
)
Das Beispiel zeigt zwei unterschiedliche Fehlerregeln. Beim Profil wird ein IOException zwar in den UI-Zustand übersetzt, danach aber erneut geworfen. Dadurch bleibt klar: Das Laden des Profils ist ein harter Fehler für diese Aktion. Bei den Empfehlungen wird der Fehler abgefangen und in einen leeren Zusatzbereich übersetzt. Das ist nur dann korrekt, wenn dieser Bereich wirklich optional ist.
Du musst hier genau hinschauen. Wenn du in jeder Coroutine pauschal catch (Exception) schreibst und danach nichts weiter tust, hast du kein robustes System gebaut. Du hast Fehler versteckt. Besonders problematisch ist es, CancellationException versehentlich zu schlucken. Cancellation ist Teil des Coroutine-Mechanismus. Wenn eine Coroutine abgebrochen wird, sollte dieser Abbruch normalerweise weiterlaufen dürfen. Fängst du sehr breit, solltest du Cancellation wieder werfen.
try {
repository.refresh()
} catch (exception: CancellationException) {
throw exception
} catch (exception: IOException) {
showOfflineMessage()
}
Diese Regel ist für Android-Alltag sehr praktisch: Fange erwartbare Fach- und Infrastrukturfehler gezielt, aber behandle Cancellation nicht wie einen normalen Fehler. So kann viewModelScope weiterhin sauber abbrechen, wenn das ViewModel nicht mehr gebraucht wird.
Eine zweite Stolperfalle betrifft parallele Arbeit ohne klare Zuständigkeit. Wenn du mehrere launch-Blöcke in einem normalen Scope startest, darfst du nicht überrascht sein, wenn ein Fehler alle Geschwister beendet. Das ist kein zufälliges Verhalten, sondern Teil des Modells. Frage dich deshalb vor dem Start paralleler Coroutines: Gehören diese Aufgaben fachlich zusammen, oder sind sie unabhängig?
Eine einfache Entscheidungsregel hilft dir im Code-Review:
Wenn alle Teilaufgaben gemeinsam ein einziges Ergebnis bilden, nutze normale strukturierte Nebenläufigkeit und lasse Fehler die Gruppe abbrechen. Wenn Teilaufgaben unabhängig im UI erscheinen dürfen, nutze Supervision und behandle Fehler pro Teilbereich sichtbar.
In Jetpack Compose zeigt sich das oft indirekt. Compose selbst rendert nur den Zustand, den dein ViewModel bereitstellt. Wenn eine Coroutine im ViewModel fehlschlägt und keinen sauberen Zustand setzt, sieht die UI vielleicht dauerhaft einen Ladezustand. Das Problem wirkt dann wie ein Compose-Fehler, liegt aber im Coroutine-Fehlermodell. Deshalb sollte dein UI-State immer Zustände für Laden, Erfolg und Fehler ausdrücken. Exceptions sind ein internes Signal, kein gutes direktes UI-Modell.
Beim Testen kannst du dein Verständnis gut prüfen. Schreibe einen Test, in dem ein Repository gezielt eine IOException wirft. Danach prüfst du, ob der erwartete Fehlerzustand im StateFlow landet und ob optionale Bereiche weiter funktionieren. In einem zweiten Test kannst du simulieren, dass eine Pflichtaufgabe fehlschlägt, und prüfen, dass abhängige Arbeit nicht so tut, als wäre alles erfolgreich. Solche Tests machen Failure Propagation sichtbar.
Auch der Debugger ist nützlich. Setze Breakpoints in beide parallelen Coroutines und beobachte, welche Jobs nach einer Exception noch aktiv sind. In Logs solltest du nicht nur die Exception sehen, sondern auch erkennen, welche fachliche Aktion betroffen war. Ein Log wie „loadProfile failed for userId=…“ ist hilfreicher als ein nackter Stacktrace ohne Kontext. Achte aber darauf, keine sensiblen Daten zu loggen.
Fazit
Coroutine Exceptions sind ein Kernstück professioneller Android-Entwicklung mit Kotlin, weil sie bestimmen, wie Fehler durch deinen Aufgabenbaum wandern. Baue dir das Modell bewusst auf: Fehler gehen ohne Supervision nach oben, Cancellation geht vom Parent zu den Kindern, und Supervision begrenzt die Ausbreitung zwischen Geschwistern. Prüfe dieses Wissen aktiv an kleinen ViewModel-Beispielen, schreibe Tests für Fehlerpfade und achte im Code-Review darauf, ob try/catch, async, launch, supervisorScope und UI-State fachlich zusammenpassen.