☰
Android BLE 4.2实战:GATT队列、CCCD订阅与133排障
2026/10/26 2:44:57 网站建设 项目流程

简介:面向Android蓝牙开发初学者的一份BLE 4.2数据透传演示工程,围绕低功耗蓝牙的广播扫描、设备连接、服务发现与数据收发等核心环节组织代码,并尽量简化调用流程,方便新手理解GATT通信的基本路径、回调时序与常见接口用法。压缩包共1244个文件,约9.24MB,以Java源码、XML资源与配置文件、Gradle构建脚本为主,同时包含DEX、CLASS、flat等编译产物及JSON、properties等工程元数据,另附APK便于安装后对照观察;源码、资源与构建输出分层存放,适合按模块检索。已有510人学习下载,可作为课程实验、课设原型或BLE功能预研的参考样例,帮助读者快速定位扫描连接逻辑、梳理特征值读写与通知订阅的数据链路,并对照工程结构完成二次修改、权限适配和调试排错。对于需要将蓝牙外设接入Android应用、验证透传协议或准备演示的读者,也可据此拆解从扫描到收发的完整链路,参考其中的权限声明、线程切换与生命周期处理思路。

1. 从一条连不上的心率带说起:bluetooth4.zip 到底是什么

手上一块支持 Android BLE 的调试板,手机扫得到、连上三秒就断,日志里只留一个 status=133。换两台机器,一台正常一台必挂,这种玄学现场在蓝牙 4.2 的 Android 项目里几乎是日常。bluetooth4.zip 是一份 BLE 4.2 的工程源码包,把扫描、连接、GATT 服务发现、特征读写、CCCD 通知订阅、MTU 协商和系统权限适配拆成独立模块,主流程拉起来就能跑。它适合两类人:刚接手 BLE 模块、被回调绕晕的新手,以及想把现有代码从“能连上”改到“断线可恢复”的熟手。下面的拆解按真实接入顺序走一遍,先看骨架,再落到参数,最后收在排障上。

2. 拆开压缩包先看骨架:BLE 4.2 的四层结构与一次完整握手

BLE 在 Android 上的调用是异步回调驱动的,一次完整的设备接入要穿过系统蓝牙栈、GATT 客户端、应用层回调三层,任何一层状态不同步,表现都是“连不上”或者“连上没数据”。这个包的价值不在代码量,而在于它把这条链路上的每一步都切成了可单独观察的节点,出问题时能定位到具体是哪一层卡住,而不是对着一坨onConnectionStateChange猜。

2.1 模块划分:从 BleClient 到 GattQueue 的职责边界

拿到包先别急着跑,把目录过一遍比直接编译划算。BLE 项目最常见的失败是把扫描、连接、读写、通知全部塞进一个 Activity 或者一个 Manager 里,代码跑得通,但一旦要支持多设备或者断线重连就彻底失控。这个包按职责切成了几块,每个类只干一件事,调用方向是单向的,不会出现 A 调 B、B 又回头调 A 的循环依赖。

模块职责对外暴露的关键入口
BleScanner扫描开关、结果去重、超时停止start/stop、onScanResult 回调
BleClient单设备连接生命周期管理connect、disconnect、close
GattQueueGATT 操作串行化与超时重试enqueue、onOperationComplete
BleCallback把系统回调翻译成业务事件onConnected、onNotify、onError
UuidTable服务与特征 UUID 常量集中管理静态常量

这张表里最值得盯的是 GattQueue 和 BleCallback 两行。前者决定了读写会不会互相打架,后者决定了业务层拿到的到底是“系统回调”还是“可用事件”。很多现成示例代码在这两块是空的,直接让业务层去实现BluetoothGattCallback,结果就是每个页面都要自己判断 status 码,重复劳动还容易漏分支。包里把它们抽出来的做法,本质上是把“系统语义”翻译成“业务语义”,这层翻译做不做,直接决定后面加功能是改一处还是改十处。

