Flow 的异常处理与执行控制

1,181 阅读6分钟

异常透明性

官方不鼓励我们在 Flow 里使用 try-catch 捕获异常,而是推荐用 catch 操作符,为什么呢?

原因在于一个核心原则:异常透明性,即上游的生产者不应该捕获或处理下游消费者抛出的异常。

先来看一段代码:

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 的代码块):

try {
    flow.collect { userId ->
        // 模拟网络操作
        val userInfo = getUserInfo(userId)
        println(userInfo)
    }
} catch (e: Exception) {
    println("Exception in collect: ${e.message}")
}

运行结果可能为:

User: Charlie, Id: 1
User: Eve, Id: 2
Exception in collect: Failed to get user info for userId: 3

实际上,flow 代码块中也可能会抛出异常,比如访问数据库或进行网络请求时,我们也需要使用 try-catch 进行捕获:

val flow = flow {
    try {
        for (i in 1..5) {
            delay(100)
            emit(i)
        }
    } catch (e: Exception) {
        println("Exception in flow: ${e.message}")
    }
}

运行结果可能为:

User: Charlie, Id: 1
Exception in flow: Failed to get user info for userId: 2

此时你会发现一个问题:collect 处的异常竟然没有被捕获到。

我来给你解释一下原因:

首先,调用 collect 函数时会执行 flow 的代码块,当调用 emit 发射数据时,实际上会去执行 collect 函数的代码块。

// collect 的完整写法
flow.collect(object : FlowCollector<Int> {
   override suspend fun emit(value: Int) { 
       // 模拟网络操作
       val userInfo = getUserInfo(value)
       println(userInfo)
   }
})

而异常默认会从调用栈向上抛,所以会从下游的 collect 代码块抛出,到上游的 emit 的调用处被捕获,异常并没有再向上抛,自然 collect 的调用处就捕获不到。

当然,你可以在 flow 中将异常再次抛出:

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() 调用

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 代码块中抛出的异常会到哪?

flow { 
    for (i in 1..5) {
        emit(i)
    }
}.map {
    it + 1
}.collect {
    throw Exception("Failed to collect")
}

首先得清楚 map 的工作原理:它会返回一个新的 Flow,这个 Flow 对象内部会收集上游的数据,经过数据转换后(执行 map 的代码块)并发射到下游,下游的 collect 会启动这个 Flow 的收集。

源码简化版:

fun <T, R> Flow<T>.map(transform: suspend (T) -> R): Flow<R> = flow {
    // 1. 收集
    collect { value ->
        // 2. 转换
        val transformed = transform(value)
        // 3. 发射
        emit(transformed)
    }
}

所以前面那段代码的执行路径是:

  1. 最外层调用 collect,也就是调用 map 生成的 Flow 的 collect
  2. 致使原始的 Flow 触发 collect
  3. 然后原始的 Flow 发射第一个值(emit(1)),会回调 map 内部的收集逻辑中,执行转换逻辑,并向下游发射值(emit(2))。
  4. 接着,会回调最外层的 collect,抛出异常。

所以下游的异常会抛到上游 emit 的调用处。而 map 的代码块只是提供了 map 转换的逻辑,对于问题 1,答案是不会,下游的异常会沿着 emit 的调用链上升,不会经过 map 的转换逻辑。

接着异常会继续向上抛,先到上游的 emit 调用处,然后到 mapcollect 调用处,最后到最外层的 collect。对于问题 2,答案是向上游传递。到达最上游之后,会沿着 collect 调用链来到最外层的 collect 调用处。

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

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}")
    }
}

运行结果可能为:

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 之间的异常。

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")
    }
}

运行结果:

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,而是接管后续的数据流,负责后续数据的发送。

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 面对上游异常的行为是重启上游。

简单来说,就是再次调用上游 Flowcollect 函数,创建一个全新的上游 Flow 对象。实际上,这种调用会替换掉整个上游 Flow 链条,而这种替换对下游来说是透明的。

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")
    }
}

运行结果:

1
4
9
1
4
9
1
4
9
1
4
9 
Catch Exception: java.io.IOException: Failed

另外,我们可以填写第二个参数 predicate。这样,只有当前重试次数小于指定最多重试次数,并且 lambda 返回 true 才会重试。

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 的参数。

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 {...} 代码块执行之前。

fun main(): Unit = runBlocking {
    flow {
        println("Flow started")
        emit(1)
    }.onStart {
        println("Starting flow collection")
    }.collect {
        println("Collected $it")
    }

}

