Flow Combination in Kotlin Flow
Du lernst, wie du mehrere Flows zu UI-State zusammenführst. Der Artikel grenzt combine, zip und merge klar voneinander ab.
Flow Combination bedeutet, dass du mehrere Flow-Datenströme so zusammenführst, dass daraus ein Zustand entsteht, den deine UI verlässlich anzeigen kann. In echten Android-Apps kommt dieser Bedarf ständig vor: Ein Screen braucht Daten aus der Datenbank, den aktuellen Suchtext, einen Filter, einen Login-Status und vielleicht noch einen Ladezustand. Statt diese Werte verstreut in der UI zu sammeln, kombinierst du sie im ViewModel zu einem klaren UiState.
Was ist das?
Ein Flow ist ein asynchroner Datenstrom. Er kann über Zeit mehrere Werte liefern: neue Daten aus Room, Änderungen aus DataStore, Eingaben aus der Suche oder Ergebnisse eines Repository-Aufrufs. Flow Combination beschreibt die Operatoren und Muster, mit denen du solche Ströme zusammenbringst.
Das Ziel ist nicht, möglichst viele Operatoren zu kennen. Das Ziel ist, ein sauberes mentales Modell zu bauen: Jeder Flow ist eine Quelle. Deine UI soll aber selten einzelne Rohquellen rendern. Sie braucht meistens ein fertiges Modell wie ArticleListUiState, ProfileUiState oder SettingsUiState. Dieses Modell entsteht im ViewModel, nicht in der Composable-Funktion.
Die drei wichtigen Begriffe sind combine, zip und merge. Sie klingen ähnlich, lösen aber unterschiedliche Probleme. combine berechnet einen neuen Wert, sobald einer der beteiligten Flows einen neuen Wert liefert und von allen Flows mindestens ein Wert bekannt ist. zip wartet auf passende Paare: erster Wert aus Flow A mit erstem Wert aus Flow B, zweiter mit zweitem, und so weiter. merge führt mehrere Flows gleichen oder kompatiblen Typs in einen Strom zusammen, ohne die Werte inhaltlich zu verbinden.
Im Android-Kontext ist vor allem combine zentral, weil moderne Screens mit Compose deklarativ arbeiten. Compose rendert den aktuellen State. Wenn sich eine Eingabe, ein Datenbankergebnis oder ein Filter ändert, soll der State neu berechnet werden. Du möchtest also nicht manuell prüfen, welche Quelle gerade aktualisiert wurde. Du beschreibst die Abhängigkeiten, und Flow liefert bei Änderungen neue Werte.
Wie funktioniert es?
Bei combine denkst du in Zustandsableitung. Du hast mehrere Quellen und eine Transformationsfunktion. Diese Funktion nimmt die neuesten bekannten Werte und erzeugt daraus ein neues Ergebnis. Ein klassisches Beispiel: articlesFlow liefert Artikel, queryFlow liefert den Suchtext, selectedTagFlow liefert einen aktiven Filter. Mit combine baust du daraus eine gefilterte Liste für die UI.
Wichtig ist: combine wartet am Anfang, bis jeder beteiligte Flow mindestens einen Wert geliefert hat. Danach löst jede Änderung eine neue Berechnung aus. Wenn dein Suchtext sich ändert, wird mit der letzten bekannten Artikelliste neu gefiltert. Wenn Room eine aktualisierte Liste liefert, wird mit dem letzten Suchtext neu gefiltert. Genau dieses Verhalten passt gut zu Bildschirmzustand.
zip hat ein anderes Zeitmodell. Es verbindet Werte nach Position. Wenn Flow A drei Werte liefert und Flow B nur einen, entsteht auch nur ein Paar. Das ist sinnvoll, wenn du zwei Sequenzen synchron abarbeiten willst, etwa zwei Schritte eines Imports oder zwei Ergebnislisten, bei denen der erste Eintrag der einen Quelle bewusst zum ersten Eintrag der anderen Quelle gehört. Für UI-State ist das oft falsch, weil ein Screen nicht auf das nächste passende Paar warten soll. Er soll meistens den neuesten bekannten Zustand zeigen.
merge ist noch einmal anders. Es nimmt mehrere Flows und sendet deren Werte in einen gemeinsamen Ausgabeflow weiter. Es kombiniert keine Felder und baut kein gemeinsames Objekt. Das passt zu Ereignissen: Du kannst zum Beispiel Klickereignisse, Pull-to-Refresh und automatische Aktualisierungen als RefreshTrigger in einen gemeinsamen Strom leiten. Danach kann ein Repository-Aufruf starten. Für dauerhaften State reicht merge allein aber nicht, weil du nicht weißt, welche Werte zusammengehören.
In einer sauberen Architektur liegen diese Kombinationen meist im ViewModel oder in einer Use-Case-Schicht. Repositories liefern fachliche Flows, etwa Flow<List<Article>>. Das ViewModel kombiniert sie mit UI-nahen Signalen wie Suchtext oder ausgewählten Filtern. Anschließend stellst du den State als StateFlow bereit. Compose sammelt diesen State lifecycle-bewusst ein und rendert daraus die Oberfläche.
Achte auch auf Coroutine-Regeln. Schwere Arbeit wie Datenbankzugriff oder Netzwerklogik gehört nicht auf den Main Thread. Flow-Operatoren laufen grundsätzlich im Kontext des Collectors, sofern du nicht mit flowOn oder sauber getrennten Repository-Funktionen arbeitest. Außerdem sollte die UI keine langlebigen Coroutines selbst verwalten, wenn das ViewModel den Zustand besitzen kann. So bleibt der Screen testbar und stabil bei Konfigurationsänderungen.
In der Praxis
Stell dir einen Screen vor, der Artikel anzeigt. Die Daten kommen aus einem Repository. Zusätzlich gibt es ein Suchfeld und einen Schalter, der nur Favoriten anzeigen soll. Die UI braucht nicht drei einzelne Werte, sondern einen fertigen State.
data class Article(
val id: String,
val title: String,
val isFavorite: Boolean
)
data class ArticleListUiState(
val query: String = "",
val favoritesOnly: Boolean = false,
val articles: List<Article> = emptyList()
)
class ArticleListViewModel(
repository: ArticleRepository
) : ViewModel() {
private val query = MutableStateFlow("")
private val favoritesOnly = MutableStateFlow(false)
val uiState: StateFlow<ArticleListUiState> =
combine(
repository.observeArticles(),
query,
favoritesOnly
) { articles, currentQuery, onlyFavorites ->
val filtered = articles
.filter { article ->
currentQuery.isBlank() ||
article.title.contains(currentQuery, ignoreCase = true)
}
.filter { article ->
!onlyFavorites || article.isFavorite
}
ArticleListUiState(
query = currentQuery,
favoritesOnly = onlyFavorites,
articles = filtered
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ArticleListUiState()
)
fun onQueryChanged(value: String) {
query.value = value
}
fun onFavoritesOnlyChanged(value: Boolean) {
favoritesOnly.value = value
}
}
In Compose würdest du dann nur uiState beobachten und Events zurück ans ViewModel geben. Die Composable muss nicht wissen, ob die Artikelliste aus Room, Netzwerk-Cache oder Testdaten kommt. Sie rendert den State.
@Composable
fun ArticleListRoute(
viewModel: ArticleListViewModel
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
ArticleListScreen(
state = state,
onQueryChanged = viewModel::onQueryChanged,
onFavoritesOnlyChanged = viewModel::onFavoritesOnlyChanged
)
}
Eine gute Entscheidungsregel lautet: Nutze combine, wenn du aus mehreren aktuellen Zuständen einen neuen Zustand ableiten willst. Nutze zip, wenn Werte bewusst paarweise zusammengehören. Nutze merge, wenn mehrere Quellen gleichartige Ereignisse auslösen dürfen.
Eine typische Stolperfalle ist, zip für UI-State zu verwenden, weil es auf den ersten Blick nach Zusammenführen klingt. Das kann dazu führen, dass dein Screen nicht aktualisiert wird, obwohl eine Quelle neue Werte liefert. Beispiel: Der Suchtext ändert sich fünfmal, aber die Artikelliste liefert in dieser Zeit keinen neuen Wert. Mit zip würden diese Suchänderungen nicht wie erwartet jeweils eine neue Anzeige erzeugen. Mit combine passiert genau das.
Eine zweite Stolperfalle ist zu viel Logik in der Composable. Wenn du mehrere collectAsState-Aufrufe machst und dann direkt im UI-Code filterst, sortierst und Sonderfälle behandelst, wird der Screen schwer testbar. Der bessere Ort ist das ViewModel: Dort kannst du mit Unit-Tests prüfen, dass aus bestimmten Flow-Werten der richtige UiState entsteht. Die UI bleibt einfacher, und Code-Reviews können klarer beurteilen, ob Datenfluss und Rendering getrennt sind.
Für Tests kannst du kleine Flows mit festen Werten verwenden und prüfen, was aus combine herauskommt. Noch wertvoller ist ein Test für Änderungen über Zeit: erst leere Suche, dann Suchtext, dann Favoritenfilter. So siehst du, ob dein State bei jeder relevanten Änderung neu berechnet wird. Beim Debuggen hilft es, an den Kombinationsstellen kurz zu loggen oder Breakpoints in die Transformationsfunktion zu setzen. Du erkennst dann schnell, welche Quelle den neuen State ausgelöst hat.
Fazit
Flow Combination ist ein Grundwerkzeug für moderne Android-Architektur, weil deine UI selten aus nur einer Datenquelle lebt. Baue dir die Gewohnheit auf, Rohdatenströme im ViewModel zu einem klaren UiState zu formen: meistens mit combine, gezielt mit zip, und für gleichartige Ereignisse mit merge. Prüfe dein Verständnis praktisch, indem du einen bestehenden Screen nimmst, seine Datenquellen aufschreibst und dann testest, ob jede Änderung genau den State erzeugt, den Compose anzeigen soll.