19、Flow:Flow的概念、Flow的创建与收集、Flow的中间操作、Flow的上下文与调度

各位同学,今天我们来聊聊 Kotlin 协程里的 Flow。

说实话,我第一次接触 Flow 的时候,心里想的是:“这不就是 RxJava 换了个马甲吗?” 后来在项目里真正用起来,才发现它比 RxJava 轻量得多,也更符合 Kotlin 的哲学。今天我就把 Flow 的核心知识点拆开揉碎了讲给你听。

19.1 Flow 的概念

Flow 是什么?说白了,它是一个冷数据流。

什么叫“冷”?就是它本身不干活,只有当你开始收集(collect)的时候,它才会发射数据。这一点跟 Sequence 很像,但 Flow 是异步的,可以挂起。

核心特点:

  • 冷流:每次收集都会重新执行生产者的代码。
  • 异步:支持挂起函数,可以处理耗时操作。
  • 背压:Flow 天然支持背压,因为它是挂起式的,消费者慢的时候生产者会自动挂起等待。

我习惯把 Flow 理解成“异步版的 Sequence”。你想想看,Sequence 是同步的,每次取一个值都得阻塞当前线程;而 Flow 是挂起的,不会阻塞线程,只是暂停协程。

19.2 Flow 的创建与收集

创建 Flow 的方式有很多,我挑几个最常用的给你演示一下。

19.2.1 使用 flow { } 构建器

fun simpleFlow(): Flow<Int> = flow {
    for (i in 1..3) {
        delay(100) // 模拟异步操作
        emit(i)    // 发射数据
    }
}

收集的时候,用 collect 操作符:

fun main() = runBlocking {
    simpleFlow().collect { value ->
        println("收到: $value")
    }
}

输出结果:

收到: 1
收到: 2
收到: 3

小提示: emit 是一个挂起函数,所以它只能在协程作用域内使用。flow { } 构建器会自动提供协程上下文。

19.2.2 使用 flowOf 和 asFlow

如果你已经有了一组固定数据,可以用 flowOf

val flow1 = flowOf(1, 2, 3)

如果你有一个集合或序列,可以用 asFlow 扩展函数:

val flow2 = listOf("A", "B", "C").asFlow()

这两种方式适合快速测试,我在写单元测试的时候经常用。

19.2.3 收集的几种方式

除了 collect,还有 collectLatestconflate。我简单说一下区别:

操作符 行为
collect 逐个处理,处理完一个再取下一个
collectLatest 如果新数据来了,旧数据还没处理完,就取消旧的处理
conflate 跳过中间未处理的数据,只保留最新的

我曾经在做一个实时搜索功能时,用 collectLatest 来避免旧请求的干扰。用户输入很快,我只关心最新的输入结果,之前的请求直接取消掉,体验好很多。

19.3 Flow 的中间操作

Flow 的中间操作跟集合的操作符很像,比如 mapfiltertransform 等等。但有一点要注意:这些操作都是冷操作,不会立即执行,只有收集的时候才会触发。

19.3.1 map 和 filter

flowOf(1, 2, 3, 4, 5)
    .filter { it % 2 == 0 }
    .map { "偶数: $it" }
    .collect { println(it) }

输出:

偶数: 2
偶数: 4

19.3.2 transform

transformmap 更灵活,它可以发射任意次数的数据,甚至可以发射不同类型的数据。

flowOf(1, 2, 3)
    .transform { value ->
        emit("值: $value")
        emit("值平方: ${value * value}")
    }
    .collect { println(it) }

输出:

值: 1
值平方: 1
值: 2
值平方: 4
值: 3
值平方: 9

19.3.3 take 和 drop

这两个操作符用来限制数据量。比如我只想取前两个数据:

flowOf(1, 2, 3, 4, 5)
    .take(2)
    .collect { println(it) } // 输出 1, 2

注意: take 操作符在取到指定数量后,会取消上游的 flow。这意味着如果上游有资源释放的逻辑,可能会被跳过。我曾经在文件读取的 flow 上用了 take,结果文件流没有正确关闭,后来改用 onCompletion 来确保资源释放。

