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 整体架构图:一张图看懂多连接处理

下面这张图是我自己画的多连接处理架构,你可以看到整个数据流向和各个模块的职责。

GattServer多连接处理架构 多个Client Client A (ID:1) Client B (ID:2) Client C (ID:3) ... 连接请求 GattServer核心 连接ID管理器 读写请求队列 通知/指示管理 资源释放器 分发 回调处理层 onConnectionStateChange onCharacteristicReadRequest onCharacteristicWriteRequest onNotificationSent 调用 业务逻辑处理

13.6 实战建议:从单连接到多连接的平滑过渡

如果你现在写的是单连接GattServer,想改成多连接,我建议你分三步走:

  1. 先抽象出连接管理器——把设备对象和内部ID的映射关系抽出来,这是多连接的基础。
  2. 再引入并发控制——用Mutex或队列把读写请求串行化,保证数据一致性。
  3. 最后完善资源释放——在断开回调里做全量清理,不留死角。

我的个人习惯:在开发阶段,我会在资源释放的地方加日志,打印出清理了哪些资源。这样一旦出问题,看日志就能快速定位是哪个资源没释放。嗯,这招帮我省了不少排查时间。

好了,多连接处理的核心内容就这些。说白了,就是一套“连接管理+并发控制+资源释放”的组合拳。你只要把这三点做好,多连接其实没那么可怕。


公众号:蓝海资料掘金营,微信deep3321