Android Coden
Android 5 min lesen

Dispatchers in Kotlin-Coroutines

Dispatchers steuern, wo Coroutine-Code läuft. Du lernst Main, IO und Default gezielt für Android-Code zu wählen.

Wenn du Coroutines in Android nutzt, reicht es nicht, nur launch oder suspend zu kennen. Du musst auch entscheiden, wo der Code läuft. Genau dafür sind Dispatchers da: Sie ordnen Arbeit dem passenden Ausführungskontext zu, damit UI-Code reaktionsfähig bleibt, blockierende Zugriffe keinen Schaden anrichten und rechenlastige Aufgaben nicht den falschen Thread belegen.

Was ist das?

Ein Dispatcher ist in Kotlin-Coroutines der Teil, der bestimmt, auf welchem Thread oder Thread-Pool eine Coroutine ausgeführt wird. Du kannst ihn dir als Verkehrsregel für Arbeit vorstellen: Manche Aufgaben gehören auf den Hauptthread, manche in einen Pool für Ein- und Ausgabe, andere in einen Pool für Rechenarbeit. Der Dispatcher führt deine Logik nicht fachlich aus, aber er entscheidet, in welchem Umfeld sie ausgeführt wird.

Für Android sind vor allem drei Dispatcher wichtig: Dispatchers.Main, Dispatchers.IO und Dispatchers.Default. Main steht für den Android-Hauptthread. Dort aktualisierst du UI-State, reagierst auf Nutzeraktionen und arbeitest mit APIs, die nur vom Hauptthread aus genutzt werden dürfen. In Jetpack Compose betrifft das zum Beispiel State, der deine Oberfläche neu zeichnet. IO ist für blockierende Ein- und Ausgabe gedacht, etwa Datenbankzugriffe, Dateioperationen oder Netzwerkcode, wenn die verwendete API blockiert. Default ist für CPU-intensive Arbeit gedacht, etwa Sortieren großer Listen, Parsen größerer Datenmengen oder komplexere Berechnungen.

Das Problem, das Dispatchers lösen, ist praktisch: Android zeigt pro Frame nur ein kleines Zeitfenster für UI-Arbeit. Wenn du dieses Zeitfenster mit Datenbankzugriffen oder schweren Berechnungen füllst, ruckelt die App oder reagiert nicht mehr sauber. Umgekehrt ist es unsauber, jede Aufgabe blind nach IO zu schieben. Dann verlierst du Übersicht, verschleierst echte Leistungsprobleme und machst Tests schwerer nachvollziehbar.

Wie funktioniert es?

Eine Coroutine hat einen CoroutineContext. Der Dispatcher ist ein Bestandteil dieses Contexts. Wenn du etwa viewModelScope.launch { ... } verwendest, läuft die Coroutine auf Android standardmäßig meist im Main-Kontext, weil ViewModels häufig UI-nahe Zustände vorbereiten. Das ist sinnvoll, solange du keine blockierende oder stark rechenlastige Arbeit direkt darin ausführst.

Der Wechsel des Dispatchers passiert typischerweise mit withContext(...). Diese Funktion unterbricht nicht deine fachliche Logik, sondern wechselt kontrolliert den Ausführungskontext für einen bestimmten Block. Nach dem Block kehrt die Coroutine in den vorherigen Kontext zurück. Dadurch kannst du im ViewModel auf Main bleiben, aber gezielt Repository-Arbeit an eine passende Schicht delegieren.

Das mentale Modell ist: Wähle den Dispatcher nach Art der Arbeit, nicht nach Dateiname oder Architektur-Schicht. UI-nahe Arbeit läuft auf Main. Blockierende Ein- und Ausgabe läuft auf IO. Rechenlastige Arbeit läuft auf Default. Eine suspend-Funktion bedeutet nicht automatisch, dass sie auf einem Hintergrundthread läuft. suspend heißt nur, dass sie ausgesetzt und später fortgesetzt werden kann. Wenn in dieser Funktion blockierender Code steht, brauchst du trotzdem einen passenden Dispatcher.

In modernem Android-Code solltest du außerdem auf Verantwortlichkeiten achten. Ein ViewModel sollte nicht überall selbst entscheiden müssen, wie ein Repository intern arbeitet. Besser ist oft: Das Repository kapselt den Dispatcher für seine Datenquelle. So kann das ViewModel fachlich sauber bleiben und Tests können Dispatcher gezielt ersetzen. Die Android-Best-Practices empfehlen, Dispatcher nicht hart in tief verteiltem Code zu verstecken, sondern injizierbar zu machen, wenn du den Code zuverlässig testen willst.

