回调注册与注销:握紧的线总要松开

GATT 回调,说白了就是系统通知你“特征值读到了”或者“写完成”的那条电话线。你注册一个 callback,系统一有事件就打你电话。可很多人只接电话,不挂电话——结果内存里留下一堆 dangling pointer,程序跑着跑着就崩了。

这一节,我就把注册和注销的门道掰开讲。我自己踩过的坑,都会摆出来。

注册回调:你得告诉系统“我要接电话”

不同平台注册方式略有不同,但核心都一样:传一个函数指针(或对象引用)给 BLE 协议栈。拿 Android 举例:

// 定义一个回调实例
private BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
    @Override
    public void onCharacteristicRead(BluetoothGatt gatt,
                                     BluetoothGattCharacteristic characteristic,
                                     int status) {
        // 处理读取结果
    }

    @Override
    public void onCharacteristicWrite(BluetoothGatt gatt,
                                      BluetoothGattCharacteristic characteristic,
                                      int status) {
        // 处理写入结果
    }
};

// 注册回调
gatt.connectGatt(context, false, gattCallback);

那段 connectGatt 的第三个参数,就是注册。系统会把这个 callback 引用存下来。嗯,这里要注意:

重点:很多库会自己做一层包装,比如 Kotlin 的 Flow 或 RxJava。但底层一定有个原生的 callback 对象。你封装得再漂亮,最终泄漏的还是那个原生对象。

嵌入式端(比如 ESP32)注册方式更直接:

// 注册读回调
esp_ble_gatts_register_callback(gatts_event_handler);
// 注册特征值回调
esp_ble_gatts_add_char(&char_para, char_callbacks);

我见过一个项目,把 callback 注册在全局区,然后不同模块都用同一个实例,又忘了同步保护。结果回调进来,状态已经乱了。所以建议:每个连接独立注册,别共用。

注销回调:不挂电话的后果很严重

你以为注册完就完事了?不,最关键的恰恰是注销。为什么?因为 BLE 协议栈内部持有你 callback 的引用。一旦连接断开或者你不再需要了,必须显式释放。

Android 上:

// 断开连接时,一定要调用 close
if (gatt != null) {
    gatt.close();  // 这一步会释放内部回调引用
    gatt = null;
}

注意:disconnect() 不等于 close()。disconnect 只断开逻辑连接,回调仍然活着。只有 close() 才会真正注销。我曾在一个 App 里看到连续 disconnect 了三次也没 close,结果 GATT 实例泄漏了十几个,内存涨到 200MB 才 crash。

平台注册方式正确注销方式常见错误
AndroidconnectGatt()gatt.close()只 disconnect 不 close
iOS (CoreBluetooth)属性赋值断开代理,nil 化重复引用未清空
ESP32esp_ble_gatts_register_callback()esp_ble_gatts_app_unregister()直接 free 未反注册
BlueZ (Linux)g_dbus_connect_signal()g_dbus_disconnect_signal()忘记 g_signal_handler_disconnect

拿着这张表去对照你的代码,很快就能揪出问题。

对称性原则:注册和注销必须成对

我个人习惯是,每次写注册代码的同一行位置,立刻写上注销的代码。就像括号成对打一样。在实际工程里我搞过一个“回调注册表”结构:

typedef struct {
    void (*on_read)(uint8_t *data, uint16_t len);
    void (*on_write)(uint8_t status);
    bool registered;
} gatt_callback_t;

esp_err_t gatt_register_callback(gatt_callback_t *cb) {
    if (cb->registered) return ESP_ERR_INVALID_STATE;
    // 注册逻辑...
    cb->registered = true;
    return ESP_OK;
}

esp_err_t gatt_unregister_callback(gatt_callback_t *cb) {
    if (!cb->registered) return ESP_ERR_INVALID_STATE;
    // 注销逻辑...
    cb->registered = false;
    return ESP_OK;
}

用状态位 registered 来保证对称。这在多线程环境下也容易检查。你想想看,如果每个回调都有个“注册锁”,泄漏几乎不可能。

实用技巧:在析构函数或生命周期结束处,主动断言 callback 是否已被注销。比如 Android 的 onDestroy 里检查:如果 gatt 还没 close,打印堆栈。

我踩过的一个坑:嵌套注册

那是我做一款手表手环项目时遇到的。每次读特征值前,都重新注册一次回调,但忘了注销之前的。结果回调列表越积越多,一有读完成事件,旧的回调也全跑一遍,数据乱了,内存也炸了。

排查时用了一个笨方法:给每个 callback 打上唯一 ID,注册时打印 ID,注销时也打印。一跑 log,发现重复 ID 出现了一大堆。最终定位到注册逻辑没做幂等检查。

所以建议:注册前先注销旧回调;或者用引用计数管理。

知识图谱:回调生命周期

为了让你一眼看清整个流程,我画了一张图:

GATT 回调注册 / 注销生命周期 ① 注册回调 ② 系统持有引用 ③ 读写事件触发 ④ 处理回调数据 ⑤ 注销回调 ⑥ 未注销→泄漏 🔑 关键:步骤⑤必须成对出现步骤①,否则步骤⑥会导致内存泄漏 每个注册的对象在不再需要时,必须显式调用注销 API

实践检查清单

每次写完回调代码,你可以按这个检查:

  1. 是否有注册 → 必有注销?(代码里搜索 callback 注册处,看看同一作用域是否有对应的注销)
  2. 异常路径呢?(比如连接失败、超时、突然断开,注销逻辑是否覆盖到了?)
  3. 多线程下会不会重复注册?(加锁 + 状态标志位)
  4. 析构时检查了吗?(Android 的 onDestroy、C++ 的析构函数里加断点或断言)

一句话总结:注册回调就像借书,注销就是还书。不还书,图书馆会把你拉黑,你的 BLE 协议栈也会。

好了,这一节就到这里。代码里该补的注销,赶紧去补上吧。