Flow 数据流的基本概念与使用
大家好,我是老张。在接触协程那会儿,我其实对 Flow 有点「敬而远之」——觉得它不就是个异步序列吗?用 LiveData 不也挺好?直到一次蓝牙项目里,我把 LiveData 换成了 Flow,才发现前者在复杂生命周期场景下有多吃力。今天咱们就从最基础的地方聊起,把 Flow 这张图彻底画清楚。
核心一句话:Flow 是协程世界里冷数据流的代表,它能按需发射数据,天然支持背压和取消。
1. 什么是 Flow?
Flow 是 Kotlin 协程库提供的一个异步数据流容器。你可以把它理解为一个「会延迟计算的序列」——只有当有人开始订阅(collect)时,上游才会开始生产数据。我个人的习惯是:把 Flow 类比成 RxJava 里的 Observable,但它更轻量,且完全遵循结构化并发。
| 特性 | 说明 |
|---|---|
| 冷流 | 每个收集者都会触发独立的发射流程 |
| 异步 | 发射和收集都在协程中运行 |
| 可取消 | 父协程取消后,Flow 自动终止 |
| 背压 | 默认 suspend 机制自然处理生产者快于消费者 |
记得有一次,我接手一个蓝牙扫描模块,之前用回调 + LiveData 组合,导致 Activity 重建后重复注册广播。改成 Flow 后,用 flow { ... } 包裹扫描逻辑,再配合 lifecycleScope 自动取消,问题直接解决。
2. 冷流 vs 热流
这个问题容易混淆,我特意画了个对比图放在下面。说白了,冷流(Cold Flow)每次收集都独立运行,热流(Hot Flow / StateFlow / SharedFlow)则共享数据。
我经常说:如果数据是一次性且每次收集者都需要完整结果,用冷流;如果是持续状态且想避免重复发射,用热流。蓝牙连接状态我推荐 StateFlow,扫描结果推荐 SharedFlow(因为无需粘性重播)。
3. 创建 Flow 的三种方式
我最常用的是 flow { } 构建器。看看这段基础代码:
val scanFlow: Flow<BluetoothDevice> = flow {
val scanner = BluetoothLeScannerCompat.getScanner()
scanner.startScan(object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult?) {
result?.device?.let { device ->
// 注意:必须使用 emit 发射
tryEmit(device)
}
}
})
// 协程挂起,等待外部取消
awaitCancellation()
}.flowOn(Dispatchers.IO)
还有其他两种方式:
- flowOf:用于固定集合数据,比如
flowOf(device1, device2) - asFlow:将集合或序列转为 Flow,比如
listOf(...).asFlow()
嗯,这里要留意:在 flow { } 里不要直接写 emit() 而不做挂起——因为 emit 本身是挂起函数,如果发射太快会导致收集者来不及消费,这就是背压问题。
4. 收集 Flow:collect 与生命周期
收集是最容易被忽视的地方。我遇到过不少同事直接在 Activity 的 onCreate 里开协程收集,结果页面销毁了 Flow 还在跑,蓝牙资源就一直没法释放。
警告:在 Activity/Fragment 里收集 Flow,请始终使用 lifecycleScope 或 viewLifecycleOwner.lifecycleScope,并配合 repeatOnLifecycle(Lifecycle.State.STARTED)。
// 正确的收集方式
class BleScanFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.scanResultFlow.collect { device ->
// 更新UI
adapter.addDevice(device)
}
}
}
}
}
为什么非要加 repeatOnLifecycle?因为当页面进入 STOPPED 状态时,collect 会自动取消,重新 STARTED 后又会重新收集——恰好解决了蓝牙扫描在后台空转的问题。
5. 操作符:flowOn、catch、retry
Flow 操作符是它强大的地方。我排个优先级:
- flowOn:切换上游运行的调度器。蓝牙 SDK 一般要求在主线程注册回调,但扫描本身需要挂起,所以我会把
flowOn(Dispatchers.IO)放在构建器后。 - catch:捕获上游异常,不会影响下游。注意 catch 只能捕获它之前的异常。
- retry:蓝牙扫描偶尔会因系统错误失败,加个重试很实用。
viewModel.scanResultFlow
.flowOn(Dispatchers.IO) // 上游在IO线程
.catch { exception ->
emit(Result.failure<BluetoothDevice>(exception))
// 不要吞异常,我习惯把错误发射出去
}
.retry(2) { cause ->
// 如果返回true则重试,最多2次
cause is BluetoothPermissionException
}
.collect { deviceResult ->
// 处理结果
}
我曾经踩过一个坑:把 flowOn 写在了 catch 之后,结果上游异常无法被 catch 捕获——因为 flowOn 切换了调度器,异常链变了。记住:操作符的顺序很重要,就像流水线工位一样。
6. Flow 与协程上下文
Flow 默认在收集者的协程上下文中运行。如果你在 lifecycleScope.launch(Dispatchers.Main) 里 collect,那么 Flow 内部发射的数据会直接在主线程。但蓝牙扫描需要在后台,所以用 flowOn(Dispatchers.IO) 切换发射线程,收集端仍在主线程接收。
这里有个关键点:flowOn 只改变上游的上下文。下游(collect 块)仍然保持调用者的上下文。这个设计我觉得特别优雅,省去了 RxJava 里反复 subscribeOn/observeOn 的烦恼。
避坑指南:我曾经在项目中把 flow { } 写成了 withContext(Dispatchers.IO) { emit() },结果抛出异常——因为 emit 必须在 flow 构建器的挂起上下文中调用。正确做法就是使用 flowOn。
7. 一张图总结 Flow 使用全景
根据我们刚才聊的内容,我把 Flow 从创建到收集的完整流程画成了一张 SVg,方便你记:
你看,整个链条很清晰:创建 → 调整线程 → 异常处理 → 收集。而收集这个环节,一定要和生命周期绑定牢。咱们下一部分,就专门讲这个「绑定」具体怎么做,以及为什么错绑会导致蓝牙泄漏。今天就到这儿,先消化这些基础。