运行结果:

Starting flow collection
Flow started
Collected 1

注意:

  1. 同时调用多个 onStart,执行顺序是从下游到上游。

  2. onStart 中抛出的异常无法被上游包裹了 emittry-catch 捕获,因为它早在上游发送第一条数据之前就执行了。

    异常能被下游的 catch 操作符捕获,catch 捕获所有上游抛出的异常。

onCompletion:流的 finally 块

onCompletion 用于在上游 Flow 终止时执行逻辑。

无论上游是正常结束还是因异常而终止,onCompletion 都会被调用。正常结束时,其 lambda 的参数值为 null;异常终止时,参数值是上游抛出的异常。

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()
}

运行结果:

Flow completed with error: java.lang.RuntimeException: Demo error
Caught error: java.lang.RuntimeException: Demo error

并且 onCompletion 并不会拦截异常,而是会继续抛出,因为它只是监听 Flow 的生命周期,并不会影响流程。

onEmpty:空流监听

onEmpty 会在 Flow 正常结束没有发射任何数据时被调用。

fun main(): Unit = runBlocking {
    flow<Int> {}
        .onEmpty {
            println("Flow was empty")
        }
        .collect()
}

Flow 的并发与不变性

flowOn:切换上下文

flowOn() 是用来切换上下文的工具,其效果和 catch 操作符类似,影响的只是上游的上下文。

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()}")
        }
    }
}

运行结果:

Flow: [ProducerCoroutine{Active}@126a04c4, Dispatchers.IO]
Map: [ScopeCoroutine{Active}@20ede7ba, Dispatchers.Default]
Collect: [ScopeCoroutine{Active}@958541d, Dispatchers.IO]

另外,如果要切换收集数据时的上下文,官方有推荐的写法:

flow.onEach {
    println("Collect: ${currentCoroutineContext()}")
}.launchIn(this + Dispatchers.IO)

Flow 不变性与 withContext 陷阱

关于 emit 的跨界问题,可以看我的这篇博客:Kotlin Flow 入门:构建响应式异步数据流

我们都知道 emit() 必须在收集者的上下文中被调用(Flow 不变性),否则可能会在生产者内部改变了收集者的上下文。

要在 flow 块中使用 withContext,必须不能包住 emit()

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 能工作。

实际怎么选?

withContextflowOn 的关系就像 try-catchcatch,只要不包住 emitwithContext 可以随意使用。

实际开发中,对于小范围的线程切换会使用 withContext,需要对整个或多个操作符进行线程切换,更多会使用 flowOn

Flow Fusion (融合)

flowOn 和接下来会讲到的 buffer(以及 channelFlow)底层都依赖了 channelFlow

当它们被连续调用时,为了减少开销,Kotlin 编译器会将它们融合成一个 channelFlow 实例,并合并配置。

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

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 操作符就是用来配置缓冲参数的:

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 融合时,融合规则是:

  1. 如果右侧的 buffer 配置了缓冲溢出策略(非默认的 SUSPEND),那么右侧的 buffer 会覆盖左侧的。

  2. 如果没有配置,那么缓冲溢出策略会沿用左侧的。如果两者都没有填缓冲区大小,那么最终的大小会是默认值 64。其中一个填了,那么最终大小为填的值。如果两者都填了,最终大小为两者之和。

    没必要记,可以看 ChannelFlowfuse 函数实现。

解构 collectLatest

现在,我们来看看一个很复杂的操作符 collectLatest。它的效果是:当新数据到来时,会取消上一个未处理完的 collect 块中的逻辑。

听起来好像很神奇,实际上它的内部实现是 mapLatest(action).buffer(0).collect()

数据处理逻辑并不在 collect 块中,而是移动到了 mapLatest 中执行,末尾的 collect() 则是空的。当新数据到来时,取消的实际上是上一个未完成的 mapLatest 块。

并且其缓冲区大小为 0(buffer(0)),这样 mapLatest 在转换完成后,必须要挂起等待末尾的 collect() 来接收数据。

组合起来的效果是:

  1. 如果 mapLatest 还没转换完,新数据会取消它。

  2. 如果转换完,但末尾的 collect 没来得及接收。那么在 mapLatest 挂起等待期间,如果上游发送了新数据,mapLatest 同样会取消这个已经转换完成、但还在等待的旧数据。