Coroutine Cancellation in Android
Du lernst, wie Coroutine Cancellation lange Arbeit beendet. Der Artikel zeigt Android-Bezug, Cleanup und typische Fehler.
Coroutine Cancellation bedeutet, dass eine laufende Coroutine geordnet beendet wird, sobald ihr Ergebnis nicht mehr gebraucht wird. In Android ist das wichtig, weil Nutzer Screens verlassen, Geräte gedreht werden, Suchanfragen ersetzt werden oder ein ViewModel gelöscht wird. Wenn deine App trotzdem weiter lädt, rechnet oder Daten verarbeitet, verschwendet sie Akku, Netzwerk und Speicher. Noch schlimmer: Veraltete Ergebnisse können später in den UI-State gelangen und einen Zustand anzeigen, der nicht mehr zur aktuellen Oberfläche passt.
Was ist das?
Eine Coroutine ist kein Thread, den du einfach von außen hart abschaltest. Cancellation ist in Kotlin kooperativ. Das heißt: Die Coroutine bekommt ein Abbruchsignal, und der laufende Code muss an bestimmten Stellen darauf reagieren. Viele suspendierende Funktionen aus der Kotlin- und Android-Welt tun das bereits. Dazu gehören zum Beispiel delay, viele Flow-Operatoren und suspendierende APIs, die korrekt in Coroutines integriert sind. Wenn eine solche Funktion merkt, dass ihr Job abgebrochen wurde, wirft sie intern eine CancellationException. Diese Exception ist kein Fehler im normalen Sinn, sondern das Signal: Diese Arbeit soll enden.
Das passende mentale Modell ist eine Aufgabenliste mit einem roten Stoppzettel. Der Stoppzettel unterbricht dich nicht mitten in einer einzelnen CPU-Anweisung. Du siehst ihn, sobald du an einer Stelle vorbeikommst, an der du ohnehin prüfen kannst, ob die Aufgabe noch sinnvoll ist. In Coroutine-Code sind solche Stellen Suspension Points oder explizite Prüfungen wie ensureActive() und isActive.
Für Android-Lernende ist das ein Grundbaustein moderner App-Architektur. In Jetpack Compose kann ein LaunchedEffect abgebrochen werden, wenn der zugehörige Composable den Composition-Bereich verlässt oder sich der Key ändert. In einem ViewModel werden Coroutines in viewModelScope automatisch abgebrochen, wenn das ViewModel freigegeben wird. In einer klassischen Lifecycle-Komponente kann repeatOnLifecycle Arbeit starten und stoppen, passend zum sichtbaren Zustand der Oberfläche. All diese Mechanismen sind nur zuverlässig, wenn dein eigener Code Cancellation respektiert.
Der zentrale Punkt ist also nicht nur: „Wie starte ich asynchrone Arbeit?“ Sondern: „Wie sorge ich dafür, dass diese Arbeit rechtzeitig endet?“ Genau das unterscheidet eine kleine Demo von App-Code, der auch unter echten Nutzungsbedingungen stabil bleibt.
Wie funktioniert es?
Jede Coroutine läuft in einem CoroutineContext, und darin steckt normalerweise ein Job. Dieser Job beschreibt den Lebenszustand der Coroutine: aktiv, abgeschlossen oder abgebrochen. Wenn du job.cancel() aufrufst oder ein übergeordneter Scope abgebrochen wird, bekommen alle zugehörigen Kind-Coroutines das Abbruchsignal. Dieses strukturierte Verhalten heißt structured concurrency. Es sorgt dafür, dass gestartete Arbeit nicht unkontrolliert weiterlebt, nachdem der aufrufende Bereich schon weg ist.
In Android nutzt du dieses Prinzip meistens indirekt. Du startest Arbeit in einem Scope, der an eine Lebensdauer gebunden ist. viewModelScope gehört zum ViewModel. lifecycleScope gehört zu einem LifecycleOwner wie Activity oder Fragment. Compose-Effekte haben eine eigene Lebensdauer in der Composition. Wenn diese Lebensdauer endet, wird der Scope abgebrochen. Dadurch musst du nicht jede einzelne Coroutine manuell verwalten. Du musst aber darauf achten, dass dein Code auf das Signal reagiert.
Suspendierende Funktionen sind dabei die einfachste Variante. Wenn eine Coroutine während delay(1000) abgebrochen wird, endet sie zeitnah. Wenn sie gerade auf eine korrekt angebundene Netzwerk- oder Datenbankoperation wartet, kann auch diese Operation abbrechen oder zumindest das Ergebnis ignoriert werden, je nach API. Problematisch wird es bei langen CPU-Schleifen, bei blockierendem Code und bei selbst geschriebenen Wrappern um Callback-APIs. Eine Schleife, die Millionen Elemente verarbeitet und nie suspendiert, merkt nicht automatisch, dass der Job abgebrochen wurde.
Dafür gibt es explizite Werkzeuge. Mit isActive kannst du in einer Schleife prüfen, ob die Coroutine noch laufen soll. Mit ensureActive() kannst du eine Prüfung einbauen, die bei Abbruch eine CancellationException auslöst. Mit yield() gibst du anderen Coroutines Gelegenheit zu laufen und schaffst ebenfalls einen Punkt, an dem Cancellation erkannt werden kann. Für Lernende reicht als erste Regel: Wenn dein Code länger arbeitet, ohne eine suspendierende Funktion aufzurufen, musst du selbst Cancellation-Punkte einbauen.
Cleanup ist der zweite wichtige Begriff. Wenn eine Coroutine abgebrochen wird, darf sie nicht einfach halbfertige Ressourcen liegen lassen. Offene Streams, temporäre Dateien, Locks, Messungen, Fortschrittsanzeigen oder eigene Registrierungen müssen freigegeben werden. Dafür nutzt du in Kotlin wie sonst auch try und finally. Der finally-Block läuft auch bei Cancellation. Wenn du darin selbst noch suspendierende Arbeit ausführen musst, brauchst du in Sonderfällen withContext(NonCancellable). Das sollte sparsam bleiben, weil du damit den Abbruch absichtlich verzögerst. Typisch ist ein sehr kurzer, notwendiger Abschluss, zum Beispiel ein lokales Freigeben oder ein finaler Log-Eintrag. Lange Netzwerk-Uploads gehören nicht leichtfertig in einen nicht abbrechbaren Cleanup-Bereich.
Eine häufige Stolperfalle ist breites Exception-Handling. Wenn du catch (e: Exception) schreibst und danach einfach weitermachst, fängst du auch CancellationException, denn sie ist eine Exception. Damit kann dein Code Cancellation verschlucken. Die Coroutine wirkt dann abgebrochen, arbeitet aber in Teilen weiter oder meldet einen falschen Fehlerzustand. In Repository- oder Use-Case-Code solltest du CancellationException entweder gar nicht fangen oder sofort wieder werfen. Fehler wie HTTP-Probleme, Parsing-Fehler oder Datenbankfehler behandelst du separat.
In der Praxis
Stell dir eine Suche in einer Android-App vor. Der Nutzer tippt „kot“, dann „kotl“, dann „kotlin“. Jede Eingabe kann eine neue Anfrage auslösen. Die alte Anfrage ist nicht mehr relevant. Wenn du sie weiterlaufen lässt, kann sie später eintreffen und die Ergebnisse für die neuere Eingabe überschreiben. Cancellation verhindert genau dieses Rennen zwischen alten und neuen Arbeiten.
In einem ViewModel sieht ein einfaches Muster so aus:
class SearchViewModel(
private val repository: SearchRepository
) : ViewModel() {
private var searchJob: Job? = null
private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
fun search(query: String) {
searchJob?.cancel()
if (query.isBlank()) {
_uiState.value = SearchUiState()
return
}
searchJob = viewModelScope.launch {
_uiState.value = SearchUiState(loading = true)
try {
val results = repository.search(query)
_uiState.value = SearchUiState(results = results)
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
_uiState.value = SearchUiState(error = "Netzwerkfehler")
} finally {
// Cleanup: lokale Marker, Tracing oder Ressourcen beenden.
}
}
}
override fun onCleared() {
super.onCleared()
// viewModelScope wird automatisch abgebrochen.
}
}
Dieses Beispiel ist bewusst klein. Entscheidend ist nicht die konkrete UI, sondern die Lebensdauer. Vor jeder neuen Suche wird der alte searchJob abgebrochen. Wenn der Nutzer den Screen verlässt, beendet viewModelScope die laufende Arbeit. Die CancellationException wird nicht in einen normalen Fehlerzustand übersetzt. Ein abgebrochener Request ist kein Netzwerkfehler und sollte dem Nutzer nicht als roter Fehler angezeigt werden.
In Compose würdest du oft noch stärker mit deklarativen Lebensdauern arbeiten. Ein LaunchedEffect(query) wird neu gestartet, wenn sich query ändert. Der vorherige Effekt wird abgebrochen. Das passt sehr gut zu Suchfeldern, Autocomplete, Detailansichten und Screen-spezifischen Ladevorgängen. Trotzdem bleibt dieselbe Regel bestehen: Die aufgerufenen suspendierenden Funktionen müssen Cancellation respektieren. Wenn dein Repository intern blockierend mit Thread.sleep, einer schweren Schleife oder einer schlecht angebundenen Callback-API arbeitet, hilft auch der schönste Scope nur begrenzt.
Für CPU-lastige Arbeit kannst du Cancellation explizit prüfen:
suspend fun filterLargeList(items: List<Item>, query: String): List<Item> {
val result = mutableListOf<Item>()
for (item in items) {
coroutineContext.ensureActive()
if (item.title.contains(query, ignoreCase = true)) {
result += item
}
}
return result
}
Diese Funktion ruft in der Schleife ensureActive() auf. Wenn der Nutzer währenddessen den Screen verlässt oder eine neue Suche startet, endet die Verarbeitung sauber. Ohne diese Prüfung würde die Schleife bis zum Ende laufen, selbst wenn niemand das Ergebnis mehr braucht. Bei kleinen Listen ist das egal. Bei großen lokalen Datenmengen, Bildverarbeitung, komplexem Mapping oder teuren Sortierungen kann es spürbar werden.
Eine praktische Entscheidungsregel lautet: Jede länger laufende Operation braucht eine klare Lebensdauer und einen klaren Abbruchpunkt. Die Lebensdauer kommt aus dem passenden Scope. Der Abbruchpunkt kommt entweder durch suspendierende APIs oder durch deine eigene Prüfung. Fehlt eines von beiden, solltest du im Code-Review nachfragen.
Cleanup prüfst du ähnlich konkret. Wenn du eine Ressource öffnest, muss im selben gedanklichen Bereich klar sein, wo sie geschlossen wird. Bei Kotlin hilft oft use { }, weil es Ressourcen automatisch schließt. Bei eigenen Registrierungen, Listenern oder Messungen brauchst du meist try/finally. Wichtig ist: Cleanup darf nicht vom Happy Path abhängen. Er muss auch laufen, wenn die Coroutine abgebrochen wird.
Typische Fehler sind leicht zu erkennen. Erstens: GlobalScope.launch für Arbeit, die eigentlich zu einem Screen gehört. Dadurch verliert die Arbeit ihre natürliche Lebensdauer. Zweitens: catch (Exception) ohne erneutes Werfen von CancellationException. Dadurch wird Abbruch wie ein normaler Fehler behandelt. Drittens: blockierende APIs in Coroutines, die keine Abbruchmöglichkeit haben. Viertens: UI-State wird nach einer alten Anfrage gesetzt, obwohl schon eine neuere Anfrage läuft. Diese Fehler zeigen sich oft nicht in der ersten manuellen Prüfung, sondern erst bei schnellen Navigationen, schlechter Verbindung oder vielen Eingaben hintereinander.
Du kannst dein Verständnis gezielt testen. Baue eine Suche oder Detailansicht, starte eine künstlich verzögerte Repository-Funktion mit delay(2000), verlasse sofort den Screen und logge im Repository sowie im ViewModel. Du solltest sehen, dass die alte Arbeit abgebrochen wird und kein alter UI-State mehr gesetzt wird. Danach ersetze delay testweise durch eine Schleife ohne Cancellation-Prüfung. Der Unterschied macht das kooperative Modell sichtbar. In Unit-Tests kannst du mit Coroutine-Testwerkzeugen prüfen, ob nach einem Abbruch kein Erfolgszustand mehr emittiert wird. Im Code-Review kannst du nach drei Fragen suchen: Welcher Scope startet die Arbeit? Wo wird Cancellation erkannt? Was passiert mit Cleanup?
Fazit
Coroutine Cancellation ist ein Pflichtkonzept, sobald deine Android-App mehr tut als kurze UI-Aktionen. Du lernst damit, Arbeit an echte Lebensdauern zu binden: Screen sichtbar, ViewModel aktiv, Suche noch aktuell. Merke dir vor allem das kooperative Modell. Cancellation ist ein Signal, kein harter Stopp. Dein Code muss an suspendierenden Stellen oder durch explizite Prüfungen reagieren, und Ressourcen gehören in verlässlichen Cleanup. Prüfe das aktiv in kleinen Übungen: navigiere während eines laufenden Ladevorgangs zurück, starte schnelle Folgeanfragen, setze Breakpoints in finally und achte im Test darauf, dass abgebrochene Arbeit keinen veralteten UI-State mehr schreibt.