多设备连接管理:连接池设计、并发控制、设备状态机、资源释放

好,咱们进入一个比较硬核的话题——多设备连接管理。说实话,很多做 BLE 开发的朋友,一开始都是单设备连接,跑通了就觉得自己会了。但一到实际产品里,要同时连三四个设备,各种问题就冒出来了。我当年第一次做多设备项目时,就被坑得不轻。

这一章,我会把多设备连接的核心问题拆开揉碎讲清楚。你想想看,同时管理多个 BLE 连接,本质上就是四个问题:

  • 怎么存这些连接?——连接池设计
  • 怎么防止它们打架?——并发控制
  • 每个设备当前在干嘛?——设备状态机
  • 断开后怎么收拾干净?——资源释放

咱们一个一个来。

连接池设计:别用散乱的 Map,要有个池子

很多新手喜欢这么写:

val deviceMap = mutableMapOf<String, BluetoothDevice>()
val gattMap = mutableMapOf<String, BluetoothGatt>()

嗯,看着挺直接,但问题很大。两个 Map 之间没有约束,你删了 device 忘了删 gatt,内存泄漏就来了。我个人习惯的做法,是封装一个 连接池对象

核心思路:一个设备地址对应一个连接槽位,槽位里包含 GATT 引用、状态、回调、定时器等所有资源。

来看代码:

data class ConnectionSlot(
    val address: String,
    var gatt: BluetoothGatt? = null,
    var state: ConnectionState = ConnectionState.DISCONNECTED,
    var retryCount: Int = 0,
    var lastActiveTime: Long = 0L,
    val callback: BleGattCallback? = null
)

class ConnectionPool(private val maxConnections: Int = 7) {

    private val slots = ConcurrentHashMap<String, ConnectionSlot>()

    fun allocate(address: String): Boolean {
        if (slots.size >= maxConnections) {
            // 这里可以触发 LRU 淘汰策略
            return false
        }
        return slots.putIfAbsent(address, ConnectionSlot(address)) == null
    }

    fun getSlot(address: String): ConnectionSlot? = slots[address]

    fun release(address: String) {
        slots.remove(address)?.let { slot ->
            slot.gatt?.close()
            // 清理回调等资源
        }
    }
}

这里有个细节:ConcurrentHashMap 是必须的。因为 BLE 回调可能来自不同线程,普通 HashMap 在多线程下会出诡异问题。我曾经排查过一个 Bug,就是 HashMap 在并发 put 时导致死循环,CPU 飙到 100%,查了两天才定位到。

并发控制:别让 GATT 操作撞车

Android BLE 有个很坑的设定——同一时刻只能有一个 GATT 操作在进行。什么意思?就是你正在 writeCharacteristic,这时候又调了 readRssi,后者会被静默丢弃,连错误回调都没有。

所以,并发控制的核心就是:每个设备一个操作队列,串行执行

我建议这样设计:

class GattOperationQueue(private val gatt: BluetoothGatt) {

    private val queue = LinkedList<BleOperation>()
    private var isBusy = false

    @Synchronized
    fun enqueue(op: BleOperation) {
        queue.add(op)
        processNext()
    }

    @Synchronized
    private fun processNext() {
        if (isBusy || queue.isEmpty()) return
        isBusy = true
        val op = queue.poll()
        op.execute(gatt) {
            @Synchronized
            fun onComplete() {
                isBusy = false
                processNext()
            }
        }
    }
}

你可能会问:那多个设备之间呢?设备之间是独立的,每个设备有自己的 GATT 对象,所以可以并行操作。真正要防的是 同一个设备上的操作重叠

小技巧:我习惯在 enqueue 时加一个超时机制。如果某个操作 10 秒还没完成,就强制清空队列并重置 GATT。因为有些低端蓝牙芯片会卡死,不发回调也不报错。

设备状态机:每个设备都得知道自己在哪

多设备管理最怕什么?怕你不知道某个设备当前处于什么阶段。是正在连接?已经连上了?还是在做服务发现?

