Flow-Kontext in Kotlin Flow
Lerne, wie flowOn den Upstream-Kontext eines Flow steuert, ohne Collector in Android-Apps zu überraschen.
Wenn du mit Kotlin Flow arbeitest, trennst du Datenströme von der Stelle, an der sie eingesammelt werden. Der Flow-Kontext entscheidet dabei, wo welche Arbeit läuft: Datenbankzugriff, Netzwerkaufruf, Mapping, Filterung und UI-Update dürfen nicht wahllos im selben Thread landen. Genau hier wird flowOn wichtig, weil du damit den Ausführungskontext des Upstream steuerst, ohne den Collector still und unerwartet auf einen anderen Dispatcher zu verschieben.
Was ist das?
Flow Context beschreibt den Coroutine-Kontext, in dem Teile eines Flow ausgeführt werden. In Android ist damit meist die Frage gemeint: Läuft diese Arbeit auf Dispatchers.Main, Dispatchers.IO, Dispatchers.Default oder in einem eigenen Dispatcher? Diese Frage ist praktisch relevant, weil Android-UI-Code auf dem Main Thread laufen muss, während langsame oder blockierende Arbeit dort nichts verloren hat.
Ein Flow besteht gedanklich aus zwei Seiten. Unten sitzt der Collector, also die Stelle, die Werte empfängt. In einer modernen Android-App ist das häufig ein ViewModel, ein Compose-Screen über collectAsStateWithLifecycle, oder eine Testfunktion. Oben liegt der Upstream: Alles, was vor einem bestimmten Operator passiert, also Quelle, Transformationen und Zwischenoperatoren. flowOn verändert den Kontext dieses Upstream.
Das ist eine wichtige Abgrenzung. flowOn(Dispatchers.IO) bedeutet nicht: „Der komplette Flow läuft auf IO.“ Es bedeutet: „Die Operatoren oberhalb von flowOn laufen im angegebenen Kontext.“ Der Collector darunter bleibt in seinem eigenen Kontext. Dieses Verhalten schützt dich vor Überraschungen. Wenn dein UI-Code in Compose sammelt, soll er weiter im passenden Lifecycle- und Main-Kontext bleiben. Gleichzeitig darf der Repository-Code davor Daten aus einer Datenbank lesen oder JSON verarbeiten, ohne die Oberfläche zu blockieren.
Im Roadmap-Kontext gehört dieses Thema zu Coroutines, Flow und Background Work. Es verbindet die Grundlagen von Coroutine-Dispatchern mit sauberer App-Architektur. Du lernst nicht nur eine API, sondern eine Denkregel: Die Schicht, die Arbeit erzeugt, sollte ihren passenden Ausführungskontext kennen. Die Schicht, die sammelt, sollte nicht erraten müssen, ob irgendwo im Upstream gerade teure Arbeit passiert.
Wie funktioniert es?
Ein Flow ist kalt. Das heißt: Der Code im Flow läuft erst, wenn jemand ihn sammelt. Ohne besondere Kontextsteuerung erbt der Flow den Kontext des Collectors. Wenn du also in einer Coroutine auf dem Main Dispatcher sammelst und dein Flow darin eine blockierende Operation ausführt, kann diese Operation auf dem Main Thread landen. Das ist in Android meist falsch.
flowOn setzt an dieser Stelle an. Du fügst den Operator in die Kette ein, und Kotlin Flow führt alles oberhalb davon in dem angegebenen Coroutine-Kontext aus. Alles unterhalb bleibt im Kontext des Collectors oder im Kontext, den spätere Operatoren setzen. Dadurch kannst du die Grenze klar lesen.
Ein einfaches mentales Modell hilft: Lies eine Flow-Kette von unten nach oben, wenn du den Einfluss von flowOn verstehen willst. Der Operator wirkt nach oben. Steht flowOn(Dispatchers.IO) direkt unter einer Datenbankabfrage und einem Mapping, laufen diese Upstream-Schritte auf IO. Steht danach noch ein map, dann gehört dieses spätere map nicht zu diesem Upstream-Abschnitt und läuft im Downstream-Kontext.
Das ist kein Detail für Spezialfälle. In der täglichen Android-Entwicklung tritt diese Frage ständig auf. Ein Repository gibt etwa Flow<List<Article>> zurück. Die Daten kommen aus Room, DataStore, Netzwerk-Cache oder einer kombinierten Quelle. Das ViewModel sammelt den Flow oder wandelt ihn mit stateIn in StateFlow um. Compose beobachtet dann diesen Zustand. Jede Schicht soll ihre Aufgabe erfüllen: Das Repository kümmert sich um Datenarbeit, das ViewModel um UI-Zustand, Compose um Darstellung.
Best Practices für Coroutines auf Android empfehlen, Dispatchers nicht hart und unkontrolliert überall zu verteilen. Stattdessen solltest du sie injizieren oder zentral bereitstellen, damit Tests stabil bleiben. Das gilt auch für flowOn. Ein Repository kann einen ioDispatcher erhalten und damit den Upstream sauber verschieben. In Tests ersetzt du diesen Dispatcher durch einen Testdispatcher. So prüfst du Verhalten ohne echte Thread-Abhängigkeit.
Wichtig ist auch: flowOn ist kontextbewusst, aber kein Ersatz für Architektur. Wenn du in einem Use Case CPU-lastige Sortierung machst, kann Dispatchers.Default besser passen als Dispatchers.IO. Wenn du Room verwendest, nimmt dir Room bereits viel Threading-Arbeit ab, trotzdem können zusätzliche Transformationen im Flow teuer sein. Du entscheidest also nicht mechanisch „Daten gleich IO“, sondern schaust auf die konkrete Arbeit: blockierend, I/O-lastig, CPU-lastig oder UI-nah.
Eine typische Stolperfalle ist die falsche Platzierung. Viele Lernende schreiben flowOn ans Ende der Kette und erwarten, dass wirklich alles davor und danach auf IO läuft. Technisch wirkt es nur auf den Upstream, aber je nach Kette kann ein später eingefügter Operator plötzlich nicht mehr auf dem erwarteten Dispatcher laufen. Noch problematischer wird es, wenn du UI-nahe Werte im Repository formatierst, weil du dann Fachlogik, Darstellung und Threading vermischst. Halte die Grenze klar: Datenarbeit oben, UI-Arbeit unten.
In der Praxis
Stell dir eine App vor, die Artikel aus einem Repository lädt und im ViewModel als UI-State anbietet. Das Repository liest aus einer lokalen Quelle und sortiert die Daten. Der Collector im ViewModel soll nicht wissen müssen, wie teuer diese Arbeit ist. Er soll nur einen Flow erhalten, den er sicher weiterverarbeiten kann.
class ArticleRepository(
private val dao: ArticleDao,
private val ioDispatcher: CoroutineDispatcher
) {
fun observeArticles(): Flow<List<Article>> {
return dao.observeAll()
.map { entities ->
entities
.filter { it.published }
.map { it.toDomain() }
}
.flowOn(ioDispatcher)
}
}
class ArticleViewModel(
repository: ArticleRepository
) : ViewModel() {
val uiState: StateFlow<ArticleUiState> =
repository.observeArticles()
.map { articles -> ArticleUiState.Content(articles) }
.catch { error -> emit(ArticleUiState.Error(error.message ?: "Unbekannter Fehler")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ArticleUiState.Loading
)
}
In diesem Beispiel gehören dao.observeAll() und das Mapping oberhalb von flowOn(ioDispatcher) zum Upstream. Diese Schritte laufen also im IO-Kontext, den du von außen übergibst. Das ViewModel sammelt und formt daraus UI-State in viewModelScope. Der Collector bleibt damit in der ViewModel-Welt. Compose kann später den uiState lifecycle-aware sammeln, ohne selbst einen Dispatcher setzen zu müssen.
Die Entscheidungsregel lautet: Setze flowOn dort, wo eine Schicht ihre eigene Arbeitslast nach oben begrenzen kann. Ein Repository darf sagen: „Meine Datenquelle und meine Datenumwandlung laufen auf diesem Dispatcher.“ Ein ViewModel sollte nicht für jedes Repository erraten, welcher Dispatcher intern nötig ist. Dadurch bleibt dein Code lesbarer und deine Tests werden klarer.
Hier ist die Platzierung entscheidend:
repository.observeArticles()
.flowOn(Dispatchers.IO)
.map { articles -> ArticleUiState.Content(articles) }
In dieser Variante läuft nur der Upstream von flowOn auf IO. Das nachfolgende map zu ArticleUiState läuft im Downstream-Kontext. Das kann korrekt sein, wenn es eine leichte UI-Zustandsumwandlung ist. Wenn du aber glaubst, dieses map laufe ebenfalls auf IO, hast du das Modell falsch verstanden.
Eine weitere Falle ist die direkte Nutzung von Dispatchers.IO im Produktionscode ohne Möglichkeit zum Austausch. Für kleine Beispiele ist das lesbar, in echter App-Architektur ist ein injizierter Dispatcher besser. Dann kannst du im Test einen kontrollierten Dispatcher verwenden und prüfen, ob der Flow Werte korrekt liefert, ohne dich auf echte Threads und Timing zu verlassen.
Beim Debuggen kannst du das Verständnis sehr konkret prüfen. Setze Breakpoints in die Operatoren oberhalb und unterhalb von flowOn und beobachte den Thread-Namen. Noch besser: Schreibe einen kleinen Test mit Testdispatchern und prüfe, ob dein Repository keine Main-Thread-Annahmen macht. Im Code-Review solltest du Flow-Ketten bewusst lesen: Wo entsteht die Arbeit? Wo wird sie gesammelt? Wo steht flowOn? Gibt es eine teure Transformation unterhalb von flowOn, die eigentlich in den Upstream gehört?
Auch bei Fehlerbehandlung lohnt sich diese Lesart. catch fängt nur Fehler aus dem Upstream, also aus den Operatoren oberhalb von catch. Wenn du flowOn und catch kombinierst, sollte die Reihenfolge bewusst gewählt sein. Das Thema bleibt zwar Flow-Kontext, aber es zeigt denselben Grundsatz: Operatoren wirken nicht magisch auf die gesamte Kette. Ihre Position beschreibt ihre Verantwortung.
Fazit
Flow Context hilft dir, Android-Flows kontrolliert und nachvollziehbar auszuführen: flowOn verschiebt den Upstream auf einen passenden Dispatcher, während der Collector in seinem eigenen Kontext bleibt. Prüfe das aktiv, indem du eine Flow-Kette in deinem Projekt markierst, Upstream und Downstream benennst, die Dispatcher-Grenze erklärst und mindestens einen Test oder Debugger-Durchlauf nutzt, um deine Annahme zu bestätigen.