Exceptions in Kotlin und Android
Exceptions zeigen unerwartete Fehler im Programmfluss. Du lernst, sie in Kotlin bewusst zu behandeln.
Exceptions sind ein Werkzeug, um außergewöhnliche Fehler im Programmablauf sichtbar zu machen. In Android-Apps begegnen sie dir beim Parsen von Daten, bei Datei- und Netzwerkzugriffen, bei fehlerhaften Zuständen im Code und in Tests. Entscheidend ist nicht, jede Exception sofort abzufangen. Entscheidend ist, dass du bewusst unterscheidest: Welche Fehler kannst du sinnvoll behandeln, welche müssen sichtbar bleiben, und welche weisen auf einen Programmierfehler hin?
Was ist das?
Eine Exception ist ein Fehlerobjekt, das während der Ausführung geworfen wird und den normalen Ablauf unterbricht. Statt dass eine Funktion einfach mit einem regulären Wert zurückkehrt, signalisiert sie: Hier ist etwas passiert, das nicht zum erwarteten Pfad passt. In Kotlin kann das zum Beispiel eine NumberFormatException sein, wenn ein Text nicht in eine Zahl umgewandelt werden kann, oder eine IllegalStateException, wenn ein Objekt in einem ungültigen Zustand verwendet wird.
Das mentale Modell ist einfach: Dein Programm hat einen Hauptweg und mehrere mögliche Fehlerwege. Exceptions sind nicht der Hauptweg. Sie sind Hinweise darauf, dass eine Annahme nicht erfüllt wurde. Diese Annahme kann von außen kommen, etwa durch Nutzereingaben, Serverantworten oder Dateiinhalte. Sie kann aber auch aus deinem eigenen Code kommen, etwa wenn du eine Funktion mit falschen Parametern aufrufst.
Kotlin unterscheidet sich hier von Java in einem wichtigen Punkt: Kotlin hat keine checked Exceptions. Du wirst also vom Compiler nicht gezwungen, bestimmte Exceptions mit try und catch zu behandeln. Das macht Kotlin-Code oft schlanker, legt aber mehr Verantwortung auf dich. Du musst selbst entscheiden, welche Fehler du an einer Stelle sinnvoll auffangen kannst.
Im Android-Kontext ist das besonders wichtig, weil Fehler oft direkt die Nutzererfahrung beeinflussen. Wenn eine App beim Laden eines Profils abstürzt, sieht der Nutzer nur einen Crash. Wenn du denselben Fehler bewusst behandelst, kannst du eine Fehlermeldung anzeigen, einen Retry anbieten, ein leeres UI darstellen oder den Fehler an eine höhere Schicht weitergeben. Gute Fehlerbehandlung ist deshalb ein Teil von Architektur, Qualität und Release-Reife.
Exceptions sind aber kein Ersatz für normale Entscheidungen im Code. Wenn ein Zustand erwartbar ist, etwa “Liste ist leer” oder “Nutzer ist nicht angemeldet”, solltest du ihn nicht als Exception modellieren. Solche Zustände gehören in reguläre Rückgabewerte, UI-State-Klassen oder Result-Typen. Exceptions passen besser zu Situationen, die den normalen Vertrag einer Funktion verletzen oder bei denen eine Operation aus externen Gründen scheitert.
Wie funktioniert es?
Die drei zentralen Schlüsselwörter sind try, catch und finally.
Mit try markierst du einen Codebereich, in dem eine Exception auftreten kann. Mit catch definierst du, wie du mit einer bestimmten Exception umgehst. Mit finally beschreibst du Code, der danach ausgeführt werden soll, unabhängig davon, ob eine Exception geworfen wurde oder nicht. Das ist nützlich für Aufräumarbeiten, zum Beispiel zum Schließen von Ressourcen. In modernem Kotlin nutzt du für Ressourcen aber häufig Funktionen wie use, weil sie dieses Aufräumen strukturiert übernehmen.
Ein einfaches Beispiel:
fun parseAge(input: String): Int? {
return try {
input.toInt()
} catch (exception: NumberFormatException) {
null
}
}
Hier ist der Rückgabewert Int?, weil ein ungültiger Text kein Alter ergibt. Die Exception wird nicht ignoriert, sondern in einen ausdrücklichen Zustand übersetzt: null. Für eine kleine Eingabeprüfung kann das passend sein. In einer größeren App würdest du vielleicht einen klareren Typ verwenden, etwa Result<Int> oder eine eigene Validierungsklasse.
Wichtig ist, dass try in Kotlin ein Ausdruck sein kann. Das bedeutet: try kann einen Wert liefern. Der Wert des erfolgreichen Blocks oder des passenden catch-Blocks wird zurückgegeben. Dadurch lassen sich viele Fälle kompakt schreiben, ohne eine veränderbare Variable vorher anzulegen.
catch sollte so spezifisch wie möglich sein. Wenn du Exception oder sogar Throwable breit abfängst, fängst du oft mehr ein, als du wirklich behandeln kannst. Dadurch können echte Programmierfehler verschwinden. Eine NullPointerException, IllegalArgumentException oder IndexOutOfBoundsException kann ein Hinweis auf einen Bug sein. Wenn du sie pauschal abfängst und nur eine leere Liste zurückgibst, läuft die App scheinbar weiter, aber der Fehler bleibt im System. Später wird er schwerer zu finden.
finally wird ausgeführt, nachdem try und eventuell catch verarbeitet wurden. Es eignet sich für Aufräumcode, nicht für Geschäftslogik. Du solltest in finally keine wichtigen Rückgabewerte überschreiben und dort möglichst keine neue Exception erzeugen, die den ursprünglichen Fehler verdeckt. Wenn im try ein Fehler passiert und im finally ein zweiter Fehler entsteht, kann der eigentliche Grund schwerer sichtbar werden.
In Android-Architekturen tritt Exception-Behandlung oft an Schichtgrenzen auf. Ein Repository kann eine technische Exception aus einer Netzwerkbibliothek erhalten. Das ViewModel sollte daraus aber keinen rohen Stacktrace für die UI machen. Stattdessen übersetzt es den Fehler in einen UI-Zustand, zum Beispiel Loading, Content oder Error. Compose liest dann diesen Zustand und zeigt die passende Oberfläche.
Das heißt nicht, dass du jede Exception im ViewModel fangen musst. Oft ist es besser, Fehler dort zu behandeln, wo du genug Kontext hast. Eine Funktion, die nur JSON parst, weiß vielleicht nicht, ob ein Fehler eine Toast-Nachricht, einen Dialog oder einen Retry auslösen soll. Sie kann den Fehler weitergeben oder in einen fachlichen Fehlertyp übersetzen. Die nächsthöhere Schicht entscheidet dann.
Bei Coroutines gibt es zusätzliche Regeln. Eine Exception in einer Coroutine kann die Coroutine abbrechen und je nach Scope auch andere Coroutines beeinflussen. try und catch funktionieren auch in suspendierenden Funktionen, aber du musst auf CancellationException achten. Diese Exception signalisiert Abbruch, zum Beispiel wenn ein Scope beendet wird. Sie sollte normalerweise nicht geschluckt werden. Wenn du sehr breit fängst, solltest du sie wieder werfen, damit Coroutine-Abbruch korrekt funktioniert.
In der Praxis
Nimm an, du baust eine Profilansicht. Das Repository lädt Daten von einer API. Die UI soll bei Erfolg das Profil zeigen und bei Fehler eine klare Meldung mit Wiederholen-Button anzeigen. Der technische Fehler soll nicht direkt in der Compose-Oberfläche landen.
Ein mögliches Modell im ViewModel sieht so aus:
sealed interface ProfileUiState {
data object Loading : ProfileUiState
data class Content(val name: String, val city: String) : ProfileUiState
data class Error(val message: String) : ProfileUiState
}
class ProfileViewModel(
private val repository: ProfileRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun loadProfile(userId: String) {
viewModelScope.launch {
_uiState.value = ProfileUiState.Loading
try {
val profile = repository.loadProfile(userId)
_uiState.value = ProfileUiState.Content(
name = profile.name,
city = profile.city
)
} catch (exception: CancellationException) {
throw exception
} catch (exception: IOException) {
_uiState.value = ProfileUiState.Error(
message = "Das Profil konnte nicht geladen werden."
)
} catch (exception: HttpException) {
_uiState.value = ProfileUiState.Error(
message = "Der Server hat die Anfrage abgelehnt."
)
}
}
}
}
Dieses Beispiel zeigt mehrere wichtige Regeln. Erstens wird der Fehler nicht still ignoriert. Die UI bekommt einen konkreten Zustand. Zweitens werden unterschiedliche Fehlerarten getrennt behandelt. Ein Netzwerkproblem ist nicht dasselbe wie eine abgelehnte Serverantwort. Drittens wird CancellationException erneut geworfen. Dadurch bleibt das Verhalten der Coroutine korrekt.
In Compose würdest du den State beobachten und darstellen:
@Composable
fun ProfileScreen(
viewModel: ProfileViewModel,
onRetry: () -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
ProfileUiState.Loading -> {
CircularProgressIndicator()
}
is ProfileUiState.Content -> {
Column {
Text(text = state.name)
Text(text = state.city)
}
}
is ProfileUiState.Error -> {
Column {
Text(text = state.message)
Button(onClick = onRetry) {
Text("Erneut versuchen")
}
}
}
}
}
Die Exception wird hier nicht in der Composable gefangen. Das ist Absicht. Composables sollten vor allem UI aus Zustand erzeugen. Fehlerbehandlung gehört meist in ViewModel, Repository oder eine darunterliegende Datenquelle. So bleibt die Oberfläche testbar und der Fehlerfluss nachvollziehbar.
Eine typische Stolperfalle ist ein leerer catch-Block:
try {
repository.sync()
} catch (exception: Exception) {
}
Dieser Code ist gefährlich, weil er den Fehler verschwinden lässt. Die Synchronisierung kann kaputt sein, die Daten können veraltet bleiben, und du bekommst keinen Hinweis. In einem echten Projekt solltest du mindestens entscheiden, was passieren soll: Loggen, UI-State setzen, Retry auslösen, Metrik erfassen oder den Fehler weiterwerfen. Ein leerer catch-Block ist nur in sehr seltenen Fällen akzeptabel, und dann sollte ein Kommentar erklären, warum der Fehler bewusst ignoriert wird.
Eine bessere Variante ist:
try {
repository.sync()
} catch (exception: IOException) {
logger.warn("Sync wegen Netzwerkfehler fehlgeschlagen", exception)
_syncState.value = SyncState.RetryAvailable
}
Hier wird klar, welche Exception erwartet wird und welche Reaktion folgt. Du verschluckst den Fehler nicht, sondern übersetzt ihn in Verhalten.
Eine weitere Stolperfalle ist, Exceptions für normale Eingabelogik zu missbrauchen. Wenn du ein Formular validierst, solltest du nicht für jedes leere Feld eine Exception werfen. Prüfe den Wert direkt und gib ein Validierungsergebnis zurück. Exceptions sind teuerer im Denken, schwerer im Kontrollfluss und oft schlechter lesbar, wenn sie für erwartbare Entscheidungen genutzt werden.
Eine praktische Entscheidungsregel lautet: Fange eine Exception nur dort, wo du etwas Sinnvolles tun kannst. Sinnvoll heißt: Du kannst eine verständliche Meldung anzeigen, einen Fallback wählen, einen Retry anbieten, den Fehler in einen fachlichen Zustand übersetzen oder ihn mit Kontext weiterwerfen. Wenn du nur catch schreibst, damit die App nicht abstürzt, ist das meist kein gutes Design. Ein Crash während der Entwicklung ist oft hilfreicher als ein versteckter Fehler im Release.
Für Tests kannst du Exception-Verhalten sehr konkret prüfen. Wenn eine Funktion bei ungültigem Zustand werfen soll, teste genau diese Exception. Wenn ein Repository bei Netzwerkfehler einen Fehlerzustand liefern soll, simuliere den Fehler mit einem Fake und prüfe den State.
@Test
fun loadProfile_showsError_whenNetworkFails() = runTest {
val repository = FakeProfileRepository(
error = IOException("No connection")
)
val viewModel = ProfileViewModel(repository)
viewModel.loadProfile("42")
advanceUntilIdle()
assertEquals(
ProfileUiState.Error("Das Profil konnte nicht geladen werden."),
viewModel.uiState.value
)
}
Solche Tests zwingen dich, den Fehlerfluss sauber zu modellieren. Sie zeigen auch schnell, ob du Exceptions versehentlich verschluckst oder zu breit behandelst. Im Code-Review kannst du gezielt nach drei Fragen suchen: Wird eine passende Exception gefangen? Gibt es eine sichtbare Reaktion? Bleiben Programmierfehler sichtbar?
finally prüfst du am besten dort, wo Ressourcen im Spiel sind. Wenn du noch mit Streams, Dateien oder ähnlichen Ressourcen arbeitest, sollte klar sein, wer sie schließt. In Kotlin ist oft use die bessere Form:
fun readConfigText(inputStream: InputStream): String {
return inputStream.bufferedReader().use { reader ->
reader.readText()
}
}
Hier brauchst du kein eigenes finally, weil use das Schließen übernimmt. Das ist lesbarer und reduziert Fehler. Trotzdem solltest du finally verstehen, weil du es in Java-Code, älteren Android-Beispielen und manchen Low-Level-APIs weiterhin sehen wirst.
Fazit
Exceptions helfen dir, außergewöhnliche Fehler sichtbar und kontrolliert zu behandeln. Nutze try, catch und finally nicht als pauschales Sicherheitsnetz, sondern als bewusstes Werkzeug an den Stellen, an denen du den Fehler wirklich einordnen kannst. Übe das an einer kleinen Repository- oder ViewModel-Funktion: Simuliere einen Fehler, beobachte ihn im Debugger, schreibe einen Test für den Fehlerzustand und prüfe im Code-Review, ob keine wichtige Exception still verschwindet.