Android Coden
Android 6 min lesen

Dispatcher Injection in Android-Coroutines

Du lernst, wie Dispatcher Injection Coroutine-Code testbar macht. Der Artikel zeigt IO, Default und typische Fehler.

Wenn du mit Kotlin-Coroutines arbeitest, entscheidet ein Dispatcher, auf welchem Thread-Pool deine Coroutine läuft. Dispatcher Injection heißt, dass du diesen Dispatcher nicht direkt in Klassen wie Repositorys oder Use Cases fest einbaust, sondern ihn von außen übergibst. Dadurch bleibt dein Code in der App performant und wird in Tests kontrollierbar.

Was ist das?

Dispatcher Injection ist eine Form von Dependency Injection für Coroutine-Dispatcher. Statt in einer Klasse direkt Dispatchers.IO oder Dispatchers.Default zu verwenden, bekommt die Klasse den passenden Dispatcher über den Konstruktor oder über ein kleines Provider-Objekt. Die Klasse weiß dann nur: “Diese Arbeit soll auf einem Dispatcher laufen.” Sie muss nicht selbst entscheiden, welcher konkrete Dispatcher das ist.

Das Problem dahinter siehst du oft erst beim Testen. In echter Android-Arbeit willst du Netzwerkzugriffe, Datenbankoperationen oder Dateizugriffe nicht auf dem Main Thread ausführen. Dafür nutzt du typischerweise Dispatchers.IO. CPU-lastige Arbeit wie Sortieren großer Listen, JSON-Verarbeitung oder Berechnungen gehört eher auf Dispatchers.Default. Das ist für Produktionscode sinnvoll. Für Unit-Tests ist es aber unpraktisch, wenn diese Dispatcher hart im Code stehen. Dann laufen Teile deines Tests auf echten Hintergrund-Threads, Timing wird schwieriger, und deine Tests können zufällig fehlschlagen.

Das mentale Modell ist einfach: Ein Dispatcher ist keine Geschäftslogik, sondern Ausführungsumgebung. Deine App-Logik sollte testbar bleiben, ohne an echte Thread-Pools gebunden zu sein. Du injizierst also nicht, weil Dispatchers.IO schlecht wäre, sondern weil du die Wahl des Ausführungsortes kontrollieren willst.

Im modernen Android-Kontext passt das gut zu ViewModel, Repository, Use Case, Flow und Compose. Compose beobachtet State meist auf dem Main Thread, während Repositorys im Hintergrund Daten laden. Mit Dispatcher Injection kannst du diese Grenze sauber halten: UI-nahe Klassen bleiben reaktiv, Datenklassen arbeiten im passenden Kontext, und Tests können alle Coroutine-Schritte deterministisch ausführen.

Wie funktioniert es?

Technisch besteht Dispatcher Injection meist aus drei Teilen: einer Schnittstelle oder Datenklasse für Dispatcher, einer Produktionsimplementierung und einer Testimplementierung. In kleinen Projekten reicht manchmal auch ein einzelner Konstruktorparameter. Wichtig ist, dass die Klasse keine konkrete globale Dispatcher-Instanz selbst auswählt.

Ein häufiges Muster ist ein AppDispatchers-Typ:

data class AppDispatchers(
    val io: CoroutineDispatcher,
    val default: CoroutineDispatcher,
    val main: CoroutineDispatcher
)

In der App initialisierst du ihn mit den echten Dispatchern:

val productionDispatchers = AppDispatchers(
    io = Dispatchers.IO,
    default = Dispatchers.Default,
    main = Dispatchers.Main
)

In Tests ersetzt du diese Werte durch Test-Dispatcher. Dadurch kann ein Test den Ablauf kontrollieren, virtuelle Zeit nutzen und gezielt prüfen, wann eine Coroutine fertig ist. Das ist besonders wichtig, wenn du mit runTest, Flows, Verzögerungen oder mehreren parallelen Coroutines arbeitest.

Der Unterschied zwischen IO und Default bleibt dabei fachlich relevant. Dispatchers.IO ist für blockierende Ein- und Ausgabe gedacht, also etwa Room-Abfragen, Dateioperationen oder klassische APIs, die blockieren können. Dispatchers.Default ist für CPU-Arbeit gedacht. Dispatcher Injection macht diese Entscheidung nicht überflüssig. Sie sorgt nur dafür, dass die Entscheidung austauschbar und testbar bleibt.

In Android-Architektur taucht das häufig in Repositorys und Use Cases auf. Ein ViewModel sollte nicht unnötig wissen, wie ein Repository intern Threads wechselt. Das Repository kann mit withContext(dispatchers.io) eine Datenbankabfrage ausführen. Ein Use Case kann mit withContext(dispatchers.default) eine größere Liste berechnen. Das ViewModel ruft diese Funktionen auf und konzentriert sich auf UI-State.

Bei Flow ist das ähnlich, aber mit einer Besonderheit. Für Flow-Pipelines nutzt du oft flowOn(dispatchers.io), wenn die vorgelagerten Operatoren auf einem Hintergrund-Dispatcher laufen sollen. Auch hier solltest du den Dispatcher injizieren. Sonst wird es in Tests schwerer, Emissionen kontrolliert einzusammeln und Fehler stabil nachzustellen.

