课程概述:Flow数据流与蓝牙泄漏问题简介
大家好。我是你们的讲师,一个在Android蓝牙坑里摸爬滚打多年的开发老兵。今天,咱们来聊一个看似平常,却能让你线上崩溃的隐形杀手——因Flow数据流未取消导致的蓝牙资源泄漏。
为什么会想到开这个课?因为我个人习惯在项目里大量使用协程和Flow来管理异步数据。几年前的一次经历让我记忆犹新:某个蓝牙扫描功能,上线后用户反馈手机发烫、耗电飞快。查了半天,发现就是一个.launchIn没有配合cancel(),导致扫描流一直运行,不断唤醒蓝牙芯片。
说白了,Flow很强大,但用不好就是一把双刃剑。
一个容易被忽视的真相
很多开发者觉得,只要用了协程,就自动生命周期安全了。这种想法很危险。其实,Flow本身只是一个冷数据流,它本身不携带生命周期信息。真正控制它生死的,是协程作用域(CoroutineScope)。
你想想看,你在Activity里用viewModelScope.launch收集一个Flow,看起来很安全。但如果这个Flow在某个中间操作开启了蓝牙Socket,而你在退出页面时没有取消Flow,那么:
- 蓝牙连接会被强引用持有
- 协程在挂起点恢复后继续执行
- 蓝牙回调和系统服务处于非正常存活状态
这就是泄漏的根源链条。
cancel(),协程只会在挂起点(如delay()、emit())检查取消状态。如果Flow处于长时间运行的蓝牙回调循环中,不主动设置ensureActive()检查,取消指令会被直接忽略。
蓝牙操作的特殊性
蓝牙通信和普通网络请求不一样。普通Retrofit请求发完就结束了,但蓝牙操作往往是长驻型的:
| 操作类型 | 典型持续时间 | 泄漏后果 |
|---|---|---|
| 蓝牙扫描 | 无界(需手动停止) | CPU唤醒锁、蓝牙芯片持续工作、电量耗尽 |
| GATT连接 | 会话级(数分钟到数小时) | 系统蓝牙服务句柄泄漏、设备无法被其他应用连接 |
| 通知接收 | 一直持续 | 内存泄漏、回调堆积、OOM |
嗯,这里要注意:大多数蓝牙泄漏不是瞬间爆发的,而是随着时间推移指数级恶化。你退出页面100次,蓝牙线程池里就堆积了100个挂起的协程。
Flow 泄漏的常见场景
- 使用
liveData桥接:liveData { ... }.asFlow(),虽然LiveData自带生命周期感知,但内部的Flow如果启动了一个callbackFlow,这个callbackFlow的生命周期与LiveData的Observer绑定,而不是与View的生命周期绑定。我就在项目中遇到过一次,用户反复进入退出页面,蓝牙通知流积累了几百个。 - 滥用
.catch和.retry:有些蓝牙操作会抛出异常(比如设备断连)。在Flow里用.retryWhen无限重试,配合.launchIn在全局Scope里执行,流变成永不结束的“僵尸流”。 - 忘记在
callbackFlow中处理awaitClose:这是最经典的。callbackFlow必须提供awaitClose来注册资源清理回调。如果忘记写,或者写了但没正确取消上游回调,蓝牙Gatt回调会永远持有Flow实例。
// 这是一个典型的危险写法
fun BluetoothGatt.observeCharacteristicChanges(): Flow<ByteArray> = callbackFlow {
val callback = object : BluetoothGattCallback() {
override fun onCharacteristicChanged(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?) {
// 这里没有trySend,而是直接send,如果消费者不消费,协程挂起
trySend(characteristic?.value ?: byteArrayOf())
}
}
// 注册回调
setCharacteristicNotification(gattService.getCharacteristic(UUID), true)
// 关键:awaitClose中必须注销回调,否则泄漏!
awaitClose {
// 我习惯在这里做清理
close() // 如果这里你只close了gatt,但没有unregister,泄漏!
}
}
callbackFlow时,我一般会在awaitClose里强制打印日志,确保每个流的创建和销毁都成对出现。这样一旦泄漏,日志里会看到明显的“不对称”。
一个真实的泄漏案例
去年我参与重构一个蓝牙打印项目。原始代码是用RxJava写的,改成Flow后出现了一个奇怪现象:手机温度持续上升,30分钟消耗50%电量。
我们排查后发现,问题出在这么一个逻辑里:
- Service启动了一个蓝牙扫描Flow,使用
flowOn(Dispatchers.IO)和buffer()优化性能。 - 扫描结果是
StateFlow,被Activity收集用于UI更新。 - 当Activity销毁时,ViewModel的
viewModelScope被取消,但Service里启动流的那个协程作用域是一个自定义的全局Scope(为了保持扫描后台运行)。 - 扫描流源源不断产生数据,即使没有消费者(Activity已销毁),
buffer()队列疯狂增长,把蓝牙芯片压到满负荷运行。
为什么会这样?因为buffer()在消费者速度慢或没有消费者时,会堆积缓存。蓝牙硬件每秒产生几十个回调,buffer无限增长,内存和CPU都受不了。
本章知识体系
下面这张图展示了Flow泄漏蓝牙资源的完整链条。我个人建议你先把它看明白,后面章节我们会逐个攻破每个环节。
本文小结
蓝牙泄漏不像空指针那样会立刻Crash,它是一种慢性的资源枯竭。作为一个开发者,你需要意识到:
- Flow本身无生命周期,它依赖所在的协程作用域
- 蓝牙操作(扫描、连接、通知)是无限期的,必须主动终止
- CallbackFlow是最常见的泄漏源头,awaitClose是唯一的清理机会
我曾经因为一个.launchIn(GlobalScope)没处理好,导致线上用户蓝牙功能集体瘫痪。所以,别偷懒,每个Flow的创建都要问自己:它什么时候停止?