需要说明的是,这份包不包含任何厂商私有协议的解析逻辑,也不带 UI。它给的是标准 GATT 流程的骨架,心率、血压、自定义透传这些具体服务,得自己按 UUID 往里填。所以如果你的诉求是“找个能直接连某品牌手环的库”,它会让你失望;但如果你的诉求是“从零搭一个能撑住多设备、能断线重连的 BLE 层”,它省掉的是最容易埋雷的那部分。

2.2 为什么 GATT 操作必须串行:一条只有单车道的路

Android 的BluetoothGatt有一个很硬的约束:同一时刻只能有一个未完成的操作。你在onCharacteristicWrite还没回来的时候发起第二次writeCharacteristic,系统不会排队,它直接返回 false,而且不报错、不抛异常,表现就是“这条数据丢了”。这个坑在批量写配置的时候特别容易出现,尤其是开启通知之后立刻写 CCCD 描述符,十次里能挂个两三次。

包的解法是维护一个ArrayDeque加一个busy标志位,所有 GATT 操作先入队,回调回来再出队放行下一个。

// GattQueue.java:所有 GATT 操作走同一个入口,保证同一时刻只有一个在途操作 public class GattQueue { private final Deque<GattOp> pending = new ArrayDeque<>(); private boolean busy = false; private final Handler handler = new Handler(Looper.getMainLooper()); public void enqueue(GattOp op) { pending.offer(op); drain(); } private void drain() { if (busy || pending.isEmpty()) return; busy = true; final GattOp op = pending.peek(); // 每个操作挂一个超时兜底,防止回调永远不来把队列堵死 handler.postDelayed(() -> { if (busy && pending.peek() == op) { failAndNext(op, "operation timeout"); } }, op.timeoutMs); if (!op.execute()) { // execute 返回 false 说明底层拒绝执行 failAndNext(op, "execute rejected"); } } public void onOperationComplete() { // 由 BluetoothGattCallback 各分支调用 handler.removeCallbacksAndMessages(null); pending.poll(); busy = false; drain(); } }

这段代码有三个关键点。busy标志是互斥锁,pending.peek()而不是poll()是因为操作要等回调确认之后才算完成,提前出队会导致失败时无法重试。超时时间timeoutMs一般按操作类型给不同的值:读特征值 2 秒、写特征值 3 秒、写描述符 5 秒、服务发现 10 秒,服务发现给得宽是因为部分低端机在服务数量多的时候确实慢。failAndNext里要做两件事,一是把失败事件抛给上层,二是出队并重置busy,否则一次超时会让整条链路永久卡死。

execute()返回 false 的情况必须处理,这是很多人漏掉的分支。典型场景是特征值不支持写、GATT 已经断开、或者BluetoothGatt处于 busy 状态,三种情况返回的都是 false,但原因完全不同。包里的做法是在execute()内部先做属性位判断,把不合法的操作在入队前就拦掉,剩下返回 false 的基本都是状态问题,可以直接触发重连流程。

这个队列的代价是吞吐量。串行意味着 20 字节一次的通知回包,一秒最多也就几十条,如果你的业务是 OTA 升级这种要跑几百 KB 的场景,得在队列之上再加一层分包和滑动窗口,不能指望队列本身提速。这一点在选型时要清楚:队列解决的是“不丢”,不是“快”。

2.3 跑起来:SDK 版本、权限清单与第一段扫描代码

环境上没什么特殊要求,Android Studio 随便一个近两年的版本就能开,Gradle 走默认的 AGP 即可。真正需要改的是minSdk和权限声明,这两处改错,代码再对也扫不到设备。

// app/build.gradle android { compileSdk 34 defaultConfig { minSdk 21 // BLE 4.2 的 central 模式在 21 以上才稳定 targetSdk 34 } }

minSdk卡在 21 是有原因的。18 到 20 虽然 API 上有BluetoothLeScanner,但ScanSettings的几个模式和TRANSPORT_LE参数都不完整,贸然降到 18 会逼你写一堆版本分支,收益极低。当前市场存量里 21 以下的机器基本可以放弃。

权限是最容易翻车的地方,因为 Android 12 前后是两套完全不同的模型。12 之前靠BLUETOOTH加BLUETOOTH_ADMIN再加定位权限,12 开始拆成了BLUETOOTH_SCAN和BLUETOOTH_CONNECT,而且扫描权限可以用neverForLocation标记来摆脱定位权限的绑定。

