Coroutine Scopes in Android
Coroutine Scopes binden asynchrone Arbeit an einen Besitzer. So verstehst du Lifecycle, Abbruch und klare Verantwortungen.
Coroutine Scopes helfen dir, asynchrone Arbeit in Android nicht nur zu starten, sondern auch sauber zu besitzen, zu beobachten und abzubrechen. Sobald deine App Netzwerkaufrufe, Datenbankzugriffe, Streams oder längere Berechnungen nutzt, brauchst du ein klares Modell dafür, welche Aufgabe zu welchem Teil deiner App gehört.
Was ist das?
Ein Coroutine Scope ist der Rahmen, in dem du Coroutines startest. Er legt fest, zu welchem Besitzer eine Coroutine gehört und welcher Job alle gestarteten Unteraufgaben zusammenhält. Du kannst dir einen Scope wie einen Arbeitsbereich vorstellen: Alles, was du darin startest, gehört zu diesem Bereich. Wird der Bereich geschlossen oder abgebrochen, sollen auch die dazugehörigen Coroutines enden.
Das löst ein sehr praktisches Android-Problem. Eine App-Oberfläche lebt nicht ewig. Eine Activity kann zerstört werden, ein Fragment kann seine View verlieren, ein Compose-Screen kann aus der Composition fallen, und ein ViewModel kann beim Verlassen eines Flows freigegeben werden. Wenn du in so einer Situation weiterhin Arbeit im Hintergrund laufen lässt, riskierst du Speicherlecks, doppelte Requests, unnötigen Akkuverbrauch oder Updates an UI, die gar nicht mehr existiert.
Coroutine Scopes sind deshalb eng mit Lifecycle, Structured Concurrency und Cancellation verbunden. Lifecycle beantwortet die Frage: Wie lange lebt der Besitzer? Structured Concurrency beantwortet: Wie hängen Eltern- und Kindaufgaben zusammen? Cancellation beantwortet: Wie wird Arbeit beendet, wenn sie nicht mehr gebraucht wird?
Im Android-Alltag begegnen dir Scopes an mehreren Stellen. viewModelScope gehört zu einem ViewModel und wird abgebrochen, wenn das ViewModel endgültig gelöscht wird. lifecycleScope gehört zu einem LifecycleOwner, etwa einer Activity oder einem Fragment. In Compose nutzt du häufig LaunchedEffect oder rememberCoroutineScope, wobei du genau beachten musst, ob die Arbeit an einen Composition-Effekt oder an eine Nutzeraktion gebunden ist. In der Daten-Schicht kann ein eigener Scope sinnvoll sein, wenn eine Aufgabe länger leben soll als ein einzelner Screen, zum Beispiel eine kontrollierte Synchronisation.
Wie funktioniert es?
Technisch besteht ein Coroutine Scope aus einem CoroutineContext. Darin liegen unter anderem ein Job und meist auch ein Dispatcher. Der Job ist wichtig für die Besitzstruktur. Er bildet die Eltern-Kind-Beziehung zwischen Coroutines ab. Wenn du in einem Scope mehrere Coroutines startest, werden sie Teil dieser Struktur. Bricht der Eltern-Job ab, werden auch seine Kinder abgebrochen.
Das ist der Kern von Structured Concurrency. Du startest Arbeit nicht irgendwo im luftleeren Raum, sondern innerhalb einer nachvollziehbaren Struktur. Dadurch kann dein Code warten, abbrechen und Fehler besser behandeln. Ein coroutineScope { ... } wartet zum Beispiel, bis alle darin gestarteten Kind-Coroutines beendet sind. Wenn eine Kind-Coroutine fehlschlägt, wird der Scope mit betroffen. Mit supervisorScope { ... } kannst du bestimmte Fehler isolieren, wenn unabhängige Aufgaben nicht alle zusammen scheitern sollen.
Cancellation ist dabei kooperativ. Eine Coroutine wird nicht an jeder Stelle brutal gestoppt. Sie muss an suspendierenden Stellen oder durch Prüfungen wie ensureActive() reagieren können. Viele suspendierende APIs aus Kotlin und Android unterstützen das bereits. Wenn du aber lange CPU-Schleifen schreibst oder blockierende APIs aufrufst, musst du selbst darauf achten, dass Abbruchsignale nicht ignoriert werden.
Der Dispatcher bestimmt, wo die Coroutine ausgeführt wird. Für UI-nahe Arbeit nutzt Android typischerweise den Main-Dispatcher. Für I/O, etwa Netzwerk oder Datenbank, verwendest du Dispatchers.IO oder lässt Repository-APIs intern mit withContext(Dispatchers.IO) wechseln. Scope und Dispatcher sind also nicht dasselbe: Der Scope beschreibt Besitz und Lebensdauer, der Dispatcher beschreibt den Ausführungsort.
Eine wichtige Regel lautet: Der Aufrufer sollte die Lebensdauer bestimmen, nicht die aufgerufene Funktion. Eine suspend-Funktion im Repository sollte normalerweise keinen neuen ungebundenen Scope erstellen. Sie sollte im Scope des Aufrufers laufen. So kann ein ViewModel den Request abbrechen, wenn der Screen verlassen wird. Genau dadurch bleibt dein Code testbarer und besser kontrollierbar.
Für Streams mit Flow ist der Scope ebenfalls zentral. Ein Flow ist oft kalt, also startet er erst beim Sammeln. Wenn du ihn in der UI sammelst, sollte das an den passenden Lifecycle gebunden sein. In Views nutzt du dafür häufig repeatOnLifecycle, damit das Sammeln stoppt, wenn die UI nicht sichtbar ist, und später wieder startet. In Compose übernehmen APIs wie collectAsStateWithLifecycle diese Bindung für typische UI-Zustände.
In der Praxis
Nehmen wir einen typischen Fall: Ein Screen zeigt ein Profil an. Das ViewModel lädt Daten aus einem Repository. Die UI soll nicht wissen, auf welchem Thread die Daten geholt werden, und der Request soll beendet werden, wenn das ViewModel nicht mehr gebraucht wird.
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(profile)
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
_uiState.value = ProfileUiState.Error("Profil konnte nicht geladen werden.")
}
}
}
}
class ProfileRepository(
private val api: ProfileApi
) {
suspend fun loadProfile(userId: String): Profile {
return withContext(Dispatchers.IO) {
api.fetchProfile(userId)
}
}
}
Hier ist viewModelScope der Besitzer der Arbeit. Das ViewModel startet den Request, und wenn das ViewModel beendet wird, wird der Scope abgebrochen. Das Repository erstellt keinen eigenen globalen Scope. Es bietet eine suspendierende Funktion an, die der Aufrufer kontrolliert. Der Wechsel auf Dispatchers.IO bleibt in der Daten-Schicht, weil dort klar ist, dass Netzwerk- oder I/O-Arbeit passiert.
Achte im Beispiel auch auf CancellationException. Du solltest sie nicht wie einen normalen Fehler verschlucken. Wenn du catch (e: Exception) schreibst und danach einen Fehlerzustand setzt, behandelst du einen regulären Abbruch eventuell fälschlich als App-Fehler. Deshalb wird CancellationException erneut geworfen. Diese kleine Zeile zeigt, dass Cancellation zum Design gehört.
In Compose sieht die Entscheidung etwas anders aus. Wenn eine Coroutine genau an das Erscheinen eines Composables gebunden ist, passt LaunchedEffect:
@Composable
fun ProfileRoute(
userId: String,
viewModel: ProfileViewModel
) {
LaunchedEffect(userId) {
viewModel.loadProfile(userId)
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ProfileScreen(uiState = uiState)
}
LaunchedEffect(userId) startet neu, wenn sich userId ändert, und wird abgebrochen, wenn der Effekt die Composition verlässt. Für einmalige Nutzeraktionen, etwa einen Snackbar-Aufruf nach einem Button-Klick, kann rememberCoroutineScope() passen. Für fachliche Arbeit, die den Screen-Zustand lädt oder verändert, ist aber häufig das ViewModel der bessere Besitzer. So bleibt die UI dünn und die Logik testbar.
Eine typische Stolperfalle ist GlobalScope.launch. Damit startest du Arbeit ohne klaren Besitzer. Sie läuft weiter, auch wenn der Screen weg ist. In Lernprojekten wirkt das zuerst bequem, aber in echten Apps ist es schwer zu debuggen. Du verlierst Kontrolle über Abbruch, Fehlerbehandlung und Tests. Wenn du glaubst, GlobalScope zu brauchen, frage dich zuerst: Wem gehört diese Arbeit fachlich? Wenn die Antwort “dem aktuellen Screen” lautet, nimm viewModelScope. Wenn sie “der sichtbaren UI” lautet, nutze einen Lifecycle-gebundenen Scope. Wenn sie “der App-weiten Synchronisation” lautet, entwerfe bewusst einen App-Scope über Dependency Injection und dokumentiere seine Lebensdauer.
Für Offline-first-Apps wird diese Entscheidung besonders wichtig. Eine Synchronisation kann länger laufen als ein einzelner Screen. Trotzdem sollte sie nicht wild global gestartet werden. Sie gehört dann zu einer klaren Komponente, etwa einem Repository, einem Sync-Manager oder WorkManager, je nach Anforderung an Zuverlässigkeit und Systembedingungen. Der Punkt bleibt gleich: Arbeit braucht einen Besitzer.
Beim Testen erkennst du gutes Scope-Design daran, dass du Coroutines kontrollieren kannst. ViewModels lassen sich mit Test-Dispatchern prüfen, wenn sie nicht selbst überall unkontrollierte Scopes erzeugen. In Code-Reviews kannst du gezielt nach launch suchen und fragen: Welcher Scope wird verwendet? Wann wird er abgebrochen? Was passiert bei CancellationException? Werden Dispatcher an der passenden Schicht gesetzt? Diese Fragen sind oft wertvoller als eine lange Liste einzelner API-Regeln.
Eine knappe Entscheidungsregel für deinen Alltag lautet: Starte Coroutines so nah wie möglich am fachlichen Besitzer und so weit weg wie nötig von der UI-Implementierung. Ein Button-Klick darf eine ViewModel-Funktion aufrufen. Das ViewModel startet Arbeit in viewModelScope. Das Repository bietet suspendierende Funktionen oder Flows an und übernimmt I/O-Details. Dadurch bleiben Lebensdauer, Zuständigkeit und Testbarkeit sauber getrennt.
Fazit
Coroutine Scopes sind kein Zusatzdetail, sondern das Ordnungsprinzip für asynchrone Arbeit in modernen Android-Apps. Wenn du bei jeder Coroutine benennen kannst, wem sie gehört, wann sie abgebrochen wird und wie Fehler behandelt werden, hast du den wichtigsten Schritt verstanden. Übe das an einem kleinen Screen: Lade Daten im viewModelScope, sammle UI-Zustand lifecycle-bewusst, setze einen Breakpoint beim Verlassen des Screens und prüfe im Test oder Code-Review, ob keine ungebundene Arbeit übrig bleibt.