In der Praxis

Stell dir ein Repository vor, das Benutzerdaten aus einer lokalen Datenquelle lädt und danach sortiert. Datenzugriff ist IO-Arbeit, Sortierung ist CPU-Arbeit. Mit Dispatcher Injection bleibt diese Trennung sichtbar:

class UserRepository(
    private val localDataSource: UserLocalDataSource,
    private val dispatchers: AppDispatchers
) {
    suspend fun loadSortedUsers(): List<User> {
        val users = withContext(dispatchers.io) {
            localDataSource.loadUsers()
        }

        return withContext(dispatchers.default) {
            users.sortedBy { it.name.lowercase() }
        }
    }

    fun observeUsers(): Flow<List<User>> {
        return localDataSource.observeUsers()
            .map { users ->
                users.sortedBy { it.name.lowercase() }
            }
            .flowOn(dispatchers.default)
    }
}

Ein Test kann nun einen Test-Dispatcher übergeben. Das Repository nutzt dann nicht echte IO- oder Default-Threads, sondern den Dispatcher, den dein Test kontrolliert:

@Test
fun loadSortedUsers_returnsUsersByName() = runTest {
    val testDispatchers = AppDispatchers(
        io = StandardTestDispatcher(testScheduler),
        default = StandardTestDispatcher(testScheduler),
        main = StandardTestDispatcher(testScheduler)
    )

    val repository = UserRepository(
        localDataSource = FakeUserLocalDataSource(
            users = listOf(
                User("2", "Zoe"),
                User("1", "Anna")
            )
        ),
        dispatchers = testDispatchers
    )

    val result = repository.loadSortedUsers()

    assertEquals(listOf("Anna", "Zoe"), result.map { it.name })
}

Der praktische Gewinn ist nicht nur “Tests laufen”. Der Gewinn ist, dass du Verhalten prüfst, nicht Thread-Zufälle. Dein Test muss nicht warten, bis ein echter Hintergrund-Thread irgendwann fertig ist. Er führt die Coroutine-Arbeit unter kontrollierten Bedingungen aus. Das passt zu Android-Testing-Grundlagen: Tests sollten klein, wiederholbar und möglichst unabhängig von echter Infrastruktur sein.

Eine gute Entscheidungsregel lautet: Sobald eine Klasse withContext(...), flowOn(...) oder einen eigenen Coroutine-Kontext verwendet, prüfe im Code-Review, ob der Dispatcher injiziert wird. Für App-Code ist hartes Dispatchers.IO in sehr kleinen Hilfsfunktionen manchmal noch vertretbar, aber in Repositorys, Use Cases, Datenquellen und Services wird es schnell teuer. Je näher die Klasse an Geschäftslogik oder Datenfluss liegt, desto eher sollte der Dispatcher von außen kommen.

Eine typische Stolperfalle ist, nur Dispatchers.Main in Tests zu ersetzen und Dispatchers.IO im Repository zu lassen. Dann sieht dein Test auf den ersten Blick modern aus, nutzt aber weiterhin echte Hintergrundarbeit. Das kann lokal funktionieren und auf CI instabil werden. Eine zweite Stolperfalle ist die falsche Dispatcher-Wahl: CPU-Arbeit auf IO auszulagern löst kein Architekturproblem, sondern kann Ressourcen verschwenden. Nutze IO für blockierende Ein- und Ausgabe, Default für rechenlastige Arbeit und Main für UI-nahe Aktualisierungen.

Achte außerdem darauf, Dispatcher Injection nicht mit Scope Injection zu verwechseln. Ein Dispatcher sagt, wo Code läuft. Ein Scope sagt, wie lange Coroutines leben und wann sie abgebrochen werden. Ein Repository braucht meistens keinen eigenen globalen Scope, nur weil es einen Dispatcher bekommt. In vielen Fällen reicht es, aus einer suspendierenden Funktion heraus mit withContext den Kontext zu wechseln. Das hält Cancellation sauber: Wenn das ViewModel oder der aufrufende Scope abbricht, wird auch die Repository-Arbeit abgebrochen.

Für Compose ändert sich am Prinzip wenig. Deine Composables sollten keine Dispatcher kennen müssen. Sie rufen ViewModel-Funktionen auf oder sammeln State. Das ViewModel koordiniert den UI-Zustand, und Repositorys oder Use Cases treffen die Entscheidung über IO und Default über injizierte Dispatcher. So bleibt die UI-Schicht schlank, und die testbare Logik sitzt dort, wo sie hingehört.

Fazit

Dispatcher Injection ist ein kleiner Architekturgriff mit großem Nutzen für Coroutine-Code: Du trennst Geschäftslogik von Ausführungsdetails, nutzt in der App weiterhin passende Dispatcher wie IO und Default, und bekommst deterministische Tests. Prüfe dein Verständnis aktiv, indem du in einem bestehenden Repository hart codierte Dispatcher suchst, sie über den Konstruktor injizierst und danach einen Unit-Test mit runTest schreibst. Im Code-Review solltest du gezielt fragen: Welche Arbeit läuft auf welchem Dispatcher, und kann der Test diesen Dispatcher kontrollieren?

Quellen (5)
Redaktion

Geschrieben von

Redaktion

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