<!-- AndroidManifest.xml:新旧两套权限同时声明,靠 maxSdkVersion 隔离 --> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

neverForLocation这个标记要慎用。加上它,应用在 12 以上就不需要定位权限了,但代价是系统会过滤掉一部分扫描结果,如果你的设备广播里带了位置相关的数据,可能会扫不到。稳妥做法是先不加,等确认扫描行为正常之后再评估。另外maxSdkVersion="30"不能省,否则 12 以上的机器会同时要求旧权限,用户看到两个蓝牙授权弹窗会直接卸载。

跑通第一段扫描只需要几行:

// BleScanner.java:最小可用的启动扫描 BluetoothManager manager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE); BluetoothAdapter adapter = manager.getAdapter(); if (adapter == null || !adapter.isEnabled()) { callback.onError("bluetooth disabled"); return; } BluetoothLeScanner scanner = adapter.getBluetoothLeScanner(); ScanSettings settings = new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 高占空比,适合短时扫描 .setReportDelay(0) // 0 表示逐条回调,不要批量 .build(); scanner.startScan(null, settings, scanCallback); // null 表示不过滤

三个参数分别对应三件事:SCAN_MODE_LOW_LATENCY决定扫描窗口的占空比,高占空比扫得快但耗电;reportDelay设成 0 是逐条回调,设成大于 0 会批量攒一波再给你,调试阶段一定用 0,否则你会以为设备没扫到;startScan的第一个参数传 null 表示不过滤,先确认能扫到再收窄条件。扫描不要一直开着,包里BleScanner默认带 15 秒自动停止,长时间扫描在部分机型上会被系统限流,反而越扫越少。

3. 扫描与连接:三个 ScanSettings 参数和一次 connectGatt 的取舍

扫描和连接是 BLE 里最容易被写成“能跑就行”的两段,但真机上的差异几乎全在这两块。同一份代码在不同机型上扫到的设备数量能差一半,连接成功率能差三成,原因基本都能追到参数和通道选择上。这部分把能调的旋钮一个个拧一遍,说清楚每个值改了会发生什么。

3.1 ScanMode 与 reportDelay:扫得慢和扫得漏是两回事

ScanSettings里真正影响行为的只有三个:scanMode、reportDelay、matchMode。其余参数在 4.2 设备上基本不起作用。

scanMode有三档。SCAN_MODE_LOW_POWER是低占空比,扫描窗口短、间隔长,适合后台长时间挂着找设备;SCAN_MODE_BALANCED是折中;SCAN_MODE_LOW_LATENCY是高占空比,几乎一直在扫,功耗高但发现速度最快。这里的取舍不是“哪个更好”,而是“你的场景允许扫多久”。如果用户在页面上点一下“搜索设备”,扫描时长通常不超过 15 秒,直接上 LOW_LATENCY;如果是后台配网那种要挂几分钟的场景,用 LOW_POWER 更合适,但要接受发现延迟可能到十几秒。

reportDelay是个反直觉的参数。大于 0 时系统会把扫描结果攒起来批量回调,看起来省了回调次数,实际上把“实时发现”变成了“延迟发现”,而且批量回调里拿到的 RSSI 是最后一次的值,做距离估算会失真。除了要做长时间信号强度统计的场景,一律填 0。

matchMode配合setCallbackType使用,MATCH_MODE_AGGRESSIVE会在每次收到广播包时都回调,MATCH_MODE_STICKY只在你第一次匹配时回调。前者适合需要持续读 RSSI 的场景,后者适合只关心“有没有出现过”。这两项在 Android 8 以下有兼容性问题,包里做了版本判断,低于 O 的机器直接跳过设置。

3.2 ScanFilter 的三种写法与设备名过滤的边界

startScan的第一个参数支持List<ScanFilter>,可以按服务 UUID、设备名、MAC 地址三种方式过滤。用系统过滤的好处是省电,不符合条件的广播包不会唤醒你的进程;坏处是过滤条件写错时你完全不知道问题出在哪,因为回调根本不会触发。

