BLE 开发基础:协议栈、API 与核心回调

本章定位:理解 BLE 通信的底层脉络,掌握 Android 侧的核心 API 和回调接口。这是后续协程实战的基础——回调在哪儿泄漏、协程在哪儿衔接,都依赖这里讲清楚。

一、BLE 协议栈概述

BLE 协议栈分好几层,说白了就是从物理信号一步步往上包装成我们能调用的 Service、Characteristic。我刚开始做 BLE 时总觉得这些层太抽象,直到在项目里踩了一次“Attribute 长度超限导致连接断开”的坑,才老实回去啃了一遍协议。

从下到上大概是这样:

  • 物理层(PHY):2.4GHz 频段,40 个信道,3 个广播信道 + 37 个数据信道。速率 1Mbps(还有 2Mbps 和长距离模式)。嗯,这一层我们 App 工程师基本碰不到。
  • 链路层(LL):负责收发数据包、连接状态管理、重传和流控。设备角色(广播者、扫描者、主设备、从设备)是在这层定义的。
  • HCI(Host Controller Interface):协议栈在主机(SoC 里的上层)和控制器(Bluetooth 芯片)之间的分界线。Android 系统封装了这部分,我们不用操心。
  • L2CAP(逻辑链路控制与适配协议):负责数据包的分段重组、多路复用。我见过有人在这里搞混了 MTU,结果数据总是丢一半。
  • ATT(属性协议):定义了属性(Attribute)的读写、通知等操作。每个属性有 handle、UUID、权限。说白了,BLE 通信就是客户端(GATT Client)访问服务器(GATT Server)的属性。
  • GATT(通用属性协议):在 ATT 之上构建了 Profile、Service、Characteristic、Descriptor 的层级结构。我们平时调用的 onServicesDiscovered 就是解析 GATT 层级。

避坑指南:我曾经在一个项目中把所有 Characteristic 的 UUID 都写死在代码里,结果换了不同批次的设备后 UUID 变了,导致服务发现后一直找不到特征值。后来我养成了习惯——先打印所有 Service 和 Characteristic,再动态匹配。

二、Android BLE API 介绍

Android 从 4.3(API 18)开始就提供了原生 BLE API,虽然这么多年没太大变化,但该有的都有了。我们最常用的几个类:

类名 作用 个人使用小建议
BluetoothAdapter 手机蓝牙适配器,获取本机蓝牙状态,开始扫描等 别忘了检查 isEnabled(),我曾经在模拟器上跑了一下午,发现是蓝牙没开……
BluetoothLeScanner BLE 扫描专用(替代旧的 startLeScan) 建议用 ScanSettings 设置低功耗模式,省电效果明显
BluetoothDevice 代表一个远程蓝牙设备,用于发起连接 设备缓存问题:相同地址的设备可能是不同的实体,我用 getType 区分经典蓝牙和 BLE
BluetoothGatt BLE GATT 客户端,核心操作对象 一个 thread-safe 的问题:所有操作实际在主线程回调,但连接方法要在非 UI 线程调用
BluetoothGattCallback 异步回调接口,通知各种事件 协程泄漏的重灾区:回调对象持有外部引用,且线程切换容易出问题

我记得第一次使用 BluetoothLeScanner 时被它的 ScanCallback 搞晕了——回调是异步的,而且不带生命周期。在 Fragment 里扫描,如果不取消,activity 销毁后还会收到结果,这就是泄漏的源头之一。

三、核心回调接口

所有 BLE 操作几乎都是异步的,结果通过 BluetoothGattCallback 返回。咱们来逐个拆解:

1. 连接状态回调

override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
    when (newState) {
        BluetoothProfile.STATE_CONNECTED -> { /* 连接成功,调用 discoverServices() */ }
        BluetoothProfile.STATE_DISCONNECTED -> { /* 断开,清理资源 */ }
    }
}

这里有个坑:status 为 133 表示连接堵塞,常见原因是对端设备关闭时未正确处理。我有个项目就是因为没处理 status != GATT_SUCCESS 的情况,导致重连逻辑永远卡死。

2. 服务发现回调

override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
    if (status == BluetoothGatt.GATT_SUCCESS) {
        // 遍历 gatt.services 拿到你要的 Service 和 Characteristic
    }
}

服务发现之后才能进行读写。为什么?因为 ATT 协议规定必须先知道 Server 上有什么属性,才能发起访问。如果你跳过发现直接读写,会收到 0x01(无效句柄)的错误。我当初调试了两个小时才发现……

3. 特征值读写回调

override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
    // characteristic.value 就是数据
}

override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
    // 写完成
}

注意:读写操作是顺序的,如果同时发起多个读写,可能会被底层排队,也可能直接失败。我在做批量 OTA 升级时,就设计了一个任务队列来保证串行执行。

4. 通知与指示回调

override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
    // 收到通知/指示,数据在 characteristic.value
}

这是 BLE 最常用的数据接收方式。启用通知需要先写入 CCCD(Client Characteristic Configuration Descriptor):

val descriptor = characteristic.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)

个人习惯:我总会在 onCharacteristicChanged 回调里先检查 characteristic.uuid,防止多个特征值共用同一个通知句柄时数据混淆。

5. 其他关键回调

  • onDescriptorRead / onDescriptorWrite:描述符读写结果,常用于读取 CCCD 状态。
  • onReadRemoteRssi:读取远程信号强度,可用于判断设备远近。
  • onMtuChanged:MTU 协商结果。默认 23 字节,协商后可提升到 512 字节,大数据传输利器。

四、知识体系与流程(SVG)

BLE 协议栈层级与 Android 回调映射 物理层 (PHY) 链路层 (LL) HCI L2CAP ATT (属性协议) GATT Android 蓝牙框架 BluetoothAdapter / BLE Scanner 扫描回调 (ScanCallback) 连接回调 onConnectionStateChange 服务发现 onServicesDiscovered 读写回调 onCharacteristicRead/Write 通知回调 onCharacteristicChanged 📌 分组说明: 左列(蓝/青/紫/红):BLE 协议栈层级,底层到上层 中间(橙):Android 系统蓝牙框架封装,提供 API 接口 右列(绿):开发者实现的 BluetoothGattCallback 回调,所有通信结果在此异步返回

我自己的理解方法:把协议栈想象成邮政系统——物理层是马路(信道),链路层是邮车(数据包收发),HCI 是分拣中心,L2CAP 是把大件拆成小包裹,ATT 是每个包裹上的地址标签(属性),而 GATT 就是包裹里的物品清单(Service & Characteristic)。回调则是门铃——门铃响了,我们去取包裹。

五、回调线程与协程泄漏的关联

说到这儿,你可能已经嗅到问题所在了:所有回调都发生在系统 Binder 线程池,而不是主线程,也不是我们自己的协程上下文。如果我们把回调对象用匿名内部类实现,它就会隐式持有外部类的引用——比如 Activity、Fragment 甚至 ViewModel。当 Activity 销毁后,系统仍然可能因为 BLE 事件未处理完而调用这些回调,导致外部对象无法被回收。这就是经典的“内存泄漏”。

我曾经犯过的错:我在 Service 中持有一个 BluetoothGatt 和它的回调匿名类,结果 Service 因为 BLE 连接未关闭而一直在运行,直到 OOM。后来用 WeakReference 和协程的 Job 取消机制才彻底解决——嗯,那就是后面章节的重点了。