Einführung in Coroutines
Coroutines helfen dir, asynchrone Arbeit in Android klar zu strukturieren. Du lernst das Grundmodell von suspend, async und Concurrency.
Coroutines sind eine der wichtigsten Grundlagen, wenn du mit Kotlin moderne Android-Apps baust. Sie helfen dir, Netzwerkzugriffe, Datenbankoperationen und andere längere Arbeiten so zu schreiben, dass deine App reaktionsfähig bleibt und dein Code trotzdem gut lesbar ist. Für deinen Lernweg ist das Thema ein Übergang: Du verlässt reine Sprachsyntax und kommst zu echter App-Logik, bei der mehrere Dinge gleichzeitig passieren können.
Was ist das?
Eine Coroutine ist eine leichtgewichtige Ausführungseinheit in Kotlin. Du kannst sie dir als Aufgabe vorstellen, die laufen, pausieren und später fortgesetzt werden kann. Der wichtige Punkt: Wenn eine Coroutine pausiert, muss sie dabei keinen Android-Thread festhalten. Genau das macht sie für Android so nützlich, denn der Main Thread ist knapp und wertvoll. Er soll Eingaben, Animationen, Compose-Recomposition und UI-Updates bearbeiten, nicht auf Netzwerkantworten oder Datenbankabfragen warten.
Das Problem dahinter kennst du aus fast jeder App: Du öffnest einen Screen, lädst Daten aus einer API, speicherst etwas lokal, liest es wieder aus und zeigst es in der UI an. Früher wurden solche Abläufe oft mit Callbacks, Listenern oder manuell verwalteten Threads gelöst. Das funktioniert, wird aber schnell unübersichtlich. Fehlerbehandlung, Abbruch, Reihenfolge und Lebenszyklus werden dann schwer zu kontrollieren.
Coroutines lösen dieses Problem auf Sprachebene. Kotlin erlaubt dir, asynchronen Code in einer Form zu schreiben, die fast wie normaler, sequenzieller Code aussieht. Das Schlüsselwort suspend ist dabei zentral. Eine suspend-Funktion darf ihre Ausführung unterbrechen und später wieder aufnehmen. Sie blockiert dabei nicht automatisch den Thread. Das ist ein anderer Gedanke als: „Ich starte einen Thread und warte.“ Besser ist: „Ich beschreibe eine Aufgabe, die an bestimmten Stellen pausieren darf.“
Im Android-Kontext findest du Coroutines vor allem in ViewModels, Repositories, Use Cases, Datenquellen und Tests. Jetpack-Bibliotheken sind stark auf Kotlin und Coroutines ausgerichtet. Room kann suspend-Funktionen verwenden, DataStore arbeitet coroutine-basiert, viele APIs liefern Flow, und ein ViewModel bringt mit viewModelScope einen passenden Startpunkt für Coroutine-Arbeit mit. Auch in Compose ist das relevant: UI-Code sollte nicht selbst blockierende Arbeit erledigen, sondern Zustände beobachten, die aus sauber verwalteter asynchroner Logik kommen.
Für dich als Lernende oder Lernender ist das mentale Modell wichtiger als die komplette API-Liste. Eine Coroutine ist keine magische Beschleunigung. Sie macht Arbeit nicht automatisch schneller. Sie hilft dir, wartende Arbeit besser zu strukturieren. Wenn du mehrere unabhängige Aufgaben parallel startest, kann sie auch Zeit sparen. Wenn deine Aufgabe aber rechenintensiv ist, braucht sie weiterhin CPU-Zeit. Coroutines sind also ein Strukturmodell für asynchrone und nebenläufige Arbeit, kein Ersatz für gutes Architekturdenken.
Wie funktioniert es?
Coroutines laufen in einem Kontext. Dieser Kontext enthält unter anderem einen CoroutineDispatcher, der entscheidet, wo die Arbeit ausgeführt wird. Für Android ist die Unterscheidung wichtig: UI-Arbeit gehört auf den Main Dispatcher, blockierende Datei- oder Datenbankarbeit meist auf IO, rechenintensive Arbeit eher auf Default. In sauberem App-Code solltest du diese Entscheidung bewusst treffen, besonders in der Data Layer. Dort werden Netzwerk, Cache und lokale Datenquellen koordiniert.
Eine Coroutine startest du nicht frei im Raum. Sie braucht einen Scope. Ein Scope beschreibt, wie lange die gestartete Arbeit leben darf. In Android ist das eng mit dem Lebenszyklus verbunden. Ein viewModelScope lebt so lange wie das ViewModel. Wird das ViewModel gelöscht, werden seine Coroutines abgebrochen. Das schützt dich vor typischen Fehlern: laufende Netzwerkaufrufe nach verlassenem Screen, Speicherlecks oder UI-Updates an einem nicht mehr existierenden Zustand.
suspend ist dabei kein Startsignal. Eine suspend-Funktion startet nicht von selbst im Hintergrund. Sie sagt nur: Diese Funktion darf nur aus einer Coroutine oder einer anderen suspend-Funktion aufgerufen werden und kann pausieren. Der Start passiert zum Beispiel über launch oder async. launch verwendest du, wenn du eine Aufgabe startest, die kein direktes Ergebnis zurückgeben soll, etwa das Laden eines Screens und das Aktualisieren eines UI-State. async verwendest du, wenn du ein Ergebnis erwartest und dieses später mit await() abholst. Wichtig ist: async ist vor allem dann sinnvoll, wenn Aufgaben wirklich unabhängig parallel laufen können.
Concurrency bedeutet, dass mehrere Aufgaben in überlappender Zeit verarbeitet werden können. Das ist nicht identisch mit Parallelität. Zwei Coroutines können nebenläufig sein, auch wenn sie auf einem einzelnen Thread kooperativ pausieren und fortgesetzt werden. Echte Parallelität entsteht erst, wenn Arbeit gleichzeitig auf mehreren Threads ausgeführt wird. Für Android reicht oft schon saubere Nebenläufigkeit, weil viele Aufgaben nicht dauerhaft rechnen, sondern auf I/O warten: Netzwerk, Datenbank, Dateisystem oder Serverantworten.
Ein Kernprinzip ist strukturierte Nebenläufigkeit. Das bedeutet: Gestartete Coroutines sollen an einen Scope gebunden sein, und Kind-Coroutines gehören logisch zu ihrer Eltern-Coroutine. Wenn ein Elternteil abgebrochen wird, sollen auch die Kinder abgebrochen werden. Wenn ein Fehler passiert, soll er nicht unbemerkt irgendwo im Hintergrund verschwinden. Dieses Prinzip ist einer der Gründe, warum GlobalScope in App-Code fast immer ein Warnsignal ist. Es entkoppelt Arbeit vom Lebenszyklus und macht Verhalten schwer prüfbar.
Fehlerbehandlung gehört ebenfalls zum Grundmodell. Eine Exception in einer Coroutine ist kein Nebendetail. Bei launch kann eine nicht behandelte Exception den Scope betreffen. Bei async wird die Exception normalerweise beim await() sichtbar. Wenn du async startest und nie await() aufrufst, baust du schnell schwer erkennbare Fehler ein. Deshalb solltest du async nicht als bequeme Hintergrundfunktion missbrauchen. Wenn du kein Ergebnis brauchst, nimm launch. Wenn du ein Ergebnis brauchst, hole es bewusst ab und behandle Fehler an einer Stelle, die fachlich Sinn ergibt.
In einer typischen Android-Architektur liegt die Verantwortung verteilt. Das ViewModel startet Arbeit im passenden Scope und hält UI-State. Das Repository bietet suspend-Funktionen oder Streams an und entscheidet, ob Daten aus Netzwerk, Datenbank oder Cache kommen. Die Datenquelle spricht mit Retrofit, Room oder einer anderen API. Diese Trennung hilft beim Testen. Du kannst Repository-Funktionen mit Test-Dispatchern prüfen und ViewModel-Logik ohne echten Server kontrollieren.
Offline-first-Architekturen zeigen besonders gut, warum Coroutines wichtig sind. Eine App liest vielleicht zuerst lokale Daten, synchronisiert dann im Hintergrund mit dem Netzwerk und aktualisiert danach die Datenbank. Dabei willst du nicht die UI blockieren. Du willst außerdem Abbruch, Wiederholung und Fehlerfälle kontrollieren. Coroutines geben dir das Sprachfundament dafür. Spätere Themen wie Flow, WorkManager oder Retry-Strategien bauen oft auf demselben Denken auf: asynchrone Arbeit ist normal, aber sie muss begrenzt, beobachtbar und testbar bleiben.
In der Praxis
Ein häufiger Einstieg ist ein ViewModel, das Daten aus einem Repository lädt. Das Repository bietet eine suspend-Funktion an. Das ViewModel startet die Arbeit in viewModelScope, aktualisiert einen Zustand und behandelt Fehler. So bleibt die UI schlank und die asynchrone Arbeit hängt am Lebenszyklus des ViewModels.
data class UserProfile(
val name: String,
val email: String
)
data class ProfileUiState(
val isLoading: Boolean = false,
val profile: UserProfile? = null,
val errorMessage: String? = null
)
interface ProfileRepository {
suspend fun loadProfile(): UserProfile
}
class ProfileViewModel(
private val repository: ProfileRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun load() {
viewModelScope.launch {
_uiState.value = ProfileUiState(isLoading = true)
try {
val profile = repository.loadProfile()
_uiState.value = ProfileUiState(profile = profile)
} catch (exception: IOException) {
_uiState.value = ProfileUiState(
errorMessage = "Profil konnte nicht geladen werden."
)
}
}
}
}
An diesem Beispiel siehst du mehrere Grundregeln. Die loadProfile()-Funktion ist suspend, weil sie wahrscheinlich Netzwerk oder Datenbank nutzt. Das ViewModel ruft sie aus einer Coroutine auf. Der UI-State wird vor, während und nach dem Laden gesetzt. Ein konkreter Fehlerfall wird behandelt. Der Screen kann den uiState beobachten, etwa mit Compose, und muss nicht wissen, ob die Daten aus dem Netzwerk oder aus einem lokalen Cache kamen.
Wichtig ist auch, was hier nicht passiert: Es wird kein eigener Thread manuell erstellt. Es wird kein GlobalScope verwendet. Die UI ruft nicht direkt eine API-Schicht auf. Dadurch bleibt der Code leichter testbar. In einem Test kannst du ein Fake-Repository einsetzen, das sofort ein Profil liefert oder gezielt eine Exception wirft. Dann prüfst du, ob der State korrekt gesetzt wird.
Wenn du mehrere unabhängige Daten gleichzeitig brauchst, kann async passend sein. Beispiel: Ein Dashboard benötigt Profil und Einstellungen. Beide Aufrufe hängen nicht voneinander ab. Dann kannst du sie innerhalb derselben Coroutine parallel starten und anschließend beide Ergebnisse abwarten.
suspend fun loadDashboard(
profileRepository: ProfileRepository,
settingsRepository: SettingsRepository
): DashboardData = coroutineScope {
val profileDeferred = async { profileRepository.loadProfile() }
val settingsDeferred = async { settingsRepository.loadSettings() }
DashboardData(
profile = profileDeferred.await(),
settings = settingsDeferred.await()
)
}
Hier ist coroutineScope wichtig, weil die beiden async-Aufgaben an einen gemeinsamen, strukturierten Bereich gebunden sind. Wenn eine Aufgabe fehlschlägt, wird der Ablauf kontrolliert abgebrochen. Du bekommst keine verwaiste Hintergrundarbeit. Das ist eine gute Entscheidungsregel: Verwende async, wenn du mehrere unabhängige Ergebnisse wirklich parallel brauchst und alle Ergebnisse bewusst mit await() einsammelst. Verwende es nicht, nur weil der Name nach moderner asynchroner Arbeit klingt.
Eine typische Stolperfalle ist blockierender Code im falschen Kontext. Wenn du in einer Coroutine auf dem Main Dispatcher eine blockierende Funktion aufrufst, bleibt die App trotzdem stehen. Coroutines verhindern Blockieren nicht automatisch. Wenn eine Bibliothek keine suspendierende API bietet und wirklich blockiert, musst du die Arbeit passend verschieben, zum Beispiel in den IO-Kontext.
suspend fun readLargeFile(file: File): String = withContext(Dispatchers.IO) {
file.readText()
}
Auch hier gilt: Das ist kein Freifahrtschein, überall Dispatcher direkt zu verstreuen. In größerem Code ist es oft besser, Dispatcher zu injizieren, damit Tests stabil bleiben. Für den Einstieg reicht aber die Regel: Main ist für UI, IO ist für blockierende Ein- und Ausgabe, Default ist für CPU-lastige Arbeit. Prüfe immer, welche Art Arbeit du ausführst.
Eine zweite Stolperfalle ist fehlende Abbruchfähigkeit. Coroutines kooperieren beim Abbruch. Viele suspendierende Funktionen reagieren darauf bereits. Wenn du aber lange Schleifen oder eigene rechenintensive Blöcke schreibst, musst du gelegentlich prüfen, ob die Coroutine noch aktiv ist, oder suspendierende Punkte einbauen. Sonst kann Arbeit weiterlaufen, obwohl der Screen längst weg ist. Für normale App-Entwicklung begegnet dir das eher bei Importen, Synchronisationen oder Verarbeitung großer Datenmengen.
Eine dritte Stolperfalle liegt in der Fehlerbehandlung. Anfänger schreiben oft einen großen try-catch um alles oder gar keinen. Besser ist eine klare fachliche Grenze. Im ViewModel behandelst du Fehler, die in UI-State übersetzt werden sollen. Im Repository behandelst du Fehler, wenn du dort eine Fallback-Strategie hast, etwa lokale Daten statt Netzwerkdaten. Eine Exception nur zu fangen und zu ignorieren ist fast immer schlecht, weil du dann weder UI noch Logging noch Tests zuverlässig auswerten kannst.
In Code-Reviews solltest du bei Coroutines gezielt auf vier Fragen achten. Erstens: In welchem Scope wird gestartet? Zweitens: Welcher Dispatcher ist für die Arbeit passend? Drittens: Was passiert bei Fehlern? Viertens: Wird ein gestartetes async auch wirklich erwartet? Diese Fragen reichen oft, um die meisten frühen Coroutine-Probleme zu finden.
Beim Debuggen hilft dir eine kleine, konkrete Übung. Setze Breakpoints vor und nach einem suspend-Aufruf. Beobachte, dass der Code logisch weiterläuft, obwohl die Funktion zwischendurch pausieren kann. Logge zusätzlich den aktuellen Threadnamen, aber ziehe daraus keine falschen Schlüsse. Eine Coroutine ist nicht dasselbe wie ein Thread. Sie kann auf einem Thread starten und nach einer Pause auf einem anderen fortgesetzt werden, abhängig vom Kontext.
Für Tests solltest du lernen, Coroutine-Dispatcher kontrolliert zu ersetzen. Damit vermeidest du zufällige Wartezeiten und Tests, die manchmal bestehen und manchmal fehlschlagen. Gerade ViewModel-Tests profitieren davon, wenn du die asynchrone Ausführung steuerst und danach den erwarteten UI-State prüfst. Du musst dafür nicht sofort jedes Detail der Testbibliotheken kennen. Wichtig ist zuerst das Ziel: Asynchroner Code soll deterministisch prüfbar sein.
Fazit
Coroutines geben dir das Grundmodell für moderne asynchrone Android-Arbeit mit Kotlin: suspend beschreibt pausierbare Funktionen, Scopes binden Arbeit an einen Lebenszyklus, Dispatcher ordnen Arbeit passenden Threads zu, und async hilft nur dann, wenn du unabhängige Ergebnisse parallel brauchst. Prüfe dein Verständnis aktiv, indem du ein kleines ViewModel mit Repository-Fake testest, Breakpoints um suspend-Aufrufe setzt und in einem Code-Review bewusst nach Scope, Dispatcher, Fehlerbehandlung und unkontrolliertem async suchst.