stateIn und shareIn
Du lernst, wie stateIn und shareIn kalte Flows teilbar machen. Mit Fokus auf Lebensdauer, Replay und Compose-State.
stateIn und shareIn helfen dir, kalte Flows gezielt in geteilte Streams umzuwandeln. Das ist in Android wichtig, weil Daten oft aus Repositorys, Datenbanken, Netzwerkquellen oder Sensoren kommen, aber mehrere Stellen in deiner App dieselben Werte beobachten wollen: ein ViewModel, eine Compose-Oberfläche, ein Test oder ein weiterer Use Case. Ohne kontrolliertes Sharing kann dieselbe teure Arbeit mehrfach starten. Mit kontrolliertem Sharing bestimmst du bewusst, wer Werte wiederverwendet, wie lange der Stream lebt und welche letzten Werte neue Beobachter sofort erhalten.
Was ist das?
Ein Flow ist zuerst einmal eine Beschreibung von Arbeit. Bei einem kalten Flow startet diese Arbeit erst, wenn jemand ihn sammelt. Wenn zwei Collector denselben kalten Flow sammeln, läuft die vorgelagerte Arbeit normalerweise zweimal. Das kann bei einfachen Umwandlungen egal sein, bei Datenbankabfragen, Netzwerk-Polling oder komplexen Berechnungen aber teuer oder sogar falsch werden.
stateIn und shareIn lösen genau dieses Problem. Beide Operatoren nehmen einen kalten Flow und binden ihn an einen CoroutineScope. Dadurch entsteht ein heißer, geteilter Stream. Heiß bedeutet hier: Die Arbeit kann unabhängig von einem einzelnen Collector laufen, solange der gewählte Scope und die gewählte Startstrategie das erlauben.
stateIn verwendest du, wenn du einen aktuellen Zustand modellierst. Das Ergebnis ist ein StateFlow. Ein StateFlow hat immer einen aktuellen Wert, braucht deshalb einen Startwert und eignet sich sehr gut für UI-State im ViewModel. In Compose passt das mental gut zu einer Oberfläche, die immer einen Zustand rendert: lädt, zeigt Daten, zeigt einen Fehler oder wartet auf Eingaben.
shareIn verwendest du, wenn du einen Flow teilen möchtest, ohne daraus zwingend einen Zustand zu machen. Das Ergebnis ist ein SharedFlow. Du entscheidest über replay, wie viele letzte Werte neue Collector sofort erhalten. Das passt zu geteilten Datenströmen, Statusmeldungen oder Ereignissen, bei denen nicht immer ein vollständiger UI-Zustand vorliegen muss.
Der wichtige Gedanke für Anfänger lautet: Du wandelst nicht nur einen Typ in einen anderen Typ um. Du triffst eine Architekturentscheidung über Lebensdauer, Mehrfachnutzung und Speicher von letzten Werten. Genau deshalb gehören stateIn und shareIn in die Roadmap rund um Coroutines, Flow und Background Work.
Wie funktioniert es?
Beide Operatoren brauchen drei zentrale Entscheidungen: den Scope, die Startstrategie und das Replay-Verhalten. Bei stateIn kommt zusätzlich ein Startwert hinzu, weil ein StateFlow nie wertlos ist.
Der Scope bestimmt die Lebensdauer. In einem Android-ViewModel ist viewModelScope meistens die passende Wahl. Dann läuft der geteilte Flow höchstens so lange wie das ViewModel. Das ist ein sinnvoller Standard, weil UI-Daten nicht unkontrolliert über den Screen hinaus weiterarbeiten sollen. In Repositorys solltest du vorsichtiger sein: Ein Repository hat oft keine eigene natürliche Lebensdauer. Wenn du dort einen App-weiten Scope nutzt, muss das fachlich beabsichtigt sein.
Die Startstrategie kommt über SharingStarted. Häufig siehst du SharingStarted.WhileSubscribed(...). Diese Strategie startet die vorgelagerte Arbeit, wenn es aktive Collector gibt, und stoppt sie nach einer konfigurierbaren Verzögerung, wenn niemand mehr zuhört. Diese Verzögerung ist in Android praktisch, weil Konfigurationswechsel, kurze Navigationen oder Compose-Recompositions nicht sofort teure Arbeit stoppen und neu starten sollen.
SharingStarted.Eagerly startet sofort, auch ohne Collector. Das kann sinnvoll sein, wenn Daten bewusst vorbereitet werden sollen. Es kann aber Ressourcen verbrauchen, obwohl niemand die Werte braucht. SharingStarted.Lazily startet erst beim ersten Collector und läuft danach weiter, solange der Scope lebt. Diese Strategie kann leicht länger arbeiten als erwartet.
Replay bedeutet: Wie viele letzte Werte bekommt ein neuer Collector sofort? Bei stateIn ist die Antwort klar: immer den aktuellen Wert. Bei shareIn steuerst du es selbst über replay. replay = 0 heißt, neue Collector bekommen nur zukünftige Werte. replay = 1 heißt, sie bekommen den letzten Wert sofort. Höhere Werte können sinnvoll sein, wenn ein kurzer Verlauf gebraucht wird, erhöhen aber auch Speicherbedarf und können veraltete Ereignisse erneut ausliefern.
Im Alltag taucht das Thema oft im ViewModel auf. Du bekommst einen Flow aus einem Repository, wandelst ihn in UI-State um und stellst ihn als StateFlow bereit. Compose sammelt diesen State dann lifecycle-bewusst, zum Beispiel mit einer passenden collect-Funktion aus den Lifecycle-Compose-Bibliotheken. So bleibt die UI deklarativ: Der Screen liest Zustand und rendert daraus die Oberfläche.
Eine typische Stolperfalle ist, stateIn zu früh oder an der falschen Stelle zu verwenden. Wenn du in jeder Composable oder in jeder Funktion neu stateIn aufrufst, erzeugst du neue geteilte Streams statt einen gemeinsamen. Der Operator gehört meist an eine stabile Stelle mit klarer Lebensdauer, etwa in das ViewModel als Property. Ebenso problematisch ist ein zu hoher Replay-Wert bei Ereignissen wie Toasts oder Navigation. Dann kann ein neuer Collector alte Signale erneut ausführen.
In der Praxis
Stell dir vor, du baust einen Profil-Screen. Das Repository liefert einen kalten Flow mit Profildaten. Der Screen soll den aktuellen Zustand anzeigen. Zusätzlich könnten Tests den ViewModel-State sammeln, und Compose kann bei Lifecycle-Änderungen kurz aus- und wieder einsteigen. Dafür eignet sich stateIn.
data class ProfileUiState(
val isLoading: Boolean = true,
val name: String = "",
val errorMessage: String? = null
)
class ProfileViewModel(
profileRepository: ProfileRepository
) : ViewModel() {
val uiState: StateFlow<ProfileUiState> =
profileRepository.observeProfile()
.map { profile ->
ProfileUiState(
isLoading = false,
name = profile.displayName
)
}
.catch { throwable ->
emit(
ProfileUiState(
isLoading = false,
errorMessage = throwable.message ?: "Profil konnte nicht geladen werden"
)
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 5_000
),
initialValue = ProfileUiState()
)
}
In diesem Beispiel ist der mentale Ablauf klar: observeProfile() bleibt die Quelle. map übersetzt Domänendaten in UI-State. catch sorgt dafür, dass Fehler als Zustand sichtbar werden. stateIn macht daraus einen stabilen StateFlow, den die UI beobachten kann. Der Startwert beschreibt den Ladezustand, bevor der erste echte Wert angekommen ist.
Für Compose ist dieser Aufbau angenehm, weil die Oberfläche nicht wissen muss, ob die Daten aus Room, DataStore, Netzwerk oder Cache stammen. Die Composable liest den State und zeigt passende UI. Wichtig ist dabei, dass du die Sammlung an den Lifecycle koppelst. So vermeidest du unnötige Arbeit, wenn der Screen nicht aktiv ist.
Eine einfache Entscheidungsregel lautet: Nutze stateIn, wenn deine UI jederzeit einen aktuellen Zustand braucht. Nutze shareIn, wenn du einen Flow teilen möchtest, aber kein vollständiger State mit Startwert nötig ist.
Ein Beispiel für shareIn wäre ein Repository, das Verbindungsstatus aus einer externen Quelle beobachtet und mehreren Schichten bereitstellt:
class ConnectivityRepository(
private val monitor: ConnectivityMonitor,
externalScope: CoroutineScope
) {
val connectivity: SharedFlow<ConnectivityStatus> =
monitor.statusChanges()
.shareIn(
scope = externalScope,
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 5_000
),
replay = 1
)
}
Hier sorgt replay = 1 dafür, dass ein neuer Collector den zuletzt bekannten Verbindungsstatus sofort bekommt. Das ist für Statusdaten sinnvoll. Für einmalige Ereignisse wäre replay = 1 dagegen gefährlich, weil alte Ereignisse erneut verarbeitet werden könnten.
Prüfe bei Code-Reviews besonders drei Punkte. Erstens: Ist der Scope fachlich korrekt? Ein ViewModel-State sollte nicht versehentlich in einem App-weiten Scope laufen. Zweitens: Passt die Startstrategie zur UI-Nutzung? WhileSubscribed ist oft ein guter Startpunkt für screenbezogene Daten. Drittens: Ist Replay bewusst gewählt? Ein Replay-Wert ist kein Detail, sondern beeinflusst das Verhalten neuer Collector direkt.
Beim Testen kannst du dein Verständnis gut prüfen. Sammle den StateFlow im Test, erzeuge neue Repository-Werte und kontrolliere, ob der UI-State stabil und in der richtigen Reihenfolge kommt. Teste auch, ob der Startwert sichtbar ist. Bei shareIn lohnt sich ein Test mit einem zweiten Collector: Bekommt er genau die Werte, die dein Replay-Verhalten verspricht? Wenn nicht, ist meistens die Entscheidung über replay, Scope oder Startstrategie unsauber.
Eine weitere Stolperfalle liegt in Nebenwirkungen vor stateIn oder shareIn. Alles, was im vorgelagerten Flow passiert, kann durch Sharing seltener oder länger laufen als bei einem rein kalten Flow. Netzwerkaufrufe, Logging, Datenbankzugriffe oder Analytics sollten deshalb bewusst platziert werden. Wenn ein Flow nur eine reine Transformation enthält, ist das Risiko klein. Wenn er echte Arbeit anstößt, musst du die Lebensdauer aktiv mitdenken.
Für Release-Qualität ist das kein Nebenthema. Falsch geteilte Flows können Akku verbrauchen, Daten mehrfach laden, UI-Zustände flackern lassen oder Speicher länger binden als geplant. Solche Fehler sieht man oft erst bei Rotation, Navigation, Hintergrundwechsel oder langsamen Geräten. Genau dort zeigt sich, ob dein Flow-Design robust ist.
Fazit
stateIn und shareIn sind Werkzeuge, um kalte Flows mit kontrollierter Lebensdauer zu teilen. Nutze stateIn für stabilen UI-State mit aktuellem Wert, shareIn für geteilte Streams mit bewusstem Replay. Übe das Thema an einem kleinen ViewModel: Baue einen kalten Repository-Flow, wandle ihn mit stateIn um, sammle ihn in Compose und schreibe einen Test für Startwert, neue Werte und erneutes Sammeln. Prüfe danach im Code-Review Scope, SharingStarted und replay, denn genau dort entstehen die meisten Fehler.