Cold Flow in Kotlin Flow
Cold Flow startet erst beim Sammeln. Du lernst, warum mehrere Collector Arbeit mehrfach auslösen können.
Ein Cold Flow ist ein Flow, dessen Arbeit erst beginnt, wenn du ihn sammelst. Für Android ist das wichtig, weil derselbe Flow je nach Collector mehrfach ausgeführt werden kann: einmal für den Screen, einmal für einen Test, einmal nach einer erneuten Subscription durch den Lebenszyklus. Wenn du dieses Modell sauber verstehst, erkennst du schneller, warum Logs doppelt erscheinen, warum ein Repository mehrfach geladen wird oder warum ein Compose-Screen mehr Arbeit auslöst als erwartet.
Was ist das?
Ein Cold Flow beschreibt in Kotlin Flow einen Datenstrom, der nicht von selbst läuft. Er ist eher eine Beschreibung von Arbeit als eine bereits laufende Arbeit. Du definierst also, was passieren soll: Daten laden, Werte berechnen, Datenbankänderungen beobachten oder Zustände umformen. Ausgeführt wird diese Beschreibung erst, wenn ein Collector den Flow sammelt.
Das mentale Modell ist: Ein Cold Flow ist wie ein Rezept. Solange niemand kocht, passiert nichts. Sobald ein Collector startet, wird das Rezept ausgeführt. Startet ein zweiter Collector, wird das Rezept erneut ausgeführt. Das ist kein Fehler, sondern ein Kernverhalten vieler Flows.
Im Android-Kontext begegnet dir Cold Flow sehr häufig in Repositories und Use Cases. Ein DAO kann einen Flow zurückgeben. Ein Repository kann mit flow { ... } Daten aus einer API laden. Ein ViewModel kann daraus UI-State ableiten. Entscheidend ist dabei nicht nur, dass Werte asynchron ankommen. Entscheidend ist, wann die Arbeit startet und wie oft sie startet.
Dieses Verhalten passt gut zu sauberer Architektur. Ein Repository muss keine Arbeit beginnen, nur weil ein Objekt erstellt wurde. Ein Use Case kann einen Flow zurückgeben, ohne sofort Ressourcen zu verbrauchen. Ein Screen entscheidet über seinen Lebenszyklus, wann er sammelt. Genau diese verzögerte Ausführung meint der Begriff Lazy Execution.
Der Vorteil: Du verschwendest weniger Arbeit, wenn ein Flow nie gebraucht wird. Der Nachteil: Du kannst versehentlich Arbeit wiederholen, wenn mehrere Collector denselben Cold Flow sammeln. Besonders bei Netzwerkzugriffen, Schreiboperationen, teuren Berechnungen oder langen Datenbankabfragen musst du dieses Verhalten bewusst einplanen.
Wie funktioniert es?
Ein Flow besteht aus einem Produzenten und einem Collector. Der Produzent erzeugt Werte, der Collector nimmt sie entgegen. Bei einem Cold Flow wird der Produzent pro Collection gestartet. Collection bedeutet hier: Jemand ruft collect, collectLatest, first, toList oder eine API auf, die intern sammelt.
Wenn du einen Flow mit flow { ... } erstellst, wird der Code im Block nicht sofort ausgeführt. Er läuft erst in einer Coroutine, wenn gesammelt wird. Dabei gilt: Jede neue Collection bekommt ihren eigenen Lauf durch den Block. Wenn im Block ein Log steht, siehst du es pro Collector. Wenn im Block ein API-Aufruf steht, wird er pro Collector erneut ausgeführt.
In Android passiert Collection oft indirekt. In Compose nutzt du zum Beispiel collectAsStateWithLifecycle, um einen Flow als State im UI zu lesen. In klassischen Views nutzt du häufig repeatOnLifecycle, um nur in einem aktiven Lifecycle-Zustand zu sammeln. In Tests rufst du vielleicht first() auf. All das sind Collections. Sie starten bei einem Cold Flow den Produzenten.
Das ist besonders relevant, wenn ein Flow aus mehreren Stufen besteht. Operatoren wie map, filter oder onEach sind ebenfalls meist nur eine Beschreibung. Sie laufen nicht beim Erstellen der Kette, sondern erst beim Sammeln. Wenn du also schreibst, dass ein Flow Werte aus der Datenbank liest, sie umwandelt und dann ins UI bringt, passiert diese gesamte Pipeline pro Collector.
Einsteiger verwechseln häufig zwei Dinge: Flow-Definition und Flow-Ausführung. Die Definition ist der Code, der den Flow baut. Die Ausführung ist die Collection. Du kannst einen Flow an zehn Stellen weiterreichen, ohne dass er arbeitet. Sobald aber zwei Stellen ihn sammeln, kann die Arbeit doppelt laufen.
Für moderne Android-Apps ist das kein Detail, sondern Teil des Designs. Du willst UI-bezogene Collection an den Lifecycle binden, damit keine unnötige Arbeit im Hintergrund weiterläuft. Gleichzeitig willst du teure Arbeit nicht aus Versehen pro UI-Teil erneut starten. Dafür gibt es APIs wie stateIn und shareIn, mit denen du einen Flow in einem passenden Scope teilen kannst. Diese APIs ändern das Verhalten bewusst: Aus einer pro Collector gestarteten Pipeline wird ein gemeinsam genutzter Stream innerhalb eines Scopes.
Trotzdem solltest du nicht reflexartig jeden Flow teilen. Ein Cold Flow ist oft genau richtig. Eine Datenbankabfrage, die leichtgewichtig ist und pro Screen sauber beobachtet wird, kann als Cold Flow gut funktionieren. Ein einmaliger Netzwerkaufruf, der bei drei Collectors dreimal startet, ist dagegen meist ein Warnsignal. Die Frage lautet daher nicht: Ist Cold Flow gut oder schlecht? Die Frage lautet: Soll diese Arbeit pro Collector neu starten?
In der Praxis
Stell dir vor, du baust einen Profil-Screen. Das Repository liefert einen Flow, der Profildaten aus dem Netzwerk lädt. Im ViewModel leitest du daraus UI-State ab. Später sammelt der Screen den State, und ein Test sammelt ihn ebenfalls. Zusätzlich fügst du im UI noch eine zweite Stelle hinzu, die denselben Flow für einen Header sammelt. Wenn der Flow kalt bleibt und den Netzwerkaufruf direkt im Produzenten ausführt, kann dieser Aufruf mehrfach passieren.
Ein bewusst kleines Beispiel:
class ProfileRepository(
private val api: ProfileApi
) {
fun profile(userId: String): Flow<Profile> = flow {
println("Lade Profil fuer $userId")
val profile = api.loadProfile(userId)
emit(profile)
}
}
class ProfileViewModel(
repository: ProfileRepository
) : ViewModel() {
val profileUiState: StateFlow<ProfileUiState> =
repository.profile("42")
.map { profile ->
ProfileUiState.Content(name = profile.name)
}
.catch { error ->
emit(ProfileUiState.Error(error.message ?: "Unbekannter Fehler"))
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ProfileUiState.Loading
)
}
In diesem Beispiel ist repository.profile("42") zunächst ein Cold Flow. Der flow-Block läuft erst, wenn gesammelt wird. Durch stateIn im ViewModel wird daraus ein StateFlow, der innerhalb des viewModelScope gehalten wird. Für das UI ist das oft sinnvoll, weil der Screen einen stabilen Zustand bekommt und der Netzwerkaufruf nicht bei jeder einzelnen UI-Collection neu durch den ursprünglichen Cold-Flow-Block laufen muss.
Wichtig ist aber der Ort dieser Entscheidung. Das Repository beschreibt die Datenquelle. Das ViewModel entscheidet hier, wie der Screen den Zustand hält. Dadurch bleibt die Architektur nachvollziehbar: Das Repository zwingt nicht jeden Aufrufer zu einem geteilten Stream, und das ViewModel schützt die UI vor unnötig wiederholter Arbeit.
Eine praktische Regel lautet: Wenn ein Cold Flow teure, seiteneffektbehaftete oder extern sichtbare Arbeit ausführt, prüfe jede Collection. Teuer ist zum Beispiel ein Netzwerkzugriff, eine große Dateioperation oder eine aufwendige Berechnung. Seiteneffektbehaftet ist Arbeit, die etwas verändert: Analytics senden, Daten schreiben, eine Synchronisierung starten. Extern sichtbar ist Arbeit, die andere Systeme belastet oder Nutzerverhalten messbar verändert.
Eine typische Stolperfalle ist das Sammeln desselben Cold Flow an mehreren Stellen im UI. In Compose kann das passieren, wenn du denselben Flow in zwei Composables separat als State sammelst, statt den bereits abgeleiteten UI-State von oben nach unten weiterzugeben. Dann sieht der Code lokal harmlos aus, aber die Pipeline startet mehrfach. Besser ist meist: Sammle im Screen oder ViewModel einmal den passenden UI-State und gib reine Werte an Kind-Composables weiter.
Eine zweite Stolperfalle liegt in Tests. Wenn du first() mehrfach auf demselben Cold Flow aufrufst, startest du den Produzenten mehrfach. Das ist für viele Tests korrekt, kann aber zu falschen Annahmen führen. Wenn dein Test prüfen soll, ob ein Netzwerkaufruf nur einmal passiert, musst du genau zählen, wie oft der Fake oder Mock aufgerufen wurde. So erkennst du, ob dein Flow geteilt werden sollte oder ob die Mehrfachausführung gewollt ist.
Auch Logs helfen dir beim Lernen. Setze gezielt ein println oder einen Logger direkt in den Produzenten und zusätzlich in onEach. Sammle den Flow anschließend zweimal. Du wirst sehen, dass die Ausgabe pro Collection erscheint. Danach kannst du stateIn oder shareIn im ViewModel ergänzen und beobachten, wie sich das Verhalten ändert. Diese Übung macht den Unterschied zwischen Flow-Definition und Collection sehr greifbar.
In Code-Reviews solltest du bei Cold Flows nach drei Mustern suchen. Erstens: Wird im flow { ... }-Block eine teure Operation ausgeführt? Zweitens: Kann der Flow von mehreren Stellen gesammelt werden? Drittens: Ist klar dokumentiert oder durch den Code erkennbar, ob wiederholte Arbeit gewollt ist? Wenn eine dieser Fragen offen bleibt, lohnt sich eine kurze Klärung, bevor daraus ein Performance- oder Datenproblem wird.
Du musst Cold Flow nicht vermeiden. Du musst ihn lesen können. Viele APIs in Kotlin und Android sind darauf ausgelegt, dass Arbeit erst durch Collection startet. Das ist gut für Lifecycle, Testbarkeit und klare Datenflüsse. Problematisch wird es nur, wenn du Collection als passives Lesen verstehst. Bei einem Cold Flow ist Collection ein Startsignal.
Fazit
Ein Cold Flow ist eine verzögert ausgeführte Datenpipeline: Er beschreibt Arbeit, startet sie aber erst beim Sammeln, und viele Cold Flows starten ihren Produzenten pro Collector erneut. Prüfe dieses Verhalten aktiv in deinem eigenen Code: Sammle einen Flow testweise zweimal, beobachte Logs oder Mock-Aufrufe, kontrolliere im Debugger den Startpunkt des Produzenten und achte im Code-Review auf teure Arbeit in flow { ... }. Wenn du erklären kannst, welche Collection welche Arbeit auslöst, hast du eine zentrale Grundlage für robuste Android-Apps mit Kotlin Flow verstanden.