Flow-Einstieg: asynchrone Datenströme in Kotlin
Flow modelliert asynchrone Datenströme in Android. Du nutzt es für Datenbank-Updates und Netzwerkstatus.
Flow ist dein Werkzeug, wenn Daten in einer Android-App nicht nur einmal geladen werden, sondern sich über Zeit verändern. Statt wiederholt manuell nachzufragen, lässt du einen Datenstrom Werte liefern: zum Beispiel neue Einträge aus einer Room-Datenbank, den aktuellen Netzwerkstatus oder den Ladezustand eines Screens.
Was ist das?
Ein Flow ist in Kotlin ein asynchroner Stream. Er kann keinen Wert, einen Wert oder viele Werte nacheinander ausgeben. Diese Werte kommen nicht unbedingt sofort, sondern dann, wenn sie verfügbar sind. Genau deshalb passt Flow gut zu Android: Apps reagieren ständig auf Änderungen, etwa auf Nutzereingaben, Datenbankänderungen, Serverantworten oder Zustandswechsel im System.
Das wichtigste mentale Modell ist: Eine normale Funktion gibt einen einzelnen Wert zurück. Eine suspend-Funktion gibt später einen einzelnen Wert zurück. Ein Flow gibt Werte über Zeit zurück. Du kannst dir Flow als Leitung vorstellen, aus der immer dann ein neuer Wert kommt, wenn sich die zugrunde liegende Quelle ändert. Diese Quelle kann eine Datenbankabfrage, ein Repository, ein Callback aus dem Android-SDK oder ein kombinierter App-Zustand sein.
Der Begriff Cold Flow ist dabei zentral. Ein Cold Flow arbeitet erst, wenn jemand ihn sammelt. Das Sammeln passiert mit collect oder indirekt über APIs wie collectAsStateWithLifecycle in Compose. Vorher ist der Flow nur eine Beschreibung: Wenn jemand zuhört, dann sollen diese Werte erzeugt werden. Das schützt dich vor unnötiger Arbeit, kann aber auch überraschen, wenn du erwartest, dass ein Flow schon beim Erstellen startet.
In der Android-Architektur liegt Flow meistens nicht direkt in der UI-Schicht. Typisch ist eine Kette aus Datenquelle, Repository, ViewModel und UI. Die Datenquelle liefert Rohdaten, das Repository formt daraus fachliche Daten, das ViewModel bereitet UI-State vor, und Compose zeigt diesen State an. Flow verbindet diese Schichten, ohne dass jede Schicht selbst wissen muss, wann genau sich etwas ändert.
Wie funktioniert es?
Ein Flow wird erzeugt, transformiert und gesammelt. Erzeugt wird er zum Beispiel mit flow { ... }, durch Room-Abfragen, durch callbackFlow oder durch bestehende APIs, die bereits Flow zurückgeben. Transformiert wird er mit Operatoren wie map, filter, combine, catch oder distinctUntilChanged. Gesammelt wird er mit collect, first, toList oder einer UI-nahen Integration.
Wichtig ist die Trennung zwischen Beschreibung und Ausführung. Wenn du schreibst:
val names: Flow<List<String>> = repository.observeNames()
dann beobachtest du noch nichts aktiv. Du hast nur einen Flow-Wert in der Hand. Erst wenn dieser Flow gesammelt wird, läuft die Kette. Bei einem Cold Flow wird die Arbeit für jeden Collector neu gestartet. Wenn zwei Stellen denselben Cold Flow sammeln, können auch zwei getrennte Ausführungen entstehen. Das ist bei einfachen Datenbankabfragen oft okay, bei teuren Netzwerkoperationen aber ein häufiger Fehler.
Flow basiert auf Coroutines. Das bedeutet: Du blockierst keinen Thread, während du auf Werte wartest. Stattdessen pausiert die Coroutine und läuft weiter, sobald ein neuer Wert vorhanden ist. Für Android ist das wichtig, weil der Main Thread frei bleiben muss. Die UI darf nicht hängen, nur weil eine Datenbank liest oder ein Netzwerkstatus beobachtet wird.
In ViewModels sammelst du Flow oft nicht dauerhaft mit einem freien launch, sondern wandelst ihn in einen langlebigen UI-State um. Dafür gibt es Operatoren wie stateIn oder shareIn. stateIn macht aus einem Flow einen StateFlow, der immer einen aktuellen Wert besitzt. Das passt gut zu Compose, weil Compose mit beobachtbarem State arbeitet. Ein Screen braucht meistens nicht nur Ereignisse, sondern einen vollständigen Zustand: lädt, Daten vorhanden, leer, Fehler.
Ein weiterer wichtiger Punkt ist Fehlerbehandlung. Ein Fehler in einem Flow beendet den Datenstrom, wenn du ihn nicht behandelst. Mit catch kannst du Fehler abfangen und etwa einen Fehlerzustand ausgeben. Du solltest dabei genau entscheiden, auf welcher Ebene du Fehler in UI-State umwandelst. Ein Repository kann technische Fehler in ein fachliches Ergebnis übersetzen; das ViewModel kann daraus darstellen, was der Screen zeigen soll.
In der Praxis
Stell dir eine App vor, die eine Liste gespeicherter Artikel zeigt. Die Daten kommen aus Room. Wenn ein Artikel gespeichert, gelöscht oder geändert wird, soll sich die Liste automatisch aktualisieren. Ohne Flow müsstest du nach jeder Änderung daran denken, die Liste neu zu laden. Mit Flow modellierst du die Liste als reaktiven Datenstrom.
In der DAO-Schicht könnte das so aussehen:
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY updatedAt DESC")
fun observeArticles(): Flow<List<ArticleEntity>>
}
Room liefert hier einen Flow. Sobald die Tabelle sich ändert, kann Room eine neue Liste ausgeben. Das Repository übersetzt die Datenbankobjekte in Domain-Modelle:
class ArticleRepository(
private val dao: ArticleDao
) {
fun observeArticles(): Flow<List<Article>> {
return dao.observeArticles()
.map { entities ->
entities.map { entity ->
Article(
id = entity.id,
title = entity.title,
updatedAt = entity.updatedAt
)
}
}
.distinctUntilChanged()
}
}
Im ViewModel bereitest du daraus einen UI-State vor. So muss der Screen nicht wissen, woher die Daten kommen:
data class ArticleListUiState(
val isLoading: Boolean = true,
val articles: List<Article> = emptyList(),
val errorMessage: String? = null
)
class ArticleListViewModel(
repository: ArticleRepository
) : ViewModel() {
val uiState: StateFlow<ArticleListUiState> =
repository.observeArticles()
.map { articles ->
ArticleListUiState(
isLoading = false,
articles = articles
)
}
.catch { error ->
emit(
ArticleListUiState(
isLoading = false,
errorMessage = "Artikel konnten nicht geladen werden."
)
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ArticleListUiState()
)
}
In Compose sammelst du diesen State lifecycle-bewusst:
@Composable
fun ArticleListScreen(
viewModel: ArticleListViewModel
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when {
uiState.isLoading -> LoadingContent()
uiState.errorMessage != null -> ErrorContent(uiState.errorMessage)
uiState.articles.isEmpty() -> EmptyContent()
else -> ArticleList(uiState.articles)
}
}
Dieses Beispiel zeigt die typische Rollenverteilung: Room beobachtet Daten, das Repository transformiert, das ViewModel formt UI-State, und Compose rendert. Du bekommst reaktive Daten, ohne dass die UI Datenquellen direkt anfasst.
Eine wichtige Entscheidungsregel: Nutze Flow, wenn mehrere Werte über Zeit erwartet werden. Nutze eine suspend-Funktion, wenn du genau eine Antwort brauchst. Ein Login-Request, der einmal ein Ergebnis liefert, ist oft eine suspend-Funktion. Eine Artikelliste, die sich automatisch aktualisieren soll, ist ein Flow. Ein Netzwerkstatus, der zwischen verbunden und getrennt wechselt, ist ebenfalls ein Flow.
Eine typische Stolperfalle ist das Sammeln an der falschen Stelle. Wenn du in einer Composable direkt collect in einer unkontrollierten Coroutine startest, riskierst du doppelte Sammler, veraltete Jobs oder Arbeit außerhalb des passenden Lebenszyklus. In Compose solltest du Flow in State umwandeln und lifecycle-bewusste APIs verwenden. Im ViewModel solltest du länger laufende Streams mit viewModelScope koppeln, damit sie enden, wenn das ViewModel verschwindet.
Eine zweite Stolperfalle ist zu viel Arbeit im Flow auf dem falschen Dispatcher. Datenbank- und Netzwerkoperationen gehören nicht auf den Main Thread. Viele Jetpack-APIs kümmern sich bereits um passende Ausführung, aber bei eigenen Flow-Blöcken musst du bewusst sein. Mit flowOn kannst du festlegen, wo ein Teil der Flow-Kette läuft. Trotzdem solltest du nicht überall Dispatcher verteilen. Halte die Regel einfach: UI-State wird im ViewModel vorbereitet, schwere Arbeit liegt in Repository oder Datenquelle, und die UI sammelt nur den fertigen Zustand.
Auch Tests profitieren von Flow. Du kannst prüfen, welche Werte ein Flow nacheinander ausgibt. Für einfache Fälle reicht es, first() zu verwenden, wenn du nur den ersten Wert erwartest. Für mehrere Emissionen kannst du den Flow in einer Test-Coroutine sammeln und die Werte vergleichen. Damit trainierst du nicht nur die API, sondern auch dein Verständnis: Welche Änderung löst welchen neuen Zustand aus?
Im Code-Review solltest du bei Flow besonders auf drei Fragen achten. Erstens: Ist Flow wirklich nötig, oder reicht eine suspend-Funktion? Zweitens: Wird der Flow lifecycle-bewusst gesammelt? Drittens: Gibt es eine klare Fehler- und Ladezustandsmodellierung? Wenn diese Fragen sauber beantwortet sind, ist der Datenstrom meist leichter zu warten.
Fazit
Flow hilft dir, Android-Apps als reaktive Systeme zu bauen: Daten ändern sich, und dein UI-State folgt kontrolliert. Übe das an einem kleinen Screen mit Room oder einem simulierten Netzwerkstatus. Setze Breakpoints in map, catch und beim Sammeln des States, damit du siehst, wann der Cold Flow startet und welche Werte wirklich ankommen. Ergänze danach einen Test für mindestens zwei Emissionen und prüfe im Code-Review, ob jede Schicht nur ihre eigene Aufgabe übernimmt.