协程在BLE中的常见用法:用协程封装BLE回调、异步操作简化

做 BLE 开发的朋友都知道,Android 官方的 BLE API 全是回调式的。你写一个连接,要等 onConnectionStateChange;写一个发现服务,要等 onServicesDiscovered;读个特征值,还得等 onCharacteristicRead。多层回调嵌套下来,代码读起来就像一团乱麻。我早年刚接手 BLE 项目时,看到一屏的回调就头皮发麻,后来用协程彻底重构了那套代码,世界一下子清爽了。

核心思路:利用 Kotlin 协程中的挂起函数和 Flow,把 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 里忘了设置 fair 参数,导致多个协程竞争时出现饥饿现象,有个设备永远连不上。后来改为 Semaphore(2, fair = true) 才解决。别小看这个参数,公平模式在 BLE 这种顺序依赖场景下很重要。

协程在 BLE 中的整体封装结构

下面这张图概括了用协程封装 BLE 回调的完整流程,从创建连接、发现服务、启用通知到读写特征值,每一层都通过挂起函数将异步回调转为同步调用,最后用 Flow 对外暴露数据流。

协程封装 BLE 回调流程图 原始 BLE 回调 onConnectionStateChange onServicesDiscovered suspendCancellableCoroutine connectSuspend() discoverServicesSuspend() callbackFlow { } notificationFlow() readCharacteristicSuspend() 业务层:viewModelScope.launch { withTimeout { connect(); discover(); collect(flow) } } 关键点:每个挂起函数都需要在 invokeOnCancellation 中清理 BLE 回调,避免协程取消后仍然持有 Gatt 引用导致泄漏

协程泄漏的根源与预防

我们费这么大劲封装,核心目的之一就是防止协程泄漏。泄漏本质上是协程的生命周期和组件(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 场景下基本就不会主动泄漏了。