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)则共享数据。

冷流 vs 热流 核心对比 冷流(Cold Flow) Flow / flow {} • 每次 collect 独立发射 • 没有收集者时不执行 • 数据不共享 • 天然适合:API请求、 传感器单次采集 热流(Hot Flow) StateFlow / SharedFlow • 不论有无收集者都在运行 • 多个收集者共享数据 • 粘性事件(StateFlow) • 天然适合:UI状态、 蓝牙事件广播 选冷还是选热?看数据是否需要「独立消费」——蓝牙扫描结果通常用热流共享

我经常说:如果数据是一次性且每次收集者都需要完整结果,用冷流;如果是持续状态且想避免重复发射,用热流。蓝牙连接状态我推荐 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 操作符是它强大的地方。我排个优先级:

  1. flowOn:切换上游运行的调度器。蓝牙 SDK 一般要求在主线程注册回调,但扫描本身需要挂起,所以我会把 flowOn(Dispatchers.IO) 放在构建器后。
  2. catch:捕获上游异常,不会影响下游。注意 catch 只能捕获它之前的异常。
  3. 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,方便你记:

Flow 数据流完整处理流程 flow { } 构建器 冷流,延迟发射 flowOn(IO) 切换上游调度器 catch + retry 捕获异常,重试 collect 接收数据 关键生命周期绑定 lifecycleScope + repeatOnLifecycle(STARTED) ➡ 页面可见时自动收集,不可见时自动取消 ➡ 蓝牙资源随生命周期释放,避免泄漏 ⚠️ 陷阱:如果直接在 lifecycleScope 里 launch { collect } 而不使用 repeatOnLifecycle,取消时机不对

你看,整个链条很清晰:创建 → 调整线程 → 异常处理 → 收集。而收集这个环节,一定要和生命周期绑定牢。咱们下一部分,就专门讲这个「绑定」具体怎么做,以及为什么错绑会导致蓝牙泄漏。今天就到这儿,先消化这些基础。