In der Praxis

Stell dir vor, du baust eine Notizen-App. Das ViewModel lädt Notizen und stellt sie Compose als State bereit. Die Datenbankabfrage darf nicht den Hauptthread blockieren. Gleichzeitig sollte das ViewModel nicht wissen müssen, ob die Daten aus Room, einer Datei oder einem Netzwerkcache kommen. Das Repository übernimmt daher den Wechsel auf IO.

class NotesRepository(
    private val dao: NotesDao,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    suspend fun loadNotes(): List<Note> = withContext(ioDispatcher) {
        dao.getAllNotes()
    }
}

class NotesViewModel(
    private val repository: NotesRepository
) : ViewModel() {

    private val _state = MutableStateFlow(NotesState())
    val state: StateFlow<NotesState> = _state.asStateFlow()

    fun load() {
        viewModelScope.launch {
            _state.value = _state.value.copy(isLoading = true)

            val notes = repository.loadNotes()

            _state.value = NotesState(
                notes = notes,
                isLoading = false
            )
        }
    }
}

Hier bleibt das ViewModel lesbar. Es startet eine Coroutine im viewModelScope, setzt Ladezustand und übernimmt das Ergebnis. Das Repository sorgt dafür, dass die Datenbankarbeit im passenden Kontext läuft. Für Compose ist das angenehm, weil UI-State auf dem Main-Kontext aktualisiert wird, während blockierende Arbeit ausgelagert ist.

Eine klare Entscheidungsregel hilft dir im Alltag: Wenn die Aufgabe die Oberfläche direkt betrifft, bleib bei Main. Wenn die Aufgabe auf Speicher, Netzwerk, Datenbank oder Dateien wartet und dabei blockieren kann, nutze IO. Wenn die Aufgabe aktiv CPU-Zeit verbraucht, ohne hauptsächlich zu warten, nutze Default.

Ein Beispiel für Default wäre das Aufbereiten einer großen Liste nach dem Laden:

suspend fun buildSearchIndex(notes: List<Note>): SearchIndex =
    withContext(Dispatchers.Default) {
        SearchIndex.from(notes)
    }

Die typische Stolperfalle ist, Dispatchers.IO als Sammelbecken für alles zu verwenden. Das wirkt zunächst bequem, ist aber fachlich ungenau. Eine schwere Berechnung auf IO kann den Pool belasten, der eigentlich für wartende Ein- und Ausgabe gedacht ist. Genauso problematisch ist blockierende Arbeit auf Main, etwa ein synchroner Datei-Zugriff direkt nach einem Button-Klick. Solche Fehler zeigen sich später als Ruckler, verzögerte Eingaben oder schwer erklärbare Timeouts.

Für Tests ist Dispatcher-Injektion wichtig. Wenn du Dispatchers.IO fest im Code verdrahtest, wird kontrolliertes Testen schwerer. Übergib stattdessen einen Dispatcher über den Konstruktor oder eine kleine Dispatcher-Schnittstelle. Dann kannst du im Test einen Test-Dispatcher verwenden und Coroutine-Abläufe deterministischer prüfen. Im Code-Review solltest du gezielt nach withContext, blockierenden APIs und Dispatcher-Entscheidungen suchen: Passt der Dispatcher zur Arbeit? Ist der Wechsel an der richtigen Schicht? Wird UI-State nur dort verändert, wo es nachvollziehbar ist?

Fazit

Dispatchers sind kein Nebenthema, sondern ein Grundwerkzeug für robuste Android-Apps mit Kotlin-Coroutines. Wenn du Main, IO und Default sauber unterscheidest, bleibt deine UI flüssig, deine Architektur klarer und dein Code besser testbar. Prüfe dein Verständnis aktiv: Suche in einem eigenen Projekt nach Datenbank-, Datei- oder Rechenarbeit, markiere den passenden Dispatcher und kontrolliere mit Debugger, Tests oder Code-Review, ob die Arbeit wirklich im richtigen Kontext läuft.

Quellen (4)
Redaktion

Geschrieben von

Redaktion

Das Redaktionsteam recherchiert und schreibt Artikel zu aktuellen Themen rund um Tech, Lifestyle und Ratgeber.