按服务 UUID 过滤是最可靠的方式,前提是设备的广播包里真的带了 Service UUID,很多低成本模块为了省电会把 128 位 UUID 省掉,只广播 16 位的,这时候用完整的 128 位 UUID 去匹配就会漏。稳妥做法是先不过滤扫一轮,把回调里的getServiceUuids()打印出来看实际广播了什么,再据此收窄。

按设备名过滤要小心大小写和截断。setDeviceName是精确匹配,不是前缀匹配,设备名带空格或者固件版本号都会导致匹配失败。真要按前缀过滤,只能在onScanResult里自己判断,系统层不支持。MAC 地址过滤最简单直接,但 Android 10 之后随机 MAC 普及,很多设备每次广播的地址都在变,这条路只对固定地址的老设备有效。

包里的BleScanner默认给的是“不过滤 + 回调里做二次筛选”的组合,虽然耗电略高,但调试阶段能看到的设备最全。等业务稳定了再往回加过滤条件,这个顺序不要反。

3.3 connectGatt 的 autoConnect、TRANSPORT_LE 与超时兜底

连接这步只有一个 API,但参数选错的表现差异极大。

// BleClient.java:连接入口,按版本选择带 transport 的重载 BluetoothGatt gatt; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // autoConnect=false 表示直连,超时由系统控制(一般 30 秒内) // TRANSPORT_LE 强制走低功耗通道,双模设备上必须显式指定 gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE); } else { // M 以下没有 transport 参数,双模设备可能连到经典蓝牙通道上 gatt = device.connectGatt(context, false, callback); }

autoConnect是第一个要做的决定。传 false 是直接连接,系统会在后台尝试,超时时间大约 30 秒,失败快、反馈明确,适合用户主动点“连接”的场景。传 true 是自动连接,系统会一直等到设备出现为止,没有超时,适合“设备离开范围后回来自动重连”的场景。但 autoConnect=true 在部分机型上有已知问题:连接建立后第一次服务发现可能拿不到完整服务列表,需要手动调一次refresh()。包里没有用 autoConnect,重连逻辑是自己写的指数退避,可控性更好。

TRANSPORT_LE这个参数容易被忽略,直到你遇到双模设备。双模设备同时支持经典蓝牙和 BLE,如果不指定 transport,系统可能走经典蓝牙通道连接,结果就是connectGatt成功、onConnectionStateChange返回 CONNECTED,但服务发现永远是空的。这个现象极具迷惑性,因为日志上看连接是成功的。M 以下的机器没有这个参数,只能通过设备端禁用经典蓝牙来规避。

连接超时必须有兜底。系统的 30 秒太长,用户等不了,包里在connect之后启动一个 10 秒的计时器,超时直接gatt.close()并回调失败。这里有个顺序问题:先disconnect()再close(),只调close()在部分机型上会留下僵尸连接,占着系统仅有的几个 GATT 客户端名额,连多了之后新连接会全部失败。

4. GATT 读写与通知:把 read/write/notify 收进一个状态机

连上之后才是真正的工作量。服务发现、特征值枚举、属性判断、描述符写入、通知订阅、MTU 协商,这六步之间有时序依赖,顺序错了就是“连上了但没数据”。这部分按调用顺序拆开讲,每一步都给出可对照的代码和判断条件。

4.1 特征值属性位:先判断 PROPERTY 再决定调哪个 API

BluetoothGattCharacteristic有一个getProperties()返回位掩码,这个值必须看。常见的错误是看到特征值就调writeCharacteristic,结果返回 false 或者回调 status=3(写不支持),因为那个特征值只支持读或者只支持通知。

属性位含义对应 API
PROPERTY_READ可读readCharacteristic
PROPERTY_WRITE可写,有响应writeCharacteristic(默认 WRITE_TYPE_DEFAULT)
PROPERTY_WRITE_NO_RESPONSE可写,无响应setWriteType(WRITE_TYPE_NO_RESPONSE)
PROPERTY_NOTIFY支持通知setCharacteristicNotification + 写 CCCD
PROPERTY_INDICATE支持指示同上,CCCD 写 ENABLE_INDICATION_VALUE

