协程在BLE中的常见用法:用协程封装BLE回调、异步操作简化
做 BLE 开发的朋友都知道,Android 官方的 BLE API 全是回调式的。你写一个连接,要等 onConnectionStateChange;写一个发现服务,要等 onServicesDiscovered;读个特征值,还得等 onCharacteristicRead。多层回调嵌套下来,代码读起来就像一团乱麻。我早年刚接手 BLE 项目时,看到一屏的回调就头皮发麻,后来用协程彻底重构了那套代码,世界一下子清爽了。
为什么非用协程不可?
说白了,回调最大的问题就是逻辑分散。你的连接逻辑可能分散在三个不同的回调里,而且一旦某个回调没走到,调试起来像大海捞针。协程允许你按顺序写代码,一步接一步,出错的地方能精确到行。
我看过不少人用 CountDownLatch 配合回调,虽然也能同步,但一旦一个回调漏了,整个线程就挂死了。协程的取消机制天生就能处理这种场景——你想想看,当页面销毁时,协程的 Job 会被取消,回调也就不会被催出来了。
封装单次回调:suspendCoroutine 与 suspendCancellableCoroutine
单次回调,比如连接、发现服务、读取特征值,最适合用 suspendCancellableCoroutine 来封装。这个函数会挂起当前协程,直到你在回调里调用 resume 或者 resumeWith。为什么我偏爱 suspendCancellableCoroutine 而不是 suspendCoroutine?因为前者能感知协程取消。我在项目中遇到过:用户快速退出页面,协程取消了,但 BLE 回调仍然执行 resume,这会导致已取消的协程状态异常。用 suspendCancellableCoroutine 配合 invokeOnCancellation 可以主动清理回调注册,避免泄漏。
suspend fun BluetoothGatt.connectSuspend(context: Context, device: BluetoothDevice): BluetoothGatt {
return suspendCancellableCoroutine { continuation ->
val gatt = device.connectGatt(context, false, object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
continuation.resume(gatt)
} else {
continuation.resumeWithException(
BluetoothException("连接失败,status=$status")
)
}
}
})
// 记得在协程取消时断开连接
continuation.invokeOnCancellation {
gatt.disconnect()
gatt.close()
}
}
}
invokeOnCancellation 里不仅要断开连接,还要关闭 gatt 对象,否则底层资源不会释放。我早期就漏了 close(),结果手机蓝牙芯片的内存被吃光了。
封装多次回调:用 Flow 监听通知和指示
BLE 特征值的通知(Notification)会不断触发 onCharacteristicChanged,这是典型的多次回调场景。用协程的 callbackFlow 来封装最适合不过。每次回调就往 Flow 里 emit 数据,外部用 collect 接收。注意一点:当 Flow 的收集者取消时,callbackFlow 会自动调用 awaitClose 中你传入的清理逻辑,这样可以确保不会残留监听器。
fun BluetoothGatt.notificationFlow(characteristic: BluetoothGattCharacteristic): Flow<ByteArray> = callbackFlow {
val callback = object : BluetoothGattCallback() {
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
// 这里要判断 characteristic 是否匹配,防止其他特征值的通知混入
if (characteristic.uuid == this@notificationFlow.characteristic.uuid) {
trySend(characteristic.value)
}
}
}
this@notificationFlow.setCharacteristicNotification(characteristic, true)
this@notificationFlow.registerCallback(callback)
awaitClose {
this@notificationFlow.setCharacteristicNotification(characteristic, false)
this@notificationFlow.unregisterCallback(callback)
}
}
上面代码里有个细节:trySend 而不是 send。因为回调线程并不一定是协程上下文,如果用 send 可能会因为调度器限制而挂起,导致回调线程被阻塞。用 trySend 就能避免这个问题。这是我被坑过一次后改的——当时发现偶尔会收不到数据,排查半天才发现是 send 阻塞了回调线程。
简化异步操作:并发控制与超时
协程另一个爽点是能方便地处理超时和并发限制。比如你要连接多个设备,传统的做法是开多个线程或者排队。用协程 withTimeout 和 Semaphore 能写得非常紧凑。
| 传统方式 | 协程方式 |
|---|---|
| 用 Handler postDelayed 实现超时 | withTimeout(5000) { connectSuspend(...) } |
| 用 BlockingQueue 做同步 | suspendCancellableCoroutine 天然支持 |
| 用 ExecutorService 限制并发连接数 | Semaphore(2) { connectSuspend(...) } |
Semaphore(2, fair = true) 才解决。别小看这个参数,公平模式在 BLE 这种顺序依赖场景下很重要。
协程在 BLE 中的整体封装结构
下面这张图概括了用协程封装 BLE 回调的完整流程,从创建连接、发现服务、启用通知到读写特征值,每一层都通过挂起函数将异步回调转为同步调用,最后用 Flow 对外暴露数据流。
协程泄漏的根源与预防
我们费这么大劲封装,核心目的之一就是防止协程泄漏。泄漏本质上是协程的生命周期和组件(Activity、Fragment、Service)不同步。举个例子:你启动了协程去连接 BLE,但还没连接成功用户就退出了页面。如果协程没有被取消,它仍然持有 bluetoothGatt 和 Context 的引用,这就是泄漏。
解决方案在我这里很简单:用 viewModelScope 或自定义的 CoroutineScope 绑定组件生命周期。在 onStop 或 onDestroy 里取消 scope,协程自动取消,invokeOnCancellation 自动执行清理。我过去就是因为偷懒使用了 GlobalScope,结果测试发现返回页面十几次后,蓝牙连接数暴涨,最后崩溃了。
viewModelScope,也要注意在 onCleared 里显式断开 BLE 连接。因为协程取消可能不会立即触发物理断连,系统层可能需要时间。我习惯在 onCleared 中加一个兜底:gatt?.disconnect() + gatt?.close()。
一个小总结
用协程封装 BLE 回调,说白了就是两招:suspendCancellableCoroutine 对付单次回调,callbackFlow 对付多次通知。同时,别忽视了生命周期绑定和超时处理。你只要记住:每次挂起函数里都要写 invokeOnCancellation,每次 Flow 都要在 awaitClose 里清理。做到这两点,协程在 BLE 场景下基本就不会主动泄漏了。