ViewModel Scope – Koroutinen konfigurationsstabil verwalten
viewModelScope bindet Koroutinen an den Lebenszyklus des ViewModels. Wenn das ViewModel gelöscht wird, werden alle laufenden Jobs automatisch abgebrochen.
Jede Android-App führt früher oder später asynchrone Arbeit aus: Netzwerkaufrufe, Datenbankabfragen, rechenintensive Transformationen. Kotlin-Koroutinen lösen dieses Problem elegant – aber nur dann, wenn sie im richtigen Scope laufen. viewModelScope ist genau dieser Scope für die ViewModel-Schicht: Er bindet asynchrone Jobs an den Lebenszyklus des ViewModels und spart dir das manuelle Aufräumen vollständig.
Was ist das?
viewModelScope ist eine Extension Property auf ViewModel, die von der Jetpack-Bibliothek androidx.lifecycle bereitgestellt wird. Hinter ihr verbirgt sich ein CoroutineScope, der automatisch beim Erstellen des ViewModels verfügbar ist und beim Aufrufen von onCleared() abgebrochen wird.
Der entscheidende Vertrag lautet: Das ViewModel überlebt Konfigurationsänderungen wie eine Bildschirmrotation. Weil viewModelScope daran gebunden ist, überleben deine Koroutinen diese Unterbrechung ebenfalls – kein Job wird durch einen Rotation-Neustart abgebrochen. Erst wenn der Nutzer den Screen endgültig verlässt und das System das ViewModel verwirft, werden alle laufenden Jobs sauber gestoppt.
Dieses Prinzip ist ein konkretes Beispiel für strukturierte Nebenläufigkeit (Structured Concurrency): Jede Koroutine hat einen klar definierten Eltern-Scope. Verliert der Eltern-Scope seine Daseinsberechtigung, werden alle Kinder automatisch mitbeendet. Kein Job läuft unbemerkt im Hintergrund weiter.
Wie funktioniert es?
Intern enthält viewModelScope einen SupervisorJob und verwendet Dispatchers.Main.immediate als Standard-Dispatcher. Der SupervisorJob ist dabei besonders wichtig: Er stellt sicher, dass das Fehlschlagen eines einzelnen Kind-Jobs die anderen nicht abbricht. Wenn also eine parallele Netzwerkanfrage mit einer Exception endet, laufen die übrigen Jobs ungestört weiter.
class SearchViewModel(private val repository: SearchRepository) : ViewModel() {
private val _results = MutableStateFlow<List<Result>>(emptyList())
val results: StateFlow<List<Result>> = _results.asStateFlow()
fun search(query: String) {
viewModelScope.launch {
val data = repository.search(query) // suspending function
_results.value = data
}
}
}
Hier läuft die Koroutine standardmäßig auf dem Main-Thread. repository.search() ist eine suspending function, die intern auf Dispatchers.IO wechselt – so bleibt der Main-Thread frei. Das Ergebnis landet in einem StateFlow, den die Compose-UI via collectAsStateWithLifecycle() beobachtet. Dreht der Nutzer das Gerät, bleibt der laufende search-Job am Leben. Wechselt der Nutzer dauerhaft zu einem anderen Screen, bricht onCleared() ihn ab.
In der Praxis
Vollständiges UiState-Muster
Ein robustes Muster kombiniert viewModelScope mit einem StateFlow und einem sealed Interface für Ladezustände:
sealed interface SearchUiState {
data object Idle : SearchUiState
data object Loading : SearchUiState
data class Success(val items: List<Result>) : SearchUiState
data class Error(val message: String) : SearchUiState
}
class SearchViewModel(private val repository: SearchRepository) : ViewModel() {
private val _uiState = MutableStateFlow<SearchUiState>(SearchUiState.Idle)
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
fun search(query: String) {
viewModelScope.launch {
_uiState.value = SearchUiState.Loading
try {
val items = repository.search(query)
_uiState.value = SearchUiState.Success(items)
} catch (e: CancellationException) {
throw e // CancellationException nie schlucken!
} catch (e: Exception) {
_uiState.value = SearchUiState.Error(e.localizedMessage ?: "Unbekannter Fehler")
}
}
}
}
Beachte den catch-Block für CancellationException: Diese Exception signalisiert dem Koroutinen-Framework, dass der Job abgebrochen werden soll. Schluckst du sie in einem allgemeinen catch (e: Exception)-Block, verhindert das den sauberen Abbruch durch viewModelScope. Wirf sie deshalb immer neu.
Stolperfalle: GlobalScope
Die häufigste Anfängerfalle ist GlobalScope.launch { }. Der Unterschied ist gravierend: GlobalScope ist an keine Komponente gebunden. Jobs laufen nach dem Verlassen des Screens weiter, verbrauchen Ressourcen, und du verlierst jede Kontrolle darüber, wann sie enden. Unit-Tests werden damit deutlich schwieriger, weil du den Scope nicht ersetzen kannst.
Wann viewModelScope nicht reicht
viewModelScope deckt alle Operationen ab, die an den Screen gebunden sind. Wenn du eine Aufgabe brauchst, die das Verlassen des Screens überlebt – beispielsweise das abschließende Speichern eines Formulars, das der Nutzer noch während des Navigierens absendet –, ist viewModelScope die falsche Wahl. Google empfiehlt für solche Fälle WorkManager. Alternativ kannst du einen Application-weiten CoroutineScope verwalten, der explizit an den App-Lebenszyklus gebunden ist.
Fazit
viewModelScope löst ein konkretes Problem: Er gibt dir einen klar definierten Ort für asynchrone Arbeit, ohne dass du selbst Ressourcen freigeben musst. Konfigurationsänderungen unterbrechen deine Jobs nicht, und beim endgültigen Verlassen des Screens werden alle Koroutinen automatisch gestoppt. Um dein Verständnis zu festigen, öffne ein bestehendes Projekt und suche alle Stellen, an denen Koroutinen gestartet werden. Prüfe, ob der verwendete Scope dem gewünschten Lebenszyklus entspricht, und schreibe anschließend einen Unit-Test mit UnconfinedTestDispatcher – das macht Lifecycle-Bugs in Koroutinen-Code sofort sichtbar.