写操作要区分有没有响应。WRITE_TYPE_DEFAULT会等设备回 ACK,慢但可靠,适合配置类数据;WRITE_TYPE_NO_RESPONSE写完就走,快但丢了不知道,适合高频传感器数据。选哪种不看喜好,看特征值的属性位支持哪个,两个都支持时,配置写用 DEFAULT,数据流用 NO_RESPONSE。

读操作有一个坑:readCharacteristic返回 true 只代表请求发出去了,值要等onCharacteristicRead回调里取,而且必须用回调参数里的 characteristic 对象,不能用你之前保存的那个引用。部分机型上这两个对象不是同一个实例,用旧引用读到的值是上次的缓存。

4.2 CCCD 订阅的两步走:setCharacteristicNotification 与 writeDescriptor

订阅通知是所有 BLE 功能里最容易少写一步的地方。完整流程是两步,缺一不可。

// BleClient.java:开启通知的标准两步 private static final UUID CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); public boolean enableNotify(BluetoothGattCharacteristic ch) { // 第一步:告诉本地 GATT 客户端,这个特征值的通知我要收 boolean ok = gatt.setCharacteristicNotification(ch, true); if (!ok) return false; // 第二步:写 CCCD 描述符,告诉设备端开始发 BluetoothGattDescriptor desc = ch.getDescriptor(CCCD); if (desc == null) return false; // 设备没提供 CCCD,无法订阅 desc.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); return gatt.writeDescriptor(desc); // 返回值只代表请求已发出 }

第一步只影响本地,不改设备行为;第二步才真正让设备开始推送。只做第一步的现象是:onCharacteristicChanged永远不触发,但日志上看不出任何错误。只做第二步的现象是:设备在发,本地收不到。两个都做了但顺序反了,在部分机型上第二步会失败。

writeDescriptor的返回值同样只代表请求入队成功,真正结果在onDescriptorWrite里,status 是 0 才算订阅成功。包里把这一步放进了 GattQueue,确保它前面没有未完成的读操作,因为读和写描述符并发是 status=133 的高发场景。

还有一个细节:如果特征值同时支持 NOTIFY 和 INDICATE,CCCD 要写对应的值,ENABLE_NOTIFICATION_VALUE是 0x0001,ENABLE_INDICATION_VALUE是 0x0002。写错了设备不会报错,只是不发数据。

4.3 MTU 协商与分包:247 不是想要就能要

BLE 4.2 的默认 ATT MTU 是 23 字节,减去 3 字节头,实际能带的业务数据是 20 字节。要传更长的数据就得协商 MTU。

// 在服务发现成功之后、订阅通知之前调用 private static final int REQUEST_MTU = 247; boolean requested = gatt.requestMtu(REQUEST_MTU); // 结果在 onMtuChanged(BluetoothGatt gatt, int mtu, int status) 里

请求 247 不是一定能拿到 247。设备端和系统都会做限制,最终值以onMtuChanged回调里的mtu为准,常见结果是 23、185、247、517 这几档。包里在回调里把实际 MTU 存下来,后面所有分包按mtu - 3计算,而不是按请求值算,否则会出现“以为能发 244 字节,实际只发了 20 字节,剩下的静默丢弃”。

请求 MTU常见实际值单包有效载荷适用场景
不请求2320 字节简单开关量、低频小数据
100100 或 2397 或 20中等长度配置下发
247247、185 或 23244、182 或 20固件升级、批量数据
517517、247 或 23514、244 或 20仅部分新设备支持

MTU 协商要在服务发现之后做,因为协商本身也是一个 GATT 操作,会占用队列。协商失败不影响后续流程,按默认 23 走就行,只是分包多一些。另外,MTU 是连接级别的,断开重连要重新协商,包里在onConnectionStateChange的 CONNECTED 分支里清空了缓存的 MTU 值,避免用旧值分包。

4.4 队列落地:把 pending 操作和超时重试写在一起

把前面几块串起来,就是一个完整的操作描述对象。

