2. GATT 对象概念

做 BLE 开发,GATT 是绕不开的核心概念。我早期刚接触蓝牙时,总把 GATT 跟 GAP 搞混。后来踩了几次坑才彻底搞明白——GATT 负责“数据怎么组织、怎么读写”,而 GAP 管的是“设备怎么发现、怎么连接”。这两者分工明确,不能混为一谈。

这章我们把 GATT 拆开揉碎,讲清楚它的角色、服务和特征。因为后面《LeakCanary 分析 GATT 对象泄漏链》的课程里,很多泄漏的根源就藏在 GATT 对象的生命周期里。你想想看,一个 Service 或 Characteristic 没被正确释放,整个蓝牙连接就可能卡住,甚至导致内存泄漏。

2.1 GATT 在 BLE 协议栈中的位置

BLE 协议栈是分层结构。从下往上:物理层 -> 链路层 -> HCI -> L2CAP -> ATT -> GATT。GATT 是最高层,直接面向开发者。

一句话总结:GATT 定义了 BLE 设备之间“数据交换的规则”——谁作服务器、谁作客户端、数据以什么格式存放、怎么读写。

我个人的习惯是,把 GATT 想象成“数据库”。服务器(Server)里存着多张表(Service),每张表里有字段(Characteristic),字段还有属性(Descriptor)。客户端(Client)去查询或写入这些字段。

GATT 协议栈角色与数据模型 应用层 (App) 例如:Android BLE API、iOS CoreBluetooth GATT 层 (Generic Attribute Profile) 角色 : Server / Client · 数据 : Service / Characteristic / Descriptor ATT (Attribute Protocol) L2CAP / HCI / Link Layer / PHY Service (服务) Characteristic (特征) Descriptor (描述符) 每个 Service 包含多个 Characteristic

2.2 GATT 角色:Server 与 Client

GATT 定义了两种角色:

  • GATT Server — 保存数据的一方。通常是某些传感器、外设。它把数据组织成 Service / Characteristic,供别人读写。
  • GATT Client — 主动读写数据的一方。通常是手机 App。它发现 Server 上的 Service,然后读取或写入 Characteristic 的值。
注意:角色与 GAP 的 Central / Peripheral 不是一回事。一个 Peripheral 设备可以同时是 GATT Server 和 GAP Peripheral;但 Central 设备通常作为 GATT Client。也有特例——比如手机作为 Peripheral 提供 Service 时,它自己就得同时跑 GATT Server。我曾在项目中写过一个 Android 设备模拟心率计的 Demo,那时候 Android 端既是 GAP Peripheral 又是 GATT Server,结果忘了解绑 Service 导致内存泄漏,费了好大劲才定位到。
表:GATT 角色与 GAP 角色的对应关系
GAP 角色 常见 GATT 角色 典型设备 内存泄漏风险点
CentralClient手机Client 端未释放已发现的 Service 引用
PeripheralServer心率带、温度计Server 端未销毁 GattService
Central (双模)亦可作为 ServerIoT 网关同一对象同时持有 Client 和 Server 引用

2.3 服务、特征与描述符

GATT 数据模型是树形结构。我习惯把这些概念记成:

  • Service(服务) — 功能模块。比如「电量服务」「设备信息服务」。每个 Service 由 16-bit 或 128-bit UUID 标识。
  • Characteristic(特征) — 具体的数据点。比如「电量百分比」。一个 Service 包含多个 Characteristic。每个 Characteristic 也有 UUID、属性(可读、可写、通知等)和一个值。
  • Descriptor(描述符) — 特征的补充说明。最常见的是 CCCD(客户端特征配置描述符),用于开启/关闭通知。
避坑指南:我以前写过一段代码,在 onServicesDiscovered 回调里直接把 BluetoothGattService 对象存成全局变量。后来发现 Activity 重建时这个 Service 对象还活着,导致 gatt.close() 后 Service 仍被引用,泄漏就发生了。正确的做法是:只保存必要的 UUID 和值引用,不要长期持有 GattService 和 GattCharacteristic 对象本身,除非你真的需要。

典型的结构代码

Android 中使用 BluetoothGatt 获取服务列表的流程:

// 假设 gatt 已经连接成功
gatt.discoverServices();
gatt.setBluetoothGattCallback(new BluetoothGattCallback() {
    @Override
    public void onServicesDiscovered(BluetoothGatt gatt, int status) {
        if (status == BluetoothGatt.GATT_SUCCESS) {
            List<BluetoothGattService> services = gatt.getServices();
            for (BluetoothGattService service : services) {
                // 建议只记录 service 的 UUID,而非整个 service 引用
                String uuid = service.getUuid().toString();
                // 处理特征...
                for (BluetoothGattCharacteristic characteristic : service.getCharacteristics()) {
                    // ...
                }
            }
        }
    }
});

2.4 为什么 GATT 对象容易泄漏?

GATT 对象泄漏的核心原因有两个:

  1. 生命周期不一致。 BluetoothGatt 是系统服务创建的 Binder 代理,它的生命周期由蓝牙连接状态决定。但开发者往往在 Activity 或 Fragment 的 onDestroy 中才去释放,如果中间重建了 Activity,旧 gatt 仍被回调持有,新 gatt 还在路上——泄漏就产生了。
  2. 回调引用泄漏。 BluetoothGattCallback 是个匿名内部类,默认持有外部类引用。如果你在 Activity 里 new 了 callback 却没及时解注册,Activity 就无法被 GC 回收。我在排查一个线上 OOM 问题时发现,用户连续开关蓝牙页面 20 次,堆里滞留了 20 个 BluetoothGattCallback 对象,每个都还牵着 Activity。
重要提醒:在 Android 10 以上,BluetoothGatt.close() 必须在主线程调用,否则会抛出异常。而且 close 后要立即将 gatt 变量置为 null,避免野指针。这看似简单,但很多人漏掉。

2.5 与 LeakCanary 的关系

LeakCanary 能检测到的 GATT 泄漏链,通常表现为:
Activity → BluetoothGattCallback → BluetoothGatt 或 Service → BluetoothGattService → GattCharacteristic。
当你看到泄漏链中包含 BluetoothGatt 或 BluetoothGattService 时,基本就是 GATT 对象没有被正确关闭或引用没清空。

接下来几章,我们会用 LeakCanary 实际抓一个 GATT 泄漏案例,一步步分析引用链。现在先把这些基础概念刻在脑子里——别等到看堆栈时才去想 Service 和 Characteristic 是什么。

一句话复习:GATT = 数据模型 + 角色分工。Server 存数据,Client 读写数据。数据以 Service 包 Characteristic,Characteristic 带 Descriptor。泄漏往往就是这些对象的生命周期管不好。