异常透明性
官方不鼓励我们在 Flow 里使用 try-catch 捕获异常,而是推荐用 catch 操作符,为什么呢?
原因在于一个核心原则:异常透明性 ,即上游的生产者不应该捕获或处理下游消费者抛出的异常。
先来看一段代码:
kotlin
fun main(): Unit = runBlocking {
val flow = flow {
for (i in 1..5) {
delay(100)
emit(i)
}
}
flow.collect { userId ->
// 模拟网络操作
val userInfo = getUserInfo(userId)
println(userInfo)
}
}
/**
* 模拟获取用户信息
*/
suspend fun getUserInfo(userId: Int): String = withContext(Dispatchers.IO) {
val userNameList = listOf("Alice", "Bob", null, "Charlie", null, "Eve") // null 模拟获取失败
delay(1000)
var userName: String? = null
repeat(userId % 10) {
userName = userNameList.random()
}
if (userName == null) {
throw Exception("Failed to get user info for userId: $userId")
} else {
"User: $userName, Id: $userId"
}
}
这段代码在运行时,有概率会出现 Exception 异常。
如果想要捕获异常,只需使用 try-catch 包裹住整个 collect 调用即可(当然也可以包住 collect 的代码块):
kotlin
try {
flow.collect { userId ->
// 模拟网络操作
val userInfo = getUserInfo(userId)
println(userInfo)
}
} catch (e: Exception) {
println("Exception in collect: ${e.message}")
}
运行结果可能为:
sql
User: Charlie, Id: 1
User: Eve, Id: 2
Exception in collect: Failed to get user info for userId: 3
实际上,flow 代码块中也可能会抛出异常,比如访问数据库或进行网络请求时,我们也需要使用 try-catch 进行捕获:
kotlin
val flow = flow {
try {
for (i in 1..5) {
delay(100)
emit(i)
}
} catch (e: Exception) {
println("Exception in flow: ${e.message}")
}
}
运行结果可能为:
less
User: Charlie, Id: 1
Exception in flow: Failed to get user info for userId: 2
此时你会发现一个问题:collect 处的异常竟然没有被捕获到。
我来给你解释一下原因:
首先,调用 collect 函数时会执行 flow 的代码块,当调用 emit 发射数据时,实际上会去执行 collect 函数的代码块。
kotlin
// collect 的完整写法
flow.collect(object : FlowCollector<Int> {
override suspend fun emit(value: Int) {
// 模拟网络操作
val userInfo = getUserInfo(value)
println(userInfo)
}
})
而异常默认会从调用栈向上抛,所以会从下游的 collect 代码块抛出,到上游的 emit 的调用处被捕获,异常并没有再向上抛,自然 collect 的调用处就捕获不到。
当然,你可以在 flow 中将异常再次抛出:
kotlin
val flow = flow {
try {
for (i in 1..5) {
delay(100)
emit(i)
}
} catch (e: Exception) {
println("Exception in flow: ${e.message}")
throw e
}
}
虽然这样也行,但关键在于消费者在处理数据失败时,生产不应该、也不知道该怎么捕获和处理这个异常。
如果上游的 try-catch 捕获到了这个它本不应该知道的下游异常,并吞掉了这个异常。这就破坏了异常的可见性,下游的 try-catch 将永远无法收到这个异常。
正确使用 try-catch
解决方法也很简单,就是不要用 try-catch 包裹 emit() 调用。
kotlin
val flow = flow {
for (i in 1..5) {
// 只是捕获生产者的逻辑异常
try {
delay(100) // 模拟数据生产逻辑
} catch (e: Exception) {
println("Exception in flow: ${e.message}")
}
emit(i) // emit() 在 try 之外
}
}
所谓的 "不要在 Flow 里用 try-catch" 的真正含义也是这个,如果非要包住 emit,为了保证下游异常的可见性,你必须在 catch 块中重新抛出异常。
中间操作符的异常传递
中间操作符以 map 为例,有两个问题:下游抛出的异常会经过 map 的代码块吗?map 代码块中抛出的异常会到哪?
kotlin
flow {
for (i in 1..5) {
emit(i)
}
}.map {
it + 1
}.collect {
throw Exception("Failed to collect")
}
首先得清楚 map 的工作原理:它会返回一个新的 Flow,这个 Flow 对象会收集上游的数据,经过数据转换后(应用传入的 transform 块的逻辑)并发射到下游,下游的 collect 会启动这个 Flow 的收集。
按照前面说的:下游的异常会抛到上游 emit 的调用处。而 map 的代码块只是提供了 map 转换的逻辑,对于问题 1,答案是不会 ,下游的异常会沿着 emit 的调用链上升,不会经过 map 的转换逻辑。
接着异常会继续向上抛,先到上游的 emit 调用处,然后到 map 的 collect 调用处,最后到最外层的 collect。对于问题 2,答案是向上游传递 。到达最上游之后,会沿着 collect 调用链来到最外层的 collect 调用处。
less
flow1 = flow{
emit()
}
flow2 = flow{
flow1.collect{
emit()
}
}
flow2.collect{
throw Exception
}
transform 则不同,因为它的 emit 是由我们手动调用的,所以异常会经过 transform 内部,当然我们不会使用 try-catch 包裹 emit 捕获异常,因为这同样会破坏异常的透明性。
Flow 的异常策略
catch 操作符的效果是可以捕获到上游 Flow 的异常,捕获不到下游的异常。
更具体点说,catch 会捕获 flow {...} 代码块中的异常,但除了 emit() 抛出的异常和特殊异常 CancellationException。
kotlin
fun main(): Unit = runBlocking {
// catch 捕获不到下游的异常
try {
flow {
for (i in 1..5) {
delay(100)
emit(i)
}
}.catch { // catch-1
println("Exception in catch-1: ${it.message}")
}.collect { id ->
val userInfo = getUserInfo(id)
println(userInfo)
}
} catch (e: Exception) {
println("Exception in outer catch: ${e.message}")
}
println("----------")
// catch 捕获到了上游的异常
try {
flow {
for (i in 1..5) {
if (i == 3) {
throw Exception("error")
}
delay(100)
emit(i)
}
}.catch { // catch-1
println("Exception in catch-1: ${it.message}")
}.collect { id ->
println("User Id: $id")
}
} catch (e: Exception) {
println("Exception in outer catch: ${e.message}")
}
}
运行结果可能为:
sql
User: Bob, Id: 1
Exception in outer catch: Failed to get user info for userId: 2
----------
User Id: 1
User Id: 2
Exception in catch-1: error
另外,当存在多个 catch 时,第一个 catch 会捕获上游的异常,其余的 catch 会捕获其前一个 catch 之间的异常。
kotlin
fun main(): Unit = runBlocking {
val flow = flow {
for (i in 1..5) {
delay(100)
if (i == 3) {
throw Exception("Failed to emit $i")
}
emit(i)
}
}.catch {
println("catch-1 caught: $it")
throw Exception("error")
}.catch {
println("catch-2 caught: $it")
}
flow.collect { userId ->
println("User Id: $userId")
}
}
运行结果:
less
User Id: 1
User Id: 2
catch-1 caught: java.lang.Exception: Failed to emit 3
catch-2 caught: java.lang.Exception: error
修复与接管:try-catch 与 catch
当上游的生产者 Flow 出现异常,但有能力从内部进行恢复(如提供默认值)并继续生产时,应该在 flow {...} 内部使用 try-catch 来修复。
但无能为力时,只好使用 catch 操作符。当异常到达 catch 时,意味着上游的 Flow 已经结束了。所以 catch 的作用不是修复 Flow,而是接管后续的数据流,负责后续数据的发送。
kotlin
fun main(): Unit = runBlocking {
flow {
emit(1)
emit(2)
throw IOException("Upstream died")
}.catch { e ->
// 模拟日志输出
println("Upstream failed: $e")
emit(-1) // 成为新的生产者,发送一个错误状态
}.collect { value ->
println(value)
}
}
当然,在 catch 中我们一般做不到数据的生产,更多是进行收尾工作,并将错误信息发射给下游。
重启:使用 retry / retryWhen
retry 的核心原理和 catch 相通,retry 面对上游异常的行为是重启上游。
简单来说,就是再次调用上游 Flow 的 collect 函数,创建一个全新的上游 Flow 对象。实际上,这种调用会替换掉整个上游 Flow 链条,而这种替换对下游来说是透明的。
kotlin
fun main(): Unit = runBlocking {
try {
flow {
for (i in 1..5) {
delay(500)
if (i >= 4) {
throw IOException("Failed")
}
emit(i)
}
}.map {
it * it
}.retry(3) // 最多重试3次,默认重试次数为 Long.MAX_VALUE
.collect { println(it) }
} catch (e: Exception) {
println("Catch Exception: $e")
}
}
运行结果:
less
1
4
9
1
4
9
1
4
9
1
4
9
Catch Exception: java.io.IOException: Failed
另外,我们可以填写第二个参数 predicate。这样,只有当前重试次数小于指定最多重试次数,并且 lambda 返回 true 才会重试。
kotlin
fun main(): Unit = runBlocking {
try {
flow {
for (i in 1..5) {
delay(500)
if (i >= 4) {
throw IOException("Failed")
}
emit(i)
}
}.map {
it * it
}.retry(3) { e -> // 最多重试3次
(e is IOException) // 只有 IO 异常才重试
}.collect { println(it) }
} catch (e: Exception) {
println("Catch Exception: $e")
}
}
retryWhen 只是 retry 的综合版本,它将上游的异常和当前重试过的次数作为了 lambda 的参数。
kotlin
fun main(): Unit = runBlocking {
try {
flow {
for (i in 1..5) {
delay(500)
if (i >= 4) {
throw IOException("Failed")
}
emit(i)
}
}.map {
it * it
}.retryWhen { cause, attempt ->
// 只有IO 异常且尝试次数小于3时重试
cause is IOException && attempt < 3
}.collect { println(it) }
} catch (e: Exception) {
println("Catch Exception: $e")
}
}
监听 Flow 的完整生命周期
onStart:在开始之前
onStart 用于在上游 Flow 开始之前执行逻辑。准确来说,是下游调用 collect 之后,上游 flow {...} 代码块执行之前。
kotlin
fun main(): Unit = runBlocking {
flow {
println("Flow started")
emit(1)
}.onStart {
println("Starting flow collection")
}.collect {
println("Collected $it")
}
}
运行结果:
less
Starting flow collection
Flow started
Collected 1
注意:
-
同时调用多个
onStart,执行顺序是从下游到上游。 -
onStart中抛出的异常无法被上游包裹了emit的try-catch捕获,因为它早在上游发送第一条数据之前就执行了。异常能被下游的
catch操作符捕获,catch捕获所有上游抛出的异常。
onCompletion:流的 finally 块
onCompletion 用于在上游 Flow 终止时执行逻辑。
无论上游是正常结束还是因异常而终止,onCompletion 都会被调用。正常结束时,其 lambda 的参数值为 null;异常终止时,参数值是上游抛出的异常。
kotlin
fun main(): Unit = runBlocking {
flow {
emit(1)
throw RuntimeException("Demo error")
}.onCompletion { cause ->
if (cause != null) {
println("Flow completed with error: $cause")
} else {
println("Flow completed successfully")
}
}.catch { println("Caught error: $it") }
.collect()
}
运行结果:
less
Flow completed with error: java.lang.RuntimeException: Demo error
Caught error: java.lang.RuntimeException: Demo error
并且 onCompletion 并不会拦截异常,而是会继续抛出,因为它只是监听 Flow 的生命周期,并不会影响流程。
onEmpty:空流监听
onEmpty 会在 Flow 正常结束 且没有发射任何数据时被调用。
kotlin
fun main(): Unit = runBlocking {
flow<Int> {}
.onEmpty {
println("Flow was empty")
}
.collect()
}
Flow 的并发与不变性
flowOn:切换上下文
flowOn() 是用来切换上下文的工具,其效果和 catch 操作符类似,影响的只是上游的上下文。
kotlin
fun main(): Unit = runBlocking {
val flow = flow {
// 在 IO 线程池执行
println("Flow: ${currentCoroutineContext()}")
emit(1)
}.flowOn(Dispatchers.IO) // 切换点
.map {
// 在 Default 线程池执行
println("Map: ${currentCoroutineContext()}")
it.toString()
}.flowOn(Dispatchers.Default) // 切换点
launch(Dispatchers.IO) { // 在 IO 线程池执行
flow.collect {
println("Collect: ${currentCoroutineContext()}")
}
}
}
运行结果:
less
Flow: [ProducerCoroutine{Active}@126a04c4, Dispatchers.IO]
Map: [ScopeCoroutine{Active}@20ede7ba, Dispatchers.Default]
Collect: [ScopeCoroutine{Active}@958541d, Dispatchers.IO]
另外,如果要切换收集数据时的上下文,官方有推荐的写法:
kotlin
flow.onEach {
println("Collect: ${currentCoroutineContext()}")
}.launchIn(this + Dispatchers.IO)
Flow 不变性与 withContext 陷阱
关于
emit的跨界问题,可以看我的这篇博客:Kotlin Flow 入门:构建响应式异步数据流
我们都知道 emit() 必须在收集者的上下文中被调用(Flow 不变性),否则可能会在生产者内部改变了收集者的上下文。
要在 flow 块中使用 withContext,必须不能包住 emit():
kotlin
fun main(): Unit = runBlocking {
val flow = flow {
val value = withContext(Dispatchers.IO) {
// 在 IO 线程池执行
println("Flow: ${currentCoroutineContext()}")
delay(1000)
1
}
emit(value)
}
launch(Dispatchers.Default) { // 在 Default 线程池执行
flow.collect {
println("Collect: ${currentCoroutineContext()}")
}
}
}
所以 "不能在 flow 块里用 withContext" 的真正含义也是指的这个。
你可能会疑问为什么 flowOn() 可以正常工作?
这是因为它在内部创建了一个 Channel,并且在内部启动了一个新协程。这个协程会在我们指定的上下文上工作,并去执行上游的所有操作,同时下游会挂起并等待 Channel 发送数据。
在 flowOn 内部,上游生产者的 emit 会重定向为 send,下游收集者的 collect 会调用 receive 挂起等待数据。一旦数据被发送出来,receive() 就会拿到数据,并传入 collect 块中。
上游和下游是独立的,所以 flowOn 能工作。
实际怎么选?
withContext 和 flowOn 的关系就像 try-catch 和 catch,只要不包住 emit,withContext 可以随意使用。
实际开发中,对于小范围的线程切换会使用 withContext,需要对整个或多个操作符进行线程切换,更多会使用 flowOn。
Flow Fusion (融合)
flowOn 和接下来会讲到的 buffer(以及 channelFlow)底层都依赖了 channelFlow。
当它们被连续调用时,为了减少开销,Kotlin 编译器会将它们融合成一个 channelFlow 实例,并合并配置。
kotlin
flow {
println("emit 1 in ${currentCoroutineContext()}")
emit(1)
}.flowOn(Dispatchers.IO).flowOn(Dispatchers.Default).collect {
println(it)
}
比如上述代码中,连续的 flowOn 调用,只会创建一个 channelFlow 实例。
并且两者的协程上下文会进行合并,相加的顺序是从右向左(Dispatchers.Default + Dispatchers.IO),所以最终的协程调度器会是 Dispatchers.IO。
对
CoroutineContext的相加操作不清楚的,可以看我的这篇博客:掌握协程的边界与环境:CoroutineScope 与 CoroutineContext
buffer
buffer(缓冲)的底层也是通过 Channel 实现的,它的效果就是给 Flow 设置缓冲。
Flow 中的每条数据从生产到最后消费的整个流程是串行的。数据流的每条数据之间也是串行的,后一条数据需要等待前一条数据消费完,才能开始生产。
buffer 虽然不能打破单条数据的处理流程,但它能让数据的生产和消费并发执行。只需让每条数据在指定的线程池中处理,这样上游的 emit 就不会被阻塞,从而可以立即返回,继续生产下一条数据。
当然我们是不能使用 withContext 包裹住 emit 的,这时,我们可以使用 flowOn。
kotlin
flow {
(1..3).forEach {
delay(100)
emit(it)
println("Emitted $it")
}
}.flowOn(Dispatchers.IO)
.collect {
println("Collecting $it")
delay(500)
println("Collected $it")
}
flowOn 除了切换了上下文外,内部还进行了缓冲,这样未处理的数据才能保留,不被丢弃。
而 buffer 操作符就是用来配置缓冲参数的:
kotlin
flow {
(1..3).forEach {
delay(100)
emit(it)
println("Emitted $it")
}
}.flowOn(Dispatchers.IO)
.buffer(capacity = 2, onBufferOverflow = BufferOverflow.DROP_OLDEST) // 配置缓冲参数
.collect {
println("Collecting $it")
delay(500)
println("Collected $it")
}
如果不想切换上下文,只想让上游和下游并发执行,单独使用 buffer() 即可。
当多个 buffer 融合时,融合规则是:
-
如果右侧的
buffer配置了缓冲溢出策略(非默认的SUSPEND),那么右侧的buffer会覆盖左侧的。 -
如果没有配置,那么缓冲溢出策略会沿用左侧的。如果两者都没有填缓冲区大小,那么最终的大小会是默认值 64。其中一个填了,那么最终大小为填的值。如果两者都填了,最终大小为两者之和。
没必要记,可以看
ChannelFlow的fuse函数实现。
解构 collectLatest
现在,我们来看看一个很复杂的操作符 collectLatest。它的效果是:当新数据到来时,会取消上一个未处理完的 collect 块中的逻辑。
听起来好像很神奇,实际上它的内部实现是 mapLatest(action).buffer(0).collect()。
数据处理逻辑并不在 collect 块中,而是移动到了 mapLatest 中执行,末尾的 collect() 则是空的。当新数据到来时,取消的实际上是上一个未完成的 mapLatest 块。
并且其缓冲区大小为 0(buffer(0)),这样 mapLatest 在转换完成后,必须要挂起等待末尾的 collect() 来接收数据。
组合起来的效果是:
-
如果
mapLatest还没转换完,新数据会取消它。 -
如果转换完,但末尾的
collect没来得及接收。那么在mapLatest挂起等待期间,如果上游发送了新数据,mapLatest同样会取消这个已经转换完成、但还在等待的旧数据。