Kotlin Flow: asynchrone Datenströme
1. Überblick
Eine suspend-Funktion gibt nur EIN Wert zurück. Wenn Sie mehrere Werte über die Zeit hinweg benötigen – wie neue Bestellungen, Zahlungsstatus oder Aktienkurse – benötigen Sie einen Datenstrom. Kotlin Flow löst genau dieses Problem.
Dieser Artikel erklärt, was Flow ist und wie Sie Daten mit Operatoren transformieren. Danach lernen Sie, wie Sie mit flowOn zwischen Threads wechseln, Fehler behandeln, StateFlow und SharedFlow verwenden und Tests für Flow schreiben.
2. Abhängigkeiten
Flow befindet sich in der kotlinx-coroutines-Bibliothek. Dieser Artikel geht davon aus, dass Ihr Maven-Projekt bereits mit kotlin-maven-plugin konfiguriert ist. Sie müssen nur die folgenden Abhängigkeiten hinzufügen:
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core</artifactId>
<version>1.10.2</version>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-test</artifactId>
<version>1.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Wenn Sie Spring Boot 3.x verwenden, wird die Coroutines-Version bereits über BOM verwaltet, sodass Sie das <version>-Tag weglassen können.
3. Ihr erster Flow
3.1. Flow erstellen und sammeln
Der einfachste Weg, einen Flow zu erstellen, ist die Verwendung des flow { }-Builders und das Aufrufen von emit() für jeden Wert:
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
data class Order(val id: Int, val amount: Long)
fun orderStream(): Flow<Order> = flow {
println("Starte Bestellungsversand")
for (id in 1..3) {
delay(100) // Wartet auf Daten vom System (simuliert)
emit(Order(id, id * 50_000L))
}
}
fun main() = runBlocking {
val orders = orderStream()
println("Flow erstellt")
orders.collect { println("Empfangen: $it") }
}
Erklärung:
orderStream()ist keine suspend-Funktion. Sie gibt nur eine Beschreibung des Datenstroms zurück.- Innerhalb von
flow { }können Sie suspend-Funktionen wiedelay()aufrufen. collectist ein Terminal-Operator. Nur wenn Sie ihn aufrufen, wird der Code innerhalb vonflow { }ausgeführt.
Ausgabe:
Flow erstellt
Starte Bestellungsversand
Empfangen: Order(id=1, amount=50000)
Empfangen: Order(id=2, amount=100000)
Empfangen: Order(id=3, amount=150000)
Die Zeile “Flow erstellt” erscheint vor “Starte Bestellungsversand”. Dies zeigt, dass das Erstellen eines Flow nichts auslöst.
3.2. Flow ist “cold”
Der mit flow { } erstellte Flow ist ein Cold Stream. Jedes Mal, wenn collect aufgerufen wird, läuft der gesamte Code-Block von vorne aus:
fun main() = runBlocking {
val orders = orderStream()
orders.collect { }
orders.collect { }
}
Starte Bestellungsversand
Starte Bestellungsversand
Hinweis: Wenn der
flow { }-Block eine API aufruft oder in eine Datenbank schreibt, wird jedercollect-Aufruf sie erneut von Anfang an ausführen. Dies ist die Quelle vieler „Warum wird die API zweimal aufgerufen?“-Bugs. Wenn Sie eine Quelle für mehrere Collector gemeinsam nutzen möchten, sehen Sie sich Abschnitt 6 an.
4. Transformationsoperatoren
4.1. Intermediate-Operatoren
Flow hat vertraute Collection-Operatoren wie map, filter, take, onEach… Diese Operatoren sind Intermediate-Operatoren. Sie geben einen neuen Flow zurück und sind ebenfalls faul wie der ursprüngliche Flow.
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
fun main() = runBlocking {
orderStream()
.filter { it.amount >= 100_000 }
.map { "Bestellung #${it.id}: ${it.amount} VND" }
.collect { println(it) }
}
Starte Bestellungsversand
Bestellung #2: 100000 VND
Bestellung #3: 150000 VND
Jedes Element durchläuft die gesamte filter → map → collect-Kette, dann wird das nächste Element ausgegeben. Flow läuft standardmäßig sequenziell innerhalb derselben Coroutine.
Im Gegensatz zu map bei Collections ist das Lambda in map von Flow eine suspend-Funktion. Daher können Sie asynchrone Funktionen direkt darin aufrufen:
suspend fun fetchCustomerName(orderId: Int): String {
delay(50)
return "Kunde $orderId"
}
orderStream()
.map { fetchCustomerName(it.id) }
.collect { println(it) }
4.2. Andere Terminal-Operatoren
Neben collect können Sie andere Terminal-Operatoren verwenden, um Ergebnisse direkt zu erhalten:
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.flow.toList
fun main() = runBlocking {
val all = orderStream().toList()
val firstBig = orderStream().first { it.amount > 60_000 }
val revenue = orderStream().fold(0L) { acc, order -> acc + order.amount }
println("Anzahl Bestellungen: ${all.size}, erste große Bestellung: #${firstBig.id}, Umsatz: $revenue")
}
...
Anzahl Bestellungen: 3, erste große Bestellung: #2, Umsatz: 300000
first { } bricht den Flow ab, sobald ein passendes Element gefunden wird. Daher wird Bestellung #3 in diesem collect-Aufruf nie ausgegeben.
5. Thread-Wechsel mit flowOn
Standardmäßig wird der Code innerhalb von flow { } im Kontext der Coroutine ausgeführt, die collect aufgerufen hat. Um die Datenausgabe auf einen anderen Thread zu verschieben – etwa zum Lesen von Dateien oder Datenbanken – verwenden Sie flowOn:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
data class Customer(val name: String)
fun loadCustomers(): Flow<Customer> = flow {
println("Lese Daten auf ${Thread.currentThread().name}")
listOf("An", "Bình").forEach { emit(Customer(it)) }
}.flowOn(Dispatchers.IO)
fun main() = runBlocking {
loadCustomers().collect {
println("Verarbeite ${it.name} auf ${Thread.currentThread().name}")
}
}
Lese Daten auf DefaultDispatcher-worker-1
Verarbeite An auf main
Verarbeite Bình auf main
flowOn beeinflusst nur den Upstream-Teil, also die Operatoren vor ihm. Der collect-Teil wird immer noch im Kontext des Aufrufers ausgeführt. Dieses Prinzip heißt Kontexterhaltung.
Hinweis: Verwenden Sie nicht
withContext, umemit()innerhalb vonflow { }zu umhüllen. Der folgende Code wirftIllegalStateException: Flow invariant is violated:flow { withContext(Dispatchers.IO) { emit(1) } // FALSCH }Verwenden Sie immer
flowOn. Wenn Sie wirklich von mehreren verschiedenen Coroutinen ausgeben müssen, verwenden SiechannelFlow { }.
6. Fehlerbehandlung
6.1. catch und onCompletion
Exceptions in Flow propagieren wie normaler Code nach unten zu collect. Der catch-Operator ermöglicht es Ihnen, Fehler abzufangen und einen alternativen Wert auszugeben:
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onCompletion
fun paymentStatus(): Flow<String> = flow {
emit("PENDING")
throw IllegalStateException("Zahlungsgateway-Timeout")
}
fun main() = runBlocking {
paymentStatus()
.catch { e -> emit("FAILED: ${e.message}") }
.onCompletion { cause -> println("Abgeschlossen, Ursache=$cause") }
.collect { println(it) }
}
PENDING
FAILED: Zahlungsgateway-Timeout
Abgeschlossen, Ursache=null
Erklärung:
catchfängt nur Fehler aus dem Upstream ab. Exceptions, die im Lambda voncollectgeworfen werden, werden von ihm nicht abgefangen.onCompletionsteht nachcatch. Der Fehler wurde bereits im vorherigen Schritt behandelt, daher istcausenull. Wenn Sie die beiden Operatoren tauschen, sehen Sie, dasscausedie Exception enthält.
6.2. retry
Bei vorübergehenden Fehlern wie Netzwerkausfall können Sie retry verwenden. Dieser Operator sammelt den Upstream-Flow von Anfang an:
import java.io.IOException
import kotlinx.coroutines.flow.retry
fun main() = runBlocking {
var attempts = 0
flow {
attempts++
if (attempts < 3) throw IOException("Verbindung verloren")
emit("OK nach $attempts Versuchen")
}
.retry(3) { e -> e is IOException }
.collect { println(it) }
}
OK nach 3 Versuchen
Das Prädikat { e is IOException } ermöglicht es Ihnen, nur bei Netzwerkfehlern erneut zu versuchen. Geschäftsfehler wie IllegalArgumentException werden nicht erneut versucht. Im Prädikat können Sie delay() aufrufen, um einen Backoff zu erstellen.
7. StateFlow und SharedFlow: Hot Streams
flow { } ist kalt. Aber oft benötigen Sie eine Datenquelle, die unabhängig vom Collector existiert – etwa einen Warenkorb-Status oder Systemereignisse. Dann benötigen Sie einen Hot Stream.
7.1. StateFlow
StateFlow hält immer einen aktuellen Wert. Ein neuer Collector erhält diesen Wert sofort:
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class CartService {
private val _total = MutableStateFlow(0L)
val total: StateFlow<Long> = _total.asStateFlow()
fun add(price: Long) = _total.update { it + price }
}
fun main() = runBlocking {
val cart = CartService()
val job = launch {
cart.total.collect { println("Gesamtsumme im Warenkorb: $it") }
}
delay(50)
cart.add(100_000)
delay(50)
cart.add(250_000)
delay(50)
job.cancel()
}
Gesamtsumme im Warenkorb: 0
Gesamtsumme im Warenkorb: 100000
Gesamtsumme im Warenkorb: 350000
Wichtige Punkte:
- Die Eigenschaft
_totalistprivateund beschreibbar. Nach außen hin sehen Sie nur den schreibgeschütztenStateFlow. Dies ist das Standard-Muster für die Kapselung von Zustand. update { }aktualisiert atomar und ist sicher, wenn mehrere Threadsaddaufrufen.StateFlowendet nie. Daher müssen Sie den Job stornieren, sonst wartetrunBlockingfür immer.StateFlowignoriert Werte, die mit dem aktuellen Wert identisch sind. Es wird auch zusammengefasst: Ein langsamer Collector sieht nur den neuesten Wert, nicht die Zwischenschritte.
7.2. SharedFlow
SharedFlow eignet sich für Ereignisse, wie „Bestellung wurde bezahlt”. Hier hat jede Ausgabe eine Bedeutung und es gibt kein Konzept eines „aktuellen Werts”:
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
class PaymentService {
private val _events = MutableSharedFlow<String>()
val events: SharedFlow<String> = _events
suspend fun pay(orderId: Int) {
_events.emit("Bestellung #$orderId wurde bezahlt")
}
}
fun main() = runBlocking {
val payments = PaymentService()
payments.pay(0) // noch keine Listener -> verloren
val job = launch { payments.events.collect { println("Email: $it") } }
delay(50)
payments.pay(1)
payments.pay(2)
delay(50)
job.cancel()
}
Email: Bestellung #1 wurde bezahlt
Email: Bestellung #2 wurde bezahlt
Mit Standardkonfiguration (replay = 0) gehen Ereignisse, die ausgegeben werden, bevor es Subscriber gibt, verloren. Wenn ein später einsteigender Collector die letzten Ereignisse empfangen muss, verwenden Sie MutableSharedFlow<String>(replay = 1).
Sie können einen Cold Flow auch in einen Hot Flow umwandeln, indem Sie stateIn(scope, SharingStarted.WhileSubscribed(5_000), initialValue) oder shareIn(...) verwenden. Dann teilen mehrere Collector einen Upstream-Lauf.
8. Flow-Tests
Die kotlinx-coroutines-test-Bibliothek stellt runTest bereit. Diese Funktion überspringt delay mit virtueller Zeit, sodass Tests fast sofort ausgeführt werden:
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class OrderFlowTest {
@Test
fun `behält nur Bestellungen ab 100.000 VND`() = runTest {
val ids = orderStream()
.filter { it.amount >= 100_000 }
.map { it.id }
.toList()
assertThat(ids).containsExactly(2, 3)
}
@Test
fun `Zahlungsfehler wird in FAILED-Status umgewandelt`() = runTest {
val states = paymentStatus()
.catch { emit("FAILED") }
.toList()
assertThat(states).containsExactly("PENDING", "FAILED")
}
@Test
fun `Warenkorb summiert Werte auf`() = runTest {
val cart = CartService()
cart.add(100_000)
cart.add(250_000)
assertThat(cart.total.value).isEqualTo(350_000L)
}
}
Bei endlichen Flows ist toList() der einfachste Weg, um alle ausgegebenen Werte zu überprüfen. Mit StateFlow lesen Sie .value direkt. Rufen Sie toList() auf StateFlow nicht auf, da dieser Flow nie endet und der Test hängen bleibt.
9. Häufige Fehler
- Code nach
collectwird nicht ausgeführt.collectist eine suspend-Funktion und gibt nur zurück, wenn der Flow endet. MitStateFlow/SharedFlowgeschieht das nie. Setzen Sie jeden unendlichencollectin sein eigeneslaunch. - API wird mehrfach unbeabsichtigt aufgerufen. Jeder Collector eines Cold Flow führt Upstream erneut aus. Verwenden Sie
shareIn/stateIn, wenn Sie teilen müssen. withContextumemit()verwenden. Ersetzen Sie es durchflowOnoderchannelFlow.- Denken, dass
catchalle Fehler abfängt. Dieser Operator fängt nur Upstream-Fehler ab. Wenn Sie Fehler im Verarbeitungsteil abfangen möchten, verschieben Sie die Logik inonEach { }vorcatchund rufen danncollect()ohne Parameter auf. StateFlowfür Ereignisse verwenden. Zwei identische Ereignisse nacheinander werden zu einem zusammengeführt. Verwenden SieSharedFlowfür Ereignisse.
Hinweis: Sie sollten Flow nicht verwenden, wenn Sie nur ein Ergebnis haben. Eine einfache
suspend fun findOrder(id: Int): Orderist viel einfacher und lesbarer alsFlow<Order>, das nur ein Element ausgibt.
10. Fazit
In diesem Artikel haben Sie gesehen, dass Flow ein asynchroner Datenstrom ist, kalt und faul. Sie haben gelernt, wie Sie Daten mit map/filter transformieren, sicher mit flowOn zwischen Threads wechseln und Fehler mit catch, retry, onCompletion behandeln. Mit der Notwendigkeit, Zustand und Ereignisse zu teilen, sind StateFlow und SharedFlow die zwei wichtigsten Tools. runTest hilft Ihnen, diese schnell und zuverlässig zu testen.
Von hier aus können Sie fortgeschrittene Operatoren wie combine, flatMapLatest, debounce oder buffer ausprobieren, um komplexere Datenpipelines zu erstellen.