我见过有人用几个 boolean 变量来标记:isConnectingisDiscovered……结果状态一多就乱套了。正确的做法是用 枚举状态机

enum class ConnectionState {
    DISCONNECTED,
    CONNECTING,
    CONNECTED,
    SERVICE_DISCOVERING,
    SERVICE_DISCOVERED,
    READY,
    DISCONNECTING
}

状态转换图大概是这样的:

DISCONNECTED CONNECTING CONNECTED SERVICE_DISCOVERING SERVICE_DISCOVERED READY DISCONNECTING connect() onConnected discoverServices() onServicesDiscovered 初始化完成 断开/超时 onDisconnected

有了这个状态机,你在回调里就可以做精确判断:

override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
    val slot = pool.getSlot(gatt.device.address) ?: return
    when (newState) {
        BluetoothProfile.STATE_CONNECTED -> {
            if (slot.state == ConnectionState.CONNECTING) {
                slot.state = ConnectionState.CONNECTED
                gatt.discoverServices()
            } else {
                // 异常状态,断开重连
                gatt.disconnect()
            }
        }
        BluetoothProfile.STATE_DISCONNECTED -> {
            slot.state = ConnectionState.DISCONNECTED
            // 触发重连或资源释放
        }
    }
}

注意:状态机一定要处理异常跳转。比如你在 CONNECTED 状态突然收到 DISCONNECTED 回调,这是正常的。但如果你在 DISCONNECTED 状态收到 onServicesDiscovered,那就有问题了。我建议每个状态转换都加 assert 或日志,方便排查。

资源释放:断开连接只是第一步

很多人以为调了 gatt.disconnect() 就完事了。其实不是。完整的资源释放流程应该是:

  1. 停止所有操作:清空 GATT 操作队列,取消未完成的定时器。
  2. 断开 GATT 连接:调用 gatt.disconnect()
  3. 关闭 GATT 引用:调用 gatt.close(),释放底层资源。
  4. 清理回调:移除注册的 BroadcastReceiver 或回调引用,防止内存泄漏。
  5. 从连接池移除:调用 pool.release(address)

我建议封装一个统一的方法:

fun releaseDevice(address: String) {
    val slot = pool.getSlot(address) ?: return

    // 1. 停止队列
    slot.gatt?.let { gatt ->
        operationQueues[address]?.clear()
    }

    // 2. 断开
    slot.gatt?.disconnect()

    // 3. 关闭
    slot.gatt?.close()

    // 4. 清理回调
    slot.callback?.let { 
        // 从注册表中移除
    }

    // 5. 从池中移除
    pool.release(address)
}

一个血的教训:我曾经在 onDisconnected 回调里直接调了 gatt.close(),结果发现 close 之后系统还会再回调一次 onDisconnected,导致空指针。正确的做法是加一个标志位,或者用 Handler 延迟执行 close。

连接池的淘汰策略

Android 系统对 BLE 连接数有限制,一般是 7 个左右(不同厂商不一样)。所以连接池满了之后,你需要决定踢掉谁。

我常用的策略是 LRU(最近最少使用)

fun evictIfNeeded(): String? {
    if (slots.size < maxConnections) return null
    return slots.minByOrNull { it.value.lastActiveTime }?.key
}

当然,你也可以根据业务优先级来。比如某个设备是核心设备,永远不踢;其他设备按时间淘汰。这个就看具体场景了。

总结一下

多设备连接管理,说白了就是三个字:池、队、态

  • :用 ConnectionPool 统一管理所有连接资源。
  • :每个设备一个操作队列,保证串行执行。
  • :用状态机精确跟踪每个设备的生命周期。

再加上完善的资源释放机制,基本就能应对大部分多设备场景了。嗯,这一块内容比较多,但都是实战中必须啃下来的硬骨头。你可以在自己的项目里先搭一个简单的连接池,然后逐步加上队列和状态机,慢慢就会顺手了。


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