// GattOp.java:一次 GATT 操作的最小描述 public abstract class GattOp { public final String tag; // 用于日志定位,比如 "read:battery" public final int timeoutMs; // 超时时间,按操作类型给 public int retryLeft; // 剩余重试次数 public GattOp(String tag, int timeoutMs, int retry) { this.tag = tag; this.timeoutMs = timeoutMs; this.retryLeft = retry; } // 返回 false 表示底层拒绝执行,由队列直接判失败 public abstract boolean execute(); // 由具体回调分支调用,status 决定是成功还是重试 public abstract void onResult(int status); }

这个抽象的价值在于把“重试策略”集中到一处。读特征值和写描述符的重试次数不一样,读可以重试 2 次,因为幂等;写不能随便重试,因为设备端可能已经收到并执行了,重试会造成重复写入。这个区别放在GattOp的构造参数里,比散在各处判断清晰得多。日志里带上tag之后,一次失败的完整链路能在 logcat 里一眼串起来:入队、执行、超时、重试、成功或者放弃。

5. 避坑与排查:五条真机翻车记录

下面这五条都是按“现象、原因、解决”整理的实际发生过的记录,覆盖了权限、连接、通知、资源四类问题。BE 调试有个特点:日志上看不出错的时候,问题往往在权限和系统状态;日志上有明确 status 码的时候,反而好办。

5.1 现象:扫描回调一次都不触发,代码无异常

原因:三种可能叠在一起。一是 Android 12 以上只申请了BLUETOOTH_SCAN却没在运行时请求,manifest 声明不等于授权;二是 Android 6 到 11 的机器上定位服务被用户关了,系统会静默屏蔽扫描结果,不报错;三是 manifest 里给BLUETOOTH_SCAN加了neverForLocation,而设备广播里恰好带了被系统判定为位置信息的数据。

解决:按版本分支请求权限,12 以上请求BLUETOOTH_SCAN加BLUETOOTH_CONNECT,11 以下请求ACCESS_FINE_LOCATION。请求前先用LocationManager.isLocationEnabled()判断定位服务状态,关了就引导用户去开,别指望代码能绕过。neverForLocation先去掉,确认能扫到再加回来验证。

5.2 现象:连接回调 status=133,重试几次偶尔能连上

原因:133 是GATT_ERROR的通用码,含义极其模糊。实际对应的情况主要有三种:设备已经被上一个连接占着没释放、服务发现还没完成就发起了读写、MTU 协商和设备不兼容。真机上最常见的是第一种,因为上一条连接只是调了disconnect()没调close(),系统层面的资源没回收。

解决:连接失败后走“disconnect、close、延迟 200ms、重新 connectGatt”这个序列,不要复用旧的BluetoothGatt对象。同时检查是不是在 133 出现之前就有操作入队了,如果有,说明服务发现没等到就动手了,要在onServicesDiscovered的 status=0 分支里才放行后续操作。连续三次 133 就不要再重试了,大概率是设备端问题,日志里记下来交给硬件侧。

5.3 现象:订阅成功,onCharacteristicChanged 就是不回调

原因:setCharacteristicNotification调了,CCCD 也写了,但 CCCD 写的时机在onDescriptorWrite回来之前又发了别的 GATT 操作,导致描述符写请求被底层丢弃,而writeDescriptor当时返回的是 true。或者 CCCD 写的是ENABLE_NOTIFICATION_VALUE,但特征值只支持 INDICATE。

解决:把 CCCD 写入严格放进 GattQueue,等onDescriptorWrite的 status=0 之后才允许下一个操作。属性位是PROPERTY_INDICATE的,值改成ENABLE_INDICATION_VALUE。还有一种情况是设备端要求先写某个使能特征值才肯发数据,这属于私有协议,得对着设备文档确认,光看标准 GATT 看不出来。

5.4 现象:切到后台再回来,通知断了一阵才恢复

原因:Activity 销毁重建时没有解绑回调,旧的BluetoothGattCallback还挂在 GATT 上,新页面又注册了一个,结果两个回调同时收到事件,业务侧状态被写乱。或者反过来,页面销毁时把gatt.close()调了,但设备端还以为连着,重连时被拒。

