Kotlin Flow: 异步数据流
1. 总览
一个 suspend 函数只返回一个值。当你需要接收多个值随时间变化时,例如新订单、支付状态或股票价格,你需要一个数据流。Kotlin Flow 正是为此而生。
本文解释什么是 Flow 以及如何使用操作符转换数据。之后你将学会使用 flowOn 切换线程、处理错误、使用 StateFlow 和 SharedFlow,以及为 Flow 编写测试。
2. 安装
Flow 位于 kotlinx-coroutines 库中。本文假设你的 Maven 项目已配置 kotlin-maven-plugin。你只需添加以下依赖项:
<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>
如果你使用 Spring Boot 3.x,coroutines 版本已通过 BOM 管理,所以你可以省略 <version> 标签。
3. 第一个 Flow
3.1. 创建和收集 Flow
创建 Flow 的最简单方法是使用 flow { } 构造器并为每个值调用 emit():
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("开始发送订单")
for (id in 1..3) {
delay(100) // 模拟等待来自系统的数据
emit(Order(id, id * 50_000L))
}
}
fun main() = runBlocking {
val orders = orderStream()
println("已创建 flow")
orders.collect { println("接收: $it") }
}
解释:
orderStream()不是suspend函数。它只返回对数据流的描述。- 在
flow { }内,你可以调用suspend函数如delay()。 collect是一个 terminal operator。只有调用它时,flow { }内的代码才会运行。
结果:
已创建 flow
开始发送订单
接收: Order(id=1, amount=50000)
接收: Order(id=2, amount=100000)
接收: Order(id=3, amount=150000)
“已创建 flow”这一行出现在“开始发送订单”之前。这证明创建 Flow 不会触发任何操作。
3.2. Flow 是“cold”的
flow { } 创建的 Flow 是一个 cold stream。每次 collect 都会从头开始重新运行整个代码块:
fun main() = runBlocking {
val orders = orderStream()
orders.collect { }
orders.collect { }
}
开始发送订单
开始发送订单
注意: 如果
flow { }块调用 API 或写入数据库,每次collect都会重新调用。这是许多“为什么 API 被调用两次”bug 的根源。如果想与多个收集器共享一个源,请参阅第 6 节。
4. 转换操作符
4.1. Intermediate operator
Flow 拥有一组熟悉的操作符,如集合中的 map、filter、take、onEach 等。这些操作符是 intermediate。它们返回一个新的 Flow,也是 lazy 的,就像原始 Flow 一样。
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
fun main() = runBlocking {
orderStream()
.filter { it.amount >= 100_000 }
.map { "订单 #${it.id}: ${it.amount} VND" }
.collect { println(it) }
}
开始发送订单
订单 #2: 100000 VND
订单 #3: 150000 VND
每个元素通过整个 filter → map → collect 链,然后才发送下一个元素。Flow 默认在同一个 coroutine 中顺序运行。
与集合的 map 不同,Flow 的 map 中的 lambda 是 suspend 的。因此你可以直接在其中调用异步函数:
suspend fun fetchCustomerName(orderId: Int): String {
delay(50)
return "客户 $orderId"
}
orderStream()
.map { fetchCustomerName(it.id) }
.collect { println(it) }
4.2. 其他 Terminal operator
除了 collect,你还可以使用其他 terminal operator 来直接获取结果:
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("订单数: ${all.size}, 首个大订单: #${firstBig.id}, 收入: $revenue")
}
...
订单数: 3, 首个大订单: #2, 收入: 300000
first { } 在找到匹配元素时立即取消 Flow。因此订单 #3 在那次 collect 中永远不会被发送。
5. 使用 flowOn 切换线程
默认情况下,flow { } 内的代码在调用 collect 的 coroutine 的 context 中运行。要将数据发送部分推送到另一个线程,例如读取文件或数据库,你可以使用 flowOn:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
data class Customer(val name: String)
fun loadCustomers(): Flow<Customer> = flow {
println("在 ${Thread.currentThread().name} 上读取数据")
listOf("Alice", "Bob").forEach { emit(Customer(it)) }
}.flowOn(Dispatchers.IO)
fun main() = runBlocking {
loadCustomers().collect {
println("在 ${Thread.currentThread().name} 上处理 ${it.name}")
}
}
在 DefaultDispatcher-worker-1 上读取数据
在 main 上处理 Alice
在 main 上处理 Bob
flowOn 只影响 upstream 部分,即它之前的操作符。collect 部分仍在调用者的 context 中运行。这个原则称为 context preservation。
注意: 不要使用
withContext来包裹flow { }内的emit()。下面的代码会抛出IllegalStateException: Flow invariant is violated:flow { withContext(Dispatchers.IO) { emit(1) } // 错误 }始终使用
flowOn。如果你真的需要从多个不同的 coroutine 发送,请使用channelFlow { }。
6. 错误处理
6.1. catch 和 onCompletion
Flow 中的异常向下传播到 collect,就像普通代码一样。catch 操作符允许你拦截错误并发送替代值:
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onCompletion
fun paymentStatus(): Flow<String> = flow {
emit("PENDING")
throw IllegalStateException("支付网关超时")
}
fun main() = runBlocking {
paymentStatus()
.catch { e -> emit("失败: ${e.message}") }
.onCompletion { cause -> println("完成,原因=$cause") }
.collect { println(it) }
}
PENDING
失败: 支付网关超时
完成,原因=null
解释:
catch只捕获来自 upstream 的错误。在collect的 lambda 中抛出的异常不会被它捕获。onCompletion位于catch之后。错误已在前面的步骤中处理,所以cause是null。如果交换两个操作符,你会看到cause包含异常。
6.2. retry
对于临时错误如网络中断,你可以使用 retry。这个操作符从头开始重新 collect upstream Flow:
import java.io.IOException
import kotlinx.coroutines.flow.retry
fun main() = runBlocking {
var attempts = 0
flow {
attempts++
if (attempts < 3) throw IOException("连接丢失")
emit("在 $attempts 次尝试后成功")
}
.retry(3) { e -> e is IOException }
.collect { println(it) }
}
在 3 次尝试后成功
Predicate { e is IOException } 帮助你只重试网络错误。业务错误如 IllegalArgumentException 不会被重试。在 predicate 中,你可以调用 delay() 来创建 backoff。
7. StateFlow 和 SharedFlow:hot stream
flow { } 是 cold 的。但很多时候你需要一个独立于收集器的数据源,例如购物车状态或系统事件。这时你需要一个 hot stream。
7.1. StateFlow
StateFlow 总是保持一个当前值。新的收集器会立即收到该值:
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("购物车总计: $it") }
}
delay(50)
cart.add(100_000)
delay(50)
cart.add(250_000)
delay(50)
job.cancel()
}
购物车总计: 0
购物车总计: 100000
购物车总计: 350000
需要注意的几点:
- 属性
_total是private且可写。外部只能看到只读的StateFlow。这是封装状态的标准模式。 update { }是原子更新,当多个线程同时调用add时是安全的。StateFlow永远不会结束。因此你必须canceljob,否则runBlocking会永久等待。StateFlow跳过与当前值相同的值。它也会 conflate:慢的收集器只能看到最新值,看不到中间的步骤。
7.2. SharedFlow
SharedFlow 适合用于事件,例如“订单已支付”。这里每次发送都有意义,没有“当前值”的概念:
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("订单 #$orderId 已支付")
}
}
fun main() = runBlocking {
val payments = PaymentService()
payments.pay(0) // 还没有人监听 -> 丢失
val job = launch { payments.events.collect { println("邮件: $it") } }
delay(50)
payments.pay(1)
payments.pay(2)
delay(50)
job.cancel()
}
邮件: 订单 #1 已支付
邮件: 订单 #2 已支付
使用默认配置(replay = 0),在没有订阅者时发送的事件会丢失。如果收集器迟到需要接收最近的几个事件,请使用 MutableSharedFlow<String>(replay = 1)。
你也可以使用 stateIn(scope, SharingStarted.WhileSubscribed(5_000), initialValue) 或 shareIn(...) 将 cold Flow 转换为 hot Flow。这样多个收集器会共享一次 upstream 运行。
8. 测试 Flow
kotlinx-coroutines-test 库提供 runTest。这个函数使用虚拟时间跳过 delay,所以测试运行几乎是瞬间的:
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 `只保留 100000 及以上的订单`() = runTest {
val ids = orderStream()
.filter { it.amount >= 100_000 }
.map { it.id }
.toList()
assertThat(ids).containsExactly(2, 3)
}
@Test
fun `支付错误转换为 FAILED 状态`() = runTest {
val states = paymentStatus()
.catch { emit("FAILED") }
.toList()
assertThat(states).containsExactly("PENDING", "FAILED")
}
@Test
fun `购物车累加值`() = runTest {
val cart = CartService()
cart.add(100_000)
cart.add(250_000)
assertThat(cart.total.value).isEqualTo(350_000L)
}
}
对于有限的 Flow,toList() 是检查所有发出值的最简单方法。对于 StateFlow,你直接读取 .value。不要在 StateFlow 上调用 toList(),因为这个 Flow 永不结束,测试会挂起。
9. 常见错误
collect之后的代码不运行。collect是一个suspend函数,只有在 Flow 结束时才返回。对于StateFlow/SharedFlow,这永远不会发生。将每个无限的collect放在单独的launch中。- 意外多次调用 API。 cold Flow 的每个收集器都会重新运行 upstream。当需要共享时,使用
shareIn/stateIn。 - 在
emit周围使用withContext。 改用flowOn或channelFlow。 - 认为
catch捕获所有错误。 这个操作符只捕获 upstream 错误。如果想在处理部分捕获错误,将逻辑移到catch前的onEach { },然后调用无参数的collect()。 - 对事件使用
StateFlow。 两个连续相同的事件会被合并为一个。对事件使用SharedFlow。
注意: 当你只有一个结果时,不应该使用 Flow。一个简单的
suspend fun findOrder(id: Int): Order函数比只发送一个元素的Flow<Order>更简单易读。
10. 总结
在本文中,你看到了 Flow 是异步数据流,是 cold 和 lazy 的。你学会了如何使用 map/filter 转换数据,使用 flowOn 安全地切换线程,以及使用 catch、retry、onCompletion 处理错误。对于共享状态和事件的需求,StateFlow 和 SharedFlow 是两个主要工具。runTest 帮助你快速稳定地测试它们。
从这里,你可以尝试高级操作符如 combine、flatMapLatest、debounce 或 buffer,以构建更复杂的数据管道。