13、蓝牙服务端多连接处理:GattServer同时服务多个Client、连接ID管理、并发读写处理、资源释放
各位同学,今天我们来聊聊蓝牙服务端开发里一个绕不开的硬骨头——多连接处理。
说实话,刚入行那会儿,我写过一个单连接的GattServer,跑得挺欢。结果产品经理说,要支持三个设备同时连。我心想,不就是多开几个回调吗?结果一上线,各种崩溃、数据错乱、资源泄漏……那叫一个惨。从那以后,我算是彻底明白了:多连接不是简单的复制粘贴,而是一套完整的状态机管理。
13.1 多连接场景下的核心挑战
你想想看,一个GattServer同时服务多个Client,本质上就是一台服务器要处理多个“会话”。每个会话都有自己的连接状态、读写请求、通知通道。如果管理不当,轻则数据串包,重则蓝牙协议栈直接罢工。
我总结了一下,核心挑战就三个:
- 连接ID的分配与回收——每个Client连上来,系统会给一个唯一的ID。这个ID不能重复,也不能泄漏。
- 并发读写的冲突处理——多个Client同时读写同一个Characteristic,谁来排队?谁先谁后?
- 资源释放的时机把控——Client断开后,相关的回调、缓存、线程必须及时清理,否则就是内存泄漏。
一句话总结:多连接的本质,是把一个“一对一”的通信模型,扩展成“一对多”的并发模型。你需要的不是更多的代码,而是更好的架构。
13.2 连接ID管理:你的“会话令牌”
在Android的BluetoothGattServer中,每个Client连接上来,系统会通过onConnectionStateChange回调告诉你一个deviceId(其实就是BluetoothDevice对象)。但注意,这个deviceId并不是一个整数型的ID,而是一个设备对象引用。
我个人习惯的做法是:自己维护一个连接ID映射表。用一个自增的整数作为内部ID,把BluetoothDevice映射上去。这样做的好处是,后续的读写回调里,你可以快速定位到是哪个Client在操作。
// 连接ID管理器
class ConnectionManager {
private val deviceToId = HashMap<BluetoothDevice, Int>()
private val idToDevice = HashMap<Int, BluetoothDevice>()
private var nextId = 1
fun addConnection(device: BluetoothDevice): Int {
val id = nextId++
deviceToId[device] = id
idToDevice[id] = device
return id
}
fun removeConnection(device: BluetoothDevice) {
val id = deviceToId.remove(device)
id?.let { idToDevice.remove(it) }
}
fun getDeviceId(device: BluetoothDevice): Int? = deviceToId[device]
fun getDevice(id: Int): BluetoothDevice? = idToDevice[id]
}
小提示:我在项目中遇到过一个问题——设备断开重连时,系统可能会复用同一个BluetoothDevice对象。所以你的映射表一定要在断开时及时清理,否则新连接会拿到旧的ID,导致状态混乱。
13.3 并发读写处理:别让数据打架
多Client同时读写,最怕的是什么?是数据竞争。比如Client A正在写一个Characteristic,Client B也来写,结果两个数据混在一起,读出来的值不伦不类。
Android的BluetoothGattServer回调默认是跑在Binder线程池里的。也就是说,onCharacteristicWriteRequest可能同时从多个线程进来。如果你不做同步,那数据就乱套了。
我的做法是:为每个Characteristic准备一个读写锁。或者更简单一点,用一个全局的串行队列来处理所有读写请求。
// 使用协程的Mutex实现串行化处理
val mutex = Mutex()
override fun onCharacteristicWriteRequest(
device: BluetoothDevice,
requestId: Int,
characteristic: BluetoothGattCharacteristic,
preparedWrite: Boolean,
responseNeeded: Boolean,
offset: Int,
value: ByteArray
) {
// 启动协程,串行处理
CoroutineScope(Dispatchers.IO).launch {
mutex.withLock {
// 在这里处理写请求,保证同一时间只有一个请求在执行
val success = characteristic.writeValue(value)
if (responseNeeded) {
bluetoothGattServer?.sendResponse(
device, requestId,
if (success) BluetoothGatt.GATT_SUCCESS else BluetoothGatt.GATT_FAILURE,
offset, value
)
}
}
}
}
注意:千万不要在回调里直接做耗时操作!比如数据库写入、网络请求。我曾经见过一个同事在onCharacteristicWriteRequest里直接写数据库,结果导致蓝牙回调线程阻塞,整个协议栈卡死。正确的做法是:回调里只做轻量级处理,耗时操作扔到后台线程。
13.4 资源释放:有借有还,再借不难
资源释放这个话题,说白了就是“谁创建,谁销毁”。但在多连接场景下,资源往往分散在各个回调里,很容易漏掉。
我总结了一个“三清”原则:
- 清连接:Client断开时,从连接ID映射表中移除该设备。
- 清回调:取消所有针对该设备的pending通知、等待响应的请求。
- 清缓存:释放与该设备相关的临时数据、状态变量。
具体到代码,就是在onConnectionStateChange回调里,当state为BluetoothProfile.STATE_DISCONNECTED时,执行清理逻辑。
override fun onConnectionStateChange(
device: BluetoothDevice,
status: Int,
newState: Int
) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
val id = connectionManager.addConnection(device)
Log.d(TAG, "设备已连接,分配ID: $id")
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
// 清理该设备的所有资源
connectionManager.removeConnection(device)
pendingNotifications.remove(device) // 取消pending通知
pendingWrites.remove(device) // 取消pending写请求
Log.d(TAG, "设备已断开,资源已清理")
}
}
我曾经踩过一个坑:在断开回调里只清理了连接ID,忘了清理pending通知队列。结果设备重连后,旧的pending通知还在,导致新连接收到了过期的数据。从那以后,我每次断开都会做一个全量清理,宁可多清理,不可漏清理。
13.5 整体架构图:一张图看懂多连接处理
下面这张图是我自己画的多连接处理架构,你可以看到整个数据流向和各个模块的职责。
13.6 实战建议:从单连接到多连接的平滑过渡
如果你现在写的是单连接GattServer,想改成多连接,我建议你分三步走:
- 先抽象出连接管理器——把设备对象和内部ID的映射关系抽出来,这是多连接的基础。
- 再引入并发控制——用Mutex或队列把读写请求串行化,保证数据一致性。
- 最后完善资源释放——在断开回调里做全量清理,不留死角。
我的个人习惯:在开发阶段,我会在资源释放的地方加日志,打印出清理了哪些资源。这样一旦出问题,看日志就能快速定位是哪个资源没释放。嗯,这招帮我省了不少排查时间。
好了,多连接处理的核心内容就这些。说白了,就是一套“连接管理+并发控制+资源释放”的组合拳。你只要把这三点做好,多连接其实没那么可怕。
公众号:蓝海资料掘金营,微信deep3321