Flow Builder in Kotlin Flow
Flow Builder erzeugen Datenströme aus suspendierbarer Logik oder Callback-APIs. Du lernst, wann flow, callbackFlow und channelFlow passen.
Wenn du in einer Android-App Daten nicht nur einmal laden, sondern fortlaufend beobachten willst, brauchst du einen sauberen Stream. Flow Builder sind die Stellen, an denen so ein Stream entsteht: aus suspendierbarer Kotlin-Logik, aus Datenbankabfragen, aus Netzwerk-Polling oder aus Callback-APIs wie Standort, Sensoren und Listenern.
Was ist das?
Flow Builder sind Funktionen, mit denen du einen Flow erzeugst. Ein Flow ist ein asynchroner Datenstrom aus Kotlin Coroutines. Er kann null, einen oder viele Werte nacheinander ausgeben und dabei suspendierbare Arbeit ausführen. In Android hilft dir das besonders in der Data Layer und im ViewModel: Repositorys liefern Streams, ViewModels bereiten sie für die UI auf, und Compose sammelt sie als State ein.
Der wichtigste Builder ist flow { ... }. Du nutzt ihn, wenn du Werte Schritt für Schritt erzeugst und mit emit() ausgeben willst. Typische Fälle sind: Daten aus einer suspendierenden Funktion laden, einen Cache-Wert zuerst senden und danach einen frischen Wert nachladen, oder mehrere Berechnungsschritte als Stream modellieren.
callbackFlow { ... } ist für APIs gedacht, die nicht suspendieren, sondern mit Callbacks arbeiten. Viele Android- und Google-Play-Services-APIs melden Ergebnisse über Listener. Mit callbackFlow kannst du daraus einen Flow machen, ohne die Callback-API im Rest deiner App zu verteilen.
channelFlow { ... } ist ähnlich, aber stärker auf Nebenläufigkeit ausgerichtet. Du kannst darin aus mehreren Coroutines senden, etwa wenn mehrere Quellen parallel Werte liefern. Für Anfänger ist wichtig: Nutze channelFlow nicht als Standardwerkzeug. Es ist sinnvoll, wenn wirklich mehrere Sender innerhalb des Builders aktiv sind.
Das mentale Modell ist: Ein Builder beschreibt, wie Werte produziert werden. Der Flow selbst ist aber nur die Beschreibung. Bei einem kalten Flow startet die Arbeit erst, wenn jemand den Flow sammelt, also collect aufruft oder ihn in Compose beobachtet. Dadurch bleibt die Erzeugung kontrollierbar und gut testbar.
Wie funktioniert es?
Bei flow { ... } läuft der Code im Builder sequenziell. Du kannst suspendierende Funktionen aufrufen und anschließend Werte mit emit(value) senden. Der Collector bekommt diese Werte in derselben Reihenfolge. Wenn eine Exception entsteht, wird der Flow abgebrochen, sofern du sie nicht mit Operatoren wie catch behandelst.
Ein einfaches Repository könnte zuerst lokale Daten und danach aktualisierte Daten senden. Damit modellierst du nicht nur ein Ergebnis, sondern den Verlauf: vorhandener Zustand, Ladezustand, frischer Zustand oder Fehler. Genau diese Denkweise passt gut zu moderner Android-Architektur, weil die UI nicht raten muss, wann sich Daten ändern.
callbackFlow arbeitet intern mit einem Channel. Statt emit() verwendest du meist trySend(value), weil der Callback nicht suspendieren kann. Der Builder bleibt aktiv, solange der Flow gesammelt wird. Am Ende musst du mit awaitClose { ... } aufräumen. Dort entfernst du Listener, stoppst Updates oder schließt Ressourcen. Diese Zeile ist keine Formalität, sondern Teil der Korrektheit.
channelFlow nutzt ebenfalls einen Channel, erlaubt aber zusätzlich, dass du innerhalb des Builders weitere Coroutines startest und aus ihnen sendest. Das ist nützlich, wenn zwei Datenquellen unabhängig voneinander Werte liefern und du sie in einem Stream zusammenführen musst. Trotzdem solltest du zuerst prüfen, ob vorhandene Flow-Operatoren wie combine, merge oder flatMapLatest den Fall klarer ausdrücken. Ein Builder sollte die Quelle erzeugen, nicht jede Transformation ersetzen.
Im Android-Alltag tauchen Flow Builder oft in Repositorys und Data Sources auf. Die Data Layer kapselt Details wie Room, Retrofit, DataStore oder fremde SDKs. Das ViewModel sollte idealerweise nicht wissen, ob die Daten aus einem Listener, einer suspendierenden Funktion oder einer lokalen Datenbank kommen. Es kennt nur den Flow. Diese Trennung macht Tests einfacher und verhindert, dass UI-Code direkt an Android-Callbacks hängt.
Ein weiterer Punkt ist der Coroutine-Kontext. Rechenintensive oder blockierende Arbeit gehört nicht auf den Main Thread. Für suspendierende APIs brauchst du oft keinen eigenen Dispatcher, wenn die API selbst korrekt suspendiert. Für blockierende Legacy-Calls solltest du aber mit withContext(Dispatchers.IO) arbeiten oder die Quelle so bauen, dass sie nicht die UI blockiert. Viele Fehler mit Flow entstehen nicht durch Flow selbst, sondern durch Arbeit im falschen Kontext.
In der Praxis
Angenommen, du hast eine einfache API, die Profilinformationen lädt. Das Repository soll zuerst einen Ladezustand senden und danach entweder Daten oder einen Fehler. Dafür reicht flow:
sealed interface ProfileState {
data object Loading : ProfileState
data class Content(val name: String) : ProfileState
data class Error(val message: String) : ProfileState
}
class ProfileRepository(
private val api: ProfileApi
) {
fun observeProfile(userId: String): Flow<ProfileState> = flow {
emit(ProfileState.Loading)
val profile = api.loadProfile(userId)
emit(ProfileState.Content(profile.name))
}.catch { error ->
emit(ProfileState.Error(error.message ?: "Profil konnte nicht geladen werden"))
}
}
Der Builder macht hier genau eine Sache: Er erzeugt den Stream aus suspendierbarer Logik. Die UI muss nicht wissen, wann geladen wird. Sie sammelt nur Zustände.
Bei einer Callback-API sieht der Code anders aus. Stell dir eine Quelle vor, die Verbindungsänderungen über einen Listener meldet:
interface ConnectionMonitor {
fun addListener(listener: (Boolean) -> Unit)
fun removeListener(listener: (Boolean) -> Unit)
}
class ConnectionRepository(
private val monitor: ConnectionMonitor
) {
fun observeConnection(): Flow<Boolean> = callbackFlow {
val listener: (Boolean) -> Unit = { isConnected ->
trySend(isConnected)
}
monitor.addListener(listener)
awaitClose {
monitor.removeListener(listener)
}
}
}
Die typische Stolperfalle ist hier awaitClose zu vergessen. Dann bleibt der Listener registriert, obwohl der Collector nicht mehr aktiv ist. In einer echten App kann das zu Speicherlecks, unnötigem Akkuverbrauch oder mehrfachen Events führen. Besonders bei Standort, Bluetooth, Sensoren und Firebase-ähnlichen APIs solltest du diesen Punkt im Code-Review bewusst prüfen.
Eine zweite Stolperfalle ist ein zu breiter Builder. Wenn du in flow { ... } viele UI-Entscheidungen, Mapping-Logik und Fehlertexte mischst, wird der Stream schwer testbar. Besser ist: Die Data Source erzeugt rohe Daten, das Repository formt fachliche Zustände, und das ViewModel bereitet sie für die Oberfläche auf. So bleibt jede Schicht verständlich.
Eine praktische Entscheidungsregel hilft dir im Alltag:
Entscheidungsregel
Nutze flow, wenn deine Quelle aus suspendierbarer, sequenzieller Logik besteht. Nutze callbackFlow, wenn du eine Listener- oder Callback-API sauber in einen Flow übersetzen musst. Nutze channelFlow, wenn innerhalb des Builders mehrere Coroutines unabhängig voneinander senden sollen und vorhandene Operatoren den Fall nicht klar genug lösen.
In Compose sammelst du solche Streams typischerweise im ViewModel und stellst sie als State bereit. Wichtig ist dabei, dass der Flow nicht bei jeder Recompositon neu gebaut und gesammelt wird. Erzeuge den Flow in stabilen Schichten wie Repository oder ViewModel. Die Oberfläche sollte nur den aktuellen Zustand darstellen.
Zum Testen kannst du prüfen, ob die erwarteten Werte in der richtigen Reihenfolge kommen. Bei flow ist das meist direkt: sammeln, Liste vergleichen, Fehlerfall auslösen. Bei callbackFlow solltest du zusätzlich testen, ob Listener beim Abbruch entfernt werden. Dafür kannst du in einem Fake-Monitor mitzählen, wie viele Listener registriert sind. Wenn nach dem Abbruch noch ein Listener übrig ist, ist der Builder falsch gebaut.
Fazit
Flow Builder sind ein kleines, aber zentrales Werkzeug für saubere Android-Architektur: Du kapselst Datenquellen, erzeugst nachvollziehbare Streams und hältst Callback-Details aus ViewModel und UI heraus. Übe das Thema, indem du eine suspendierende Funktion mit flow und eine Listener-API mit callbackFlow kapselst, dann prüfst du im Debugger oder Test die Reihenfolge der Werte und das Aufräumen der Ressourcen.