解决:连接的生命周期要放在 Service 或者单例里,不能跟着 Activity 走。页面只注册监听、不持有BluetoothGatt。真要跟着页面走,onDestroy里只解注册回调,不碰close()。这个错误在开发阶段不容易发现,因为切后台的时间短,问题被掩盖了,上线之后用户一锁屏就暴露。

5.5 现象:连了三台设备之后,第四台永远连不上

原因:Android 对每个应用的 GATT 客户端数量有限制,不同厂商给的数值不一样,常见是 4 到 7 个。每次connectGatt都新建一个客户端,旧的不close()就会一直占着名额,到上限之后所有新连接都返回 null 或者直接 133。

解决:在BleClient里维护一个已连接设备的映射表,每次新建连接前检查数量,超了就按 LRU 踢掉最久没通信的那台,踢的时候严格走disconnect、等onConnectionStateChange的 DISCONNECTED、再close()。不要在一个回调里连续关多个连接,系统的状态机会乱。另外close()之后要把引用置 null,避免后续误用已经失效的对象。

6. 进阶:把 BLE 排障做成可回放的黑匣子

BLE 的问题之所以难查,是因为现场很难复现。用户在某个机型上连不上,你拿到一句“连不上”,什么信息都没有。我的做法是在BleClient里加一层事件记录,把每一次状态变化、每一个 GATT 操作、每一个回调的 status 都带上时间戳和耗时写进一个环形缓冲区,出问题时把这个缓冲区导出来,整条链路就能回放。

// BleTrace.java:环形缓冲,只保留最近 200 条事件 public class BleTrace { private static final int CAPACITY = 200; private final Deque<String> ring = new ArrayDeque<>(CAPACITY); private long lastTs = 0; public void record(String tag, String detail) { long now = SystemClock.elapsedRealtime(); long delta = lastTs == 0 ? 0 : now - lastTs; lastTs = now; String line = String.format("%06d +%04dms %s %s", now % 1000000, delta, tag, detail); if (ring.size() >= CAPACITY) ring.pollFirst(); ring.offerLast(line); } public String dump() { StringBuilder sb = new StringBuilder(); for (String s : ring) sb.append(s).append('\n'); return sb.toString(); } }

delta这个字段是关键,绝对时间戳在跨进程分析时没什么用,但两次事件之间的间隔能直接暴露问题。比如connect到onConnectionStateChange之间如果只有 50ms,说明是快速失败,大概率是地址不对或者设备已被占用;如果是 30 秒整,说明是系统超时,设备根本没响应。再比如writeDescriptor到onDescriptorWrite之间超过 5 秒,基本可以判定为队列卡住或者设备端没处理。

配套要做的还有一件事:在操作队列的每个节点上都记一条“入队”和“完成”,这样队列长度和等待时间一目了然。我见过一个案例,日志显示某个写操作从入队到执行隔了 8 秒,追下去发现是前一个读操作的回调因为设备休眠永远没回来,超时兜底又设成了 30 秒,整个队列就被一个操作堵死了。如果没有入队时间戳,这个结论根本推不出来。

事件对正常间隔超过阈值说明什么
connect 至 onConnectionStateChange100ms 至 3s小于 100ms:快速失败,地址或占用问题;大于 10s:设备无响应
onServicesDiscovered 至首次读写小于 500ms间隔过长说明业务侧有时序判断错误
writeDescriptor 至 onDescriptorWrite小于 1s大于 5s:队列堵塞或设备端未处理
onCharacteristicChanged 相邻两条按设备上报周期突然变长说明链路质量下降或设备降频

这套东西的代价很小,一个 200 条的环形缓冲占不了多少内存,record调用也只是字符串拼接,比打 logcat 还轻。但它的前提是所有 GATT 操作都走同一个入口,如果代码里还有地方直接调gatt.writeCharacteristic,记录就会断链,回放出来的时间线是残缺的。所以我在接入任何一份 BLE 代码时,第一件事都是搜一遍gatt.开头的直接调用,把它们全部收进队列,再谈其他。

从那以后我每次接新的 BLE 设备,都强制先把扫描、连接、服务发现、CCCD 订阅这四步的日志打全,跑通之后再动手写业务逻辑,宁可慢半天,也不想在联调现场对着一个 133 干瞪眼。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询