Flow 的异常处理与执行控制

异常透明性

官方不鼓励我们在 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 调用处,然后到 mapcollect 调用处,最后到最外层的 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 面对上游异常的行为是重启上游。

简单来说,就是再次调用上游 Flowcollect 函数,创建一个全新的上游 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

注意:

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

  2. onStart 中抛出的异常无法被上游包裹了 emittry-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 能工作。

实际怎么选?

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

实际开发中,对于小范围的线程切换会使用 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 融合时,融合规则是:

  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 同样会取消这个已经转换完成、但还在等待的旧数据。

相关推荐
00后程序员张4 小时前
Web 前端工具全流程指南 从开发到调试的完整生态体系
android·前端·ios·小程序·uni-app·iphone·webview
ClassOps4 小时前
Gradle Groovy 和 Kotlin kts 语法对比
android·kotlin·gradle·groovy
消失的旧时光-19435 小时前
Android ble和经典蓝牙
android
李少兄6 小时前
IntelliJ IDEA 如何全局配置 Maven?避免每次打开新项目重新配置 (适用于 2024~2025 版本)
android·maven·intellij-idea
小蜜蜂嗡嗡6 小时前
【flutter报错:Build failed due to use of deprecated Android v1 embedding.】
android·flutter·embedding
杨筱毅6 小时前
【底层机制】Android GC -- 为什么要有GC?GC的核心原理?理解GC的意义
android·jvm·gc
用户69371750013847 小时前
⚡Kotlin 五大神器完全解析:let、with、run、apply、also 一次搞懂,面试官都笑了!
android·kotlin
木易 士心7 小时前
Spring Boot + Kotlin + Gradle 构建现代化后端应用
spring·kotlin
QmDeve7 小时前
Android 使用液态玻璃(LiquidGlass)效果,真实的折射和色散效果
android·github