回调注册与注销:握紧的线总要松开
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 引用存下来。嗯,这里要注意:
嵌入式端(比如 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。
| 平台 | 注册方式 | 正确注销方式 | 常见错误 |
|---|---|---|---|
| Android | connectGatt() | gatt.close() | 只 disconnect 不 close |
| iOS (CoreBluetooth) | 属性赋值 | 断开代理,nil 化 | 重复引用未清空 |
| ESP32 | esp_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 打上唯一 ID,注册时打印 ID,注销时也打印。一跑 log,发现重复 ID 出现了一大堆。最终定位到注册逻辑没做幂等检查。
所以建议:注册前先注销旧回调;或者用引用计数管理。
知识图谱:回调生命周期
为了让你一眼看清整个流程,我画了一张图:
实践检查清单
每次写完回调代码,你可以按这个检查:
- 是否有注册 → 必有注销?(代码里搜索 callback 注册处,看看同一作用域是否有对应的注销)
- 异常路径呢?(比如连接失败、超时、突然断开,注销逻辑是否覆盖到了?)
- 多线程下会不会重复注册?(加锁 + 状态标志位)
- 析构时检查了吗?(Android 的 onDestroy、C++ 的析构函数里加断点或断言)
一句话总结:注册回调就像借书,注销就是还书。不还书,图书馆会把你拉黑,你的 BLE 协议栈也会。
好了,这一节就到这里。代码里该补的注销,赶紧去补上吧。