19.3.4 组合操作:zip 和 combine

如果你有两个 flow,想把它们合并成一个,可以用 zipcombine

zip 是一对一匹配,就像拉链一样:

val flowA = flowOf("A", "B", "C")
val flowB = flowOf(1, 2, 3)
flowA.zip(flowB) { a, b -> "$a$b" }
    .collect { println(it) } // 输出 A1, B2, C3

combine 则是每当任意一个 flow 有新数据时,就用最新的两个数据组合一次。这个在 UI 开发中很常用,比如同时监听两个输入框的变化。

19.4 Flow 的上下文与调度

Flow 的上下文问题,是很多初学者容易踩坑的地方。我刚开始用的时候也困惑过。

19.4.1 flowOn 操作符

Flow 默认在收集的协程上下文中执行。但如果你想让 flow 的生产者在另一个线程执行,可以用 flowOn

flow {
    for (i in 1..3) {
        delay(100)
        emit(i)
    }
}
.flowOn(Dispatchers.IO) // 生产者在 IO 线程
.collect { value ->
    println("收集在 ${Thread.currentThread().name}")
}

输出:

收集在 main
收集在 main
收集在 main

注意,flowOn 只改变它之前的上游上下文,下游仍然在原来的上下文中收集。

关键点: flowOn 会改变 flow 的上下文,但不会改变收集的上下文。如果你想让收集也在后台线程,可以在 collect 外面用 withContext 包裹。

19.4.2 协程上下文与异常处理

Flow 的异常处理跟普通协程不太一样。你不能直接在 flow 构建器里用 try-catch 捕获所有异常,因为异常可能发生在下游。

正确的做法是用 catch 操作符:

flow {
    emit(1)
    throw RuntimeException("出错了")
}
.catch { e ->
    println("捕获异常: $e")
    emit(-1) // 可以发射一个默认值
}
.collect { println(it) }

输出:

1
捕获异常: java.lang.RuntimeException: 出错了
-1

我曾经在项目里犯过一个错误:在 flow 构建器里直接 try-catch,结果异常是捕获了,但 flow 没有正常结束,导致下游一直等待。后来改用 catch 操作符,问题就解决了。

19.4.3 背压与缓冲

Flow 默认是顺序执行的,生产者发射一个数据,消费者处理完,生产者再发射下一个。如果消费者处理得慢,生产者会自动挂起等待。这就是 Flow 的背压机制。

但有时候你想让生产者快一点,可以用 buffer 操作符:

flow {
    for (i in 1..3) {
        delay(100)
        emit(i)
    }
}
.buffer() // 允许生产者提前发射,消费者在后面慢慢处理
.collect { value ->
    delay(300)
    println(value)
}

这样生产者和消费者可以并行工作,总耗时从 1.2 秒降到了 0.6 秒左右。

19.5 知识体系总览

下面这张图是我自己整理的 Flow 核心知识结构,你可以把它当作一个快速索引。

Flow 冷流 异步挂起 背压支持 flow { } flowOf / asFlow collect / collectLatest map / filter transform take / drop zip / combine flowOn catch buffer Flow 核心知识体系 冷流 · 创建收集 · 中间操作 · 上下文调度

19.6 避坑指南

最后,我分享几个自己踩过的坑,希望能帮你少走弯路。

  • 不要在 flow 构建器里使用 withContext:flow 构建器已经提供了协程上下文,如果你再用 withContext 切换线程,可能会导致 flowOn 失效。正确的做法是用 flowOn 来指定生产者的调度器。
  • 注意 collect 的挂起特性:collect 是一个挂起函数,它会一直挂起直到 flow 结束。如果你在 collect 后面写了代码,那些代码永远不会执行,除非 flow 是无限的。
  • 异常处理要放在正确的位置:catch 操作符只能捕获它上游的异常,下游的异常它管不了。如果你想让 catch 捕获所有异常,把它放在最靠近 collect 的位置。

嗯,关于 Flow 的核心内容就讲到这里。你可以在项目里试着用 Flow 替换掉那些回调式的异步代码,体验一下它的简洁和优雅。


公众号:蓝海资料掘金营,微信deep3321