Cancellation Cooperation in Kotlin-Coroutines
Lerne, wie du Schleifen und blockierende Adapter in Android-Coroutines sauber abbrechbar machst.
Cancellation Cooperation heißt, dass dein Coroutine-Code beim Abbruch mitarbeitet. Das ist besonders wichtig, wenn du lange Schleifen schreibst oder alte, blockierende APIs in Coroutines einbindest. Eine Coroutine wird nicht überall magisch gestoppt. Sie muss an passenden Stellen prüfen, ob sie noch aktiv ist, und dann sauber beenden.
Was ist das?
Cancellation Cooperation beschreibt das Verhalten von Coroutine-Code, der Abbruchsignale beachtet. Wenn ein ViewModel gelöscht wird, ein Compose-Screen die Composition verlässt oder ein Flow nicht mehr gesammelt wird, sollen laufende Aufgaben enden. Das spart Akku, Speicher, Netzwerkverkehr und verhindert, dass alte Ergebnisse eine neue UI überschreiben.
Das mentale Modell ist: Cancellation ist kein harter Thread-Kill. Kotlin-Coroutines arbeiten kooperativ. Viele suspendierende Funktionen wie delay, withContext oder viele Flow-Operatoren prüfen intern, ob die Coroutine noch aktiv ist. Dein eigener Code muss das aber ebenfalls tun, wenn er längere CPU-Schleifen ausführt oder blockierende APIs anbindet.
Die wichtigsten Werkzeuge dafür sind isActive, ensureActive und geordnetes cleanup. Mit isActive kannst du eine Schleife nur weiterlaufen lassen, solange der Coroutine-Kontext aktiv ist. Mit ensureActive() setzt du einen klaren Abbruchpunkt: Wenn die Coroutine abgebrochen wurde, wird eine CancellationException geworfen. Cleanup meint Aufräumarbeiten wie Listener entfernen, Streams schließen oder temporäre Zustände zurücksetzen.
Im Android-Alltag begegnet dir das in Repository-Funktionen, Use Cases, Sync-Jobs, Paging-Quellen, Flow-Produzenten und Adaptern um ältere SDKs. Wenn du dort Cancellation ignorierst, laufen Aufgaben weiter, obwohl die UI sie nicht mehr braucht.
Wie funktioniert es?
Eine Coroutine hat einen Job. Dieser Job kann aktiv, abgeschlossen oder abgebrochen sein. Wenn du viewModelScope.launch { ... } nutzt, hängt der Job am ViewModel. Wird das ViewModel entfernt, wird der Scope abgebrochen. Ähnlich gilt das für lifecycleScope, LaunchedEffect in Compose und Flow-Collections, die an einen Lifecycle gebunden sind.
Suspendierende Funktionen reagieren meist bereits auf Cancellation. Problematisch wird es bei Code, der lange ohne Suspension arbeitet. Beispiel: Du iterierst über 100.000 Datensätze, berechnest Hashes, filterst Medien oder wandelst ein großes lokales Modell um. Wenn diese Schleife keinen Abbruchpunkt enthält, läuft sie weiter, obwohl ihr Ergebnis nicht mehr gebraucht wird.
isActive kommt aus dem CoroutineScope beziehungsweise CoroutineContext. Du verwendest es typischerweise in Schleifenbedingungen:
while (isActive) {
// Arbeit in kleinen Portionen
}
Das beendet die Schleife still, sobald der Job abgebrochen ist. Das ist gut, wenn ein normales Schleifenende reicht.
ensureActive() ist strenger. Du rufst es innerhalb einer Schleife oder vor einem teuren Schritt auf. Wenn der Job nicht mehr aktiv ist, wirft es CancellationException. Das ist sinnvoll, wenn der aktuelle Pfad sofort verlassen werden soll und umgebender Code den Abbruch korrekt weiterreichen soll.
Cleanup gehört in finally. Wichtig ist: Eine CancellationException ist kein Fehler, den du aus Versehen verschlucken solltest. Wenn du breit catch (Exception) verwendest und danach normal weitermachst, machst du Cancellation kaputt. Fange nur, was du wirklich behandeln kannst, oder wirf CancellationException wieder.
Bei blockierenden Adaptern ist besondere Vorsicht nötig. Wenn du eine alte Callback-API, einen blockierenden Stream oder einen SDK-Aufruf einbindest, muss dein Adapter wissen, wie er beim Abbruch aufräumt. Bei Callback-APIs bedeutet das meist: Listener abmelden. Bei blockierenden Ressourcen bedeutet es: Socket, Stream oder Request schließen, sofern die API das unterstützt. Ohne diese Verbindung zwischen Coroutine-Abbruch und alter API läuft die Arbeit außerhalb deiner Coroutine weiter.
In der Praxis
Angenommen, du willst in einem Repository lokale Einträge in Batches verarbeiten. Die UI startet die Arbeit aus einem ViewModel. Wenn der Nutzer den Screen verlässt, soll die Verarbeitung stoppen. Eine kooperative Implementierung sieht so aus:
class SearchRepository(
private val dao: ItemDao
) {
suspend fun buildIndex(items: List<Item>): Index = withContext(Dispatchers.Default) {
val builder = Index.Builder()
try {
for (item in items) {
ensureActive()
val tokens = tokenize(item.title, item.description)
builder.add(item.id, tokens)
if (builder.pendingCount >= 500) {
dao.insertIndexEntries(builder.drainPending())
ensureActive()
}
}
builder.build()
} finally {
builder.close()
}
}
}
Hier passieren mehrere Dinge bewusst. withContext(Dispatchers.Default) verschiebt CPU-Arbeit vom Main Thread. ensureActive() steht vor dem teuren Schritt und nach dem Datenbank-Batch. Dadurch reagiert die Funktion auch dann zügig, wenn der Screen geschlossen wird. Der finally-Block räumt den Builder auf, egal ob die Verarbeitung erfolgreich endet oder abgebrochen wird.
Wenn du statt ensureActive() nur am Anfang prüfst, ist der Code zu träge. Bei großen Listen kann die App noch lange rechnen. Wenn du gar nicht prüfst, kann altes Ergebnis später in deinem State landen, besonders wenn mehrere Such- oder Sync-Vorgänge schnell nacheinander gestartet werden.
Ein zweites Beispiel ist ein blockierender Adapter. Stell dir vor, ein altes SDK liefert Daten über einen Listener. Dann sollte der Abbruch den Listener entfernen:
fun LegacyClient.eventsAsFlow(): Flow<LegacyEvent> = callbackFlow {
val listener = object : LegacyListener {
override fun onEvent(event: LegacyEvent) {
trySend(event)
}
override fun onError(error: Throwable) {
close(error)
}
}
registerListener(listener)
awaitClose {
unregisterListener(listener)
}
}
Bei callbackFlow ist awaitClose der zentrale Cleanup-Punkt. Wird der Flow nicht mehr gesammelt, wird der Listener entfernt. Ohne awaitClose würdest du im Hintergrund weiter Events empfangen. Das kann Speicherlecks erzeugen und UI-Zustände aktualisieren, die es nicht mehr gibt.
Eine typische Stolperfalle ist dieser Code:
suspend fun syncAll() {
try {
repository.sync()
} catch (e: Exception) {
logger.log(e)
}
}
Das sieht harmlos aus, kann aber Cancellation beschädigen, weil CancellationException eine Exception ist. Besser ist:
suspend fun syncAll() {
try {
repository.sync()
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
logger.log(e)
}
}
Die Entscheidungsregel ist: Lange eigene Schleifen bekommen Abbruchpunkte. Adapter um alte APIs bekommen Cleanup. Breite Catch-Blöcke werfen CancellationException weiter. Wenn du diese drei Punkte in Code-Reviews prüfst, findest du viele reale Fehler früh.
Zum Testen kannst du eine Coroutine starten, sie abbrechen und prüfen, dass Cleanup ausgeführt wurde. Für Flow-Adapter prüfst du, ob unregisterListener beim Beenden der Collection aufgerufen wird. Im Debugger kannst du zusätzlich beobachten, ob eine Schleife nach job.cancel() wirklich stoppt. Gute Tests sind hier oft klein: Sie brauchen keine komplette App, sondern nur den Scope, den Job und eine Ressource, deren Freigabe du zählen kannst.
Fazit
Cancellation Cooperation ist ein Qualitätsmerkmal von sauberem Coroutine-Code. Du lernst dabei, Android-Arbeit an den Lebenszyklus zu koppeln, statt Aufgaben unkontrolliert weiterlaufen zu lassen. Prüfe in deinem nächsten Repository, Use Case oder Flow-Produzenten gezielt drei Fragen: Gibt es lange Schleifen ohne isActive oder ensureActive? Gibt es blockierende oder callbackbasierte Adapter ohne Cleanup? Gibt es catch (Exception), das Cancellation verschlucken könnte? Wenn du diese Fragen mit Tests, Debugger oder Code-Review überprüfst, machst du deine Coroutines robuster und besser wartbar.