接手第一个鸿蒙蓝牙项目的时候,我在扫描回调里干等了将近两分钟,一个设备都没冒出来。权限声明写了、代码编译过了、日志也没报错,就是没有数据。后来才发现是权限申请里漏了用户授权那一步,module.json5里声明了但运行时没弹窗,扫描接口静默返回空数组。这件事让我明白,鸿蒙中蓝牙服务开发这件事,真正难的不是那几个 API 调用,而是从协议栈分层、权限链路、扫描节流、GATT 串行化到 MTU 协商这一整条链路上,有太多“不报错但也不工作”的灰色地带。
这篇内容我想把鸿蒙蓝牙服务开发里最核心的那一层拆开讲:蓝牙服务在整个应用架构里应该怎么摆、BLE 和经典蓝牙怎么选、扫描参数怎么算、GATT 连接怎么稳、服务端怎么搭、出问题怎么查。适合已经能写 ArkTS、准备把蓝牙接进真实硬件的人,也适合被“扫描不到设备”“连上就断”“通知收不全”折磨过的同行。文中代码以较新的 HarmonyOS SDK 蓝牙接口为参考,不同 API 版本存在差异的地方我会明确标出来,你手上那份 SDK 文档永远是最终依据。
1. 先把“蓝牙服务”这层想清楚:整体设计与选型
我一直觉得,蓝牙开发最容易翻车的地方是架构,不是语法。很多项目一开始图省事,把扫描、连接、读写全塞在一个页面里,aboutToAppear里启动扫描,onClick里直接write。demo 阶段确实跑得通,等设备数量上去、页面来回切换、后台挂一会儿再回来,就会出现连接泄漏、回调重复注册、GATT 命令互相打断这些烂账。所以在写第一行代码之前,得先把蓝牙在应用里的位置定下来。
1.1 从协议栈分层看,鸿蒙给开发者留了哪几道口子
鸿蒙的蓝牙能力是按层暴露的,理解这个分层,你就知道什么问题该在哪一层解决。
最底下是控制器(Controller),也就是蓝牙芯片固件,负责射频收发、跳频、链路层封装。这部分应用完全碰不到,但它的能力决定了你的上限——比如支不支持扩展广播、支不支持 2M PHY、最大连接数是多少。
往上是主机协议栈(Host),包含 HCI、L2CAP、SMP、ATT、GATT、GAP 这些。鸿蒙把这一层的常用能力开放出来了,开发者主要接触两块:
| 能力模块 | 典型接口 | 适用场景 |
|---|---|---|
| BLE 模块 | access.bluetooth.ble | 低功耗外设、传感器、穿戴设备、配网 |
| Socket 模块 | access.bluetooth.socket | 经典蓝牙 RFCOMM/SPP、L2CAP 数据传输 |
| Connection 模块 | access.bluetooth.connection | 配对、已配对设备列表管理 |
| Constant 模块 | access.bluetooth.constant | 枚举值、状态码、Profile UUID 常量 |
再往上是应用层,也就是你写的 ArkTS。这一层要干的事其实只有四件:管状态、管连接、管数据、管异常。我的习惯是把这四件事分别落到四个类里,而不是散在页面代码中。
BluetoothCore(单例) ├── AdapterWatcher // 监听蓝牙开关、适配器状态 ├── ScanController // 扫描启停、结果去重、超时控制 ├── ConnectionPool // 设备连接、断线重连、连接上限 ├── GattQueue // GATT 操作串行化队列 └── Codec // 私有协议编解码、分包重组为什么要拆成GattQueue单独一个东西?因为 GATT 的 ATT 层本质上是一条串行链路,同一时刻只能有一个未完成的请求。你连着发三个writeCharacteristicValue,第二个很可能返回失败,或者干脆把第一个的响应吃掉。这不是鸿蒙的问题,是协议本身的设计。所以从架构上就要把读写操作排进队列,一次只放一个出去,收到回调再放下一个。
注意:GATT 操作的串行化是硬约束,不是优化项。我见过太多项目在这里出问题,表现为“偶尔写失败”“写十次成功八次”,排查半天以为是信号问题,其实是并发写。
1.2 BLE 还是经典蓝牙:一次面向真实场景的选型推演
这个选择题在新项目启动会上一定会出现。我的判断逻辑通常是这样:先看数据特征,再看功耗约束,最后看兼容性包袱。
BLE 的优势在于连接建立快(几十毫秒级)、待机功耗极低、协议结构清晰(服务/特征值模型),缺点是单包载荷小、吞吐上限不高。经典蓝牙的优势是吞吐大、支持音频和串口式流传输,缺点是连接建立慢、功耗高、配对流程重。
我把实际项目里的经验整理成一张对照表,你可以直接拿去用:
| 维度 | BLE | 经典蓝牙(RFCOMM/SPP) |
|---|---|---|
| 典型吞吐 | 几 KB/s 到几十 KB/s | 几十 KB/s 到上百 KB/s |
| 连接建立 | 快,百毫秒内 | 慢,秒级 |
| 待机功耗 | 极低 | 高 |
| 数据结构 | 服务 + 特征值,结构化 | 字节流,需自定义帧格式 |
| 固件复杂度 | 中 | 低(透传模块多) |
| 适合场景 | 传感器数据、配网、控制指令、OTA | 音频、大批量文件、老设备兼容 |
有个做工业采集的项目,一开始选了经典蓝牙,因为模块便宜、透传简单。结果现场是电池供电,设备要连续工作三个月,实测两周就没电了。后来改成 BLE,用 200ms 的通知周期上报数据,功耗直接降下来一个数量级。这个案例说明,选型的时候不能只看开发成本,要把整个生命周期的功耗账算进去。
反过来也有选了 BLE 后悔的。有个项目要传固件包,单个包 300KB,用 BLE 在默认 MTU 下传了快十分钟。如果换成经典蓝牙,一两分钟就完了。后来他们优化了 MTU 协商和包间隔,压到三分钟左右,才算能接受。
所以我的建议是:控制指令、状态上报、小数据量、电池供电,无脑选 BLE;音频、大文件、需要兼容大量存量外设,考虑经典蓝牙。如果两类需求都有,那就两条链路并存,别硬塞进一个协议里。
1.3 模块拆分:把扫描、连接、数据通道、状态管理解耦
具体到代码组织,我通常按“状态驱动的状态机”来写,而不是“事件驱动的回调堆”。
蓝牙适配器本身有明确的状态流转:关闭 → 开启中 → 已开启 → 关闭中。扫描有:空闲 → 扫描中 → 停止。连接有:断开 → 连接中 → 已连接 → 服务发现中 → 就绪 → 断开中。这些状态如果用零散的布尔变量去记,很快就会互相矛盾,比如isConnected是 true 但isDiscovering也是 true。
我的做法是定义一个统一的状态枚举,所有对外暴露的能力都先检查状态:
export enum BleLinkState { IDLE = 'IDLE', CONNECTING = 'CONNECTING', CONNECTED = 'CONNECTED', DISCOVERING = 'DISCOVERING', READY = 'READY', DISCONNECTING = 'DISCONNECTING' }对外提供的服务接口只有三类:startScan()/stopScan()、connect(deviceId)/disconnect(deviceId)、enqueueWrite(...)/enqueueRead(...)。页面层完全不碰GattClientDevice对象,只订阅状态变化和数据回调。这样做的直接好处是,页面销毁重建不会影响连接,后台切回来也不用重新连。
还有一点是关于回调注册。鸿蒙的on('BLEConnectionStateChange')这类接口,重复注册会累加监听器,同一个连接状态变化你可能收到两三次回调。我习惯在ConnectionPool里维护一个Map<string, boolean>记录哪些 deviceId 已经注册过监听,注册前先查表,销毁时统一off。这个细节在文档里不太显眼,但线上出问题时排查成本极高。
2. 权限、开关与设备发现:最容易被忽略的前置环节
蓝牙开发有个很反直觉的特点:90% 的“功能不工作”,原因都在权限和状态这两个前置环节。业务代码写得再漂亮,权限没走通,扫描接口就是安安静静返回空结果,连个错误码都不给你。所以这一章我把前置环节讲透。
2.1 权限声明的坑:为什么你的扫描一直返回空
鸿蒙的蓝牙权限在版本演进中做过调整。较新的版本把蓝牙相关操作统一收敛到一个权限上,老版本(API 9 前后)则拆成了多个,还牵扯到位置权限。这里我按较新的方式讲,同时把老版本的差异标出来。
第一步是在module.json5里声明:
{ "module": { "requestPermissions": [ { "name": "ohos.permission.ACCESS_BLUETOOTH", "reason": "$string:reason_bluetooth", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } } ] } }第二步,也是最容易漏的一步:运行时动态申请。ACCESS_BLUETOOTH属于用户授权权限(user_grant),光声明不够,必须在进入蓝牙功能前主动拉起授权弹窗。
import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit'; const PERMS: Permissions[] = ['ohos.permission.ACCESS_BLUETOOTH']; async function ensureBluetoothPermission(context: common.UIAbilityContext): Promise<boolean> { const atManager = abilityAccessCtrl.createAtManager(); const result = await atManager.requestPermissionsFromUser(context, PERMS); // authResults 中 0 表示已授权,-1 表示拒绝 return result.authResults.every((code) => code === 0); }这段代码有几个实操要点值得强调。
第一个是申请时机。不要在onWindowStageCreate里无脑申请,用户还不知道你要干什么,很容易点拒绝,一旦拒绝两次,系统就不再弹窗了,后面只能引导去设置页手动开。我的做法是在用户点“添加设备”按钮时申请,同时配一句说明文案,通过率明显更高。
第二个是拒绝后的降级处理。权限被拒后,startScan不会抛异常,只会返回空。所以一定要在调用前先判断权限状态,用checkAccessToken查,未授权就直接给用户提示,而不是让他对着空白列表发呆。
const grantStatus = await atManager.checkAccessToken(tokenId, 'ohos.permission.ACCESS_BLUETOOTH'); if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) { // 走引导授权流程,不要继续扫描 }第三个是老版本的差异。API 9 附近的接口需要ohos.permission.USE_BLUETOOTH、ohos.permission.DISCOVER_BLUETOOTH,并且扫描附近设备还涉及位置权限和位置服务开关。如果你维护的是老工程,升级 SDK 后可能会遇到“原来能扫现在不能扫”,先去看权限清单有没有变化,别急着怀疑硬件。
提示:权限问题最典型的症状是“接口不报错但没结果”。凡是遇到扫描无数据、连接直接失败、服务列表为空,第一件事就是打日志确认权限状态,而不是去翻协议。
2.2 蓝牙开关与适配器状态的正确监听方式
权限过了,还要看蓝牙开关。用户可能压根没开蓝牙,或者开着飞行模式。
鸿蒙提供了适配器状态查询和状态变化订阅。我的做法是在AdapterWatcher里做三件事:启动时查一次当前状态、订阅状态变化、状态变成不可用时主动清理所有连接。
import { access } from '@kit.ConnectivityKit'; class AdapterWatcher { private registered = false; start(onStateChange: (available: boolean) => void) { const state = access.bluetooth.getState(); onStateChange(state === access.bluetooth.BluetoothState.STATE_ON); if (this.registered) return; access.bluetooth.on('stateChange', (data) => { const on = data === access.bluetooth.BluetoothState.STATE_ON; onStateChange(on); if (!on) { // 蓝牙被关闭,所有连接都会失效,这里要主动清理 ConnectionPool.getInstance().releaseAll(); } }); this.registered = true; } }这里有个真实踩过的坑:蓝牙关闭时,已经建立的 GATT 连接不会立刻触发断连回调,有时延迟好几秒。如果你的界面还在按“已连接”状态发数据,就会一直写失败。所以在stateChange变成关闭的瞬间,就要主动把所有连接标记为断开、清空队列、通知 UI。不要指望断连回调兜底。
另一个细节是监听器的生命周期。on('stateChange')是全局的,注册了就得记得off,否则单例重复初始化会叠加监听。我在AdapterWatcher里用了一个registered布尔量做幂等,这招很土但很管用。
2.3 扫描参数怎么定:窗口、间隔与功耗的三角权衡
扫描参数是很多人直接抄默认值的地方,但它直接决定了发现速度、功耗和漏检率。搞懂这几个参数,你就能在“发现慢”和“耗电快”之间找到平衡点。
BLE 扫描的本质是周期性打开射频接收窗口。核心参数是两个:扫描窗口(window)和扫描间隔(interval)。间隔是两次扫描开始的周期,窗口是每次持续监听的时间。两者的比值就是占空比。
占空比 = 窗口 / 间隔
假设间隔 100ms、窗口 25ms,占空比就是 25%;间隔 100ms、窗口 100ms,占空比是 100%,也就是常说的连续扫描。
占空比越高,越不容易错过广播包,发现越快,但射频一直开着,功耗越高。反过来,占空比低,功耗友好,但如果外设的广播间隔恰好和你错开,就可能要等好几个周期才能收到一次。
这里做个简单估算。假设外设广播间隔是 100ms,广播事件持续时间约 1ms。扫描间隔 1000ms、窗口 20ms(占空比 2%),那么每次窗口打开时命中广播的概率大约 20/1000 = 2%,平均需要 50 个周期也就是 50 秒才能发现一次。这个数字在实际项目里非常致命——用户会觉得“搜不到设备”。
所以我的经验值是:
| 场景 | 间隔 | 窗口 | 说明 |
|---|---|---|---|
| 配网、主动搜索 | 100ms | 100ms | 连续扫描,发现最快,功耗可接受 |
| 后台补扫 | 1000ms | 100ms | 占空比 10%,兼顾功耗和发现率 |
| 长时间待机扫描 | 5000ms | 50ms | 占空比 1%,适合低频广播设备 |
鸿蒙的扫描接口里,通常用ScanDuty这类枚举来表达扫描模式,底层会映射到对应的窗口间隔组合。较新的 SDK 提供了createBleScanner()对象的写法:
import { access } from '@kit.ConnectivityKit'; const scanner = access.bluetooth.ble.createBleScanner(); const filters: Array<access.bluetooth.ble.ScanFilter> = [{ // 只收广播了指定服务 UUID 的设备,能显著降低回调量 serviceUuid: '0000FFF0-0000-1000-8000-00805F9B34FB' }]; const options: access.bluetooth.ble.ScanOptions = { dutyMode: access.bluetooth.ble.ScanDuty.SCAN_MODE_LOW_LATENCY, matchMode: access.bluetooth.ble.MatchMode.MATCH_MODE_AGGRESSIVE }; scanner.startScan(filters, options);老版本对应的是全局函数ble.startBLEScan(filters, options)和ble.stopBLEScan(),能力基本一致,只是调用形态不同。升级 SDK 时这块要留意。
扫描过滤是最容易被忽视的省电手段。如果你的设备广播里带了自定义服务 UUID,一定要用serviceUuid过滤。不过滤的话,周围的蓝牙耳机、手环、共享单车锁全都会进你的回调,不但耗电,还要在 JS 层做一次遍历筛选,白白浪费 CPU。我在一个商场环境里做过对比,加过滤后回调量从每秒几十条降到每秒一两条,体验差别很大。
注意:扫描本身有系统级限流。频繁启停扫描(比如 1 秒内 start/stop 反复调用)可能触发底层保护,表现为后续几次扫描收不到任何结果。扫描的启停间隔建议不小于 2 秒,停止后延迟一小段时间再重新开始。
另外,扫描结果要做去重和过期。同一个设备会在每个广播周期被回调一次,如果直接往列表里 push,列表会疯狂抖动。我的做法是用Map<deviceId, ScanResult>存,同时记录lastSeen时间戳,界面上按 RSSI 排序展示,超过 5 秒没更新的设备自动移除。
3. GATT 连接的完整链路:从发现到数据通道建立
扫描到设备只是拿到了一个地址,真正干活要建立连接,然后逐层找到服务和特征值。这一段是整个蓝牙开发里步骤最多、最容易中断的环节。
3.1 连接前的设备信息核对:地址类型与广播数据解析
在发起连接前,我习惯先核对两件事:设备地址类型是否正确、广播数据里有没有我想要的信息。
为什么要看地址类型?因为 BLE 设备地址分公共地址和随机地址,随机地址里还分静态、可解析、不可解析几种。有些设备重启后会随机换地址,如果你用地址做唯一标识,用户配过的设备下次就认不出来了。这种设备必须用广播里的自定义字段或者读设备信息特征值来做标识。
// 从广播数据里取厂商自定义字段 const md = scanResult.data?.manufacturerData; if (md) { // 厂商字段一般前两个字节是公司标识,后面是业务数据 const payload = new Uint8Array(md); const companyId = (payload[1] << 8) | payload[0]; const deviceType = payload[2]; }广播包长度也是有限制的。传统广播单包有效载荷 31 字节,扩展广播才能到更长。所以别指望把设备名、序列号、固件版本全塞广播里。我的做法是广播里只放最必要的三个东西:服务 UUID、设备类型、连接状态标志,其他信息连上之后读特征值拿。
这里再提一个细节:广播数据解析要注意大小端。很多固件是 C 语言写的,小端序,而 JS 的DataView默认是大端,不加littleEndian参数就会读出错乱的数值。这种 bug 特别隐蔽,因为数值看起来“像那么回事”,只是不对。
3.2 服务发现(discoverServices)与 UUID 规划
连接建立后,必须做服务发现。没有服务发现就去读写特征值,一定会失败。流程是:connect()→ 等待连接状态回调变成已连接 →getServices()→ 拿到服务列表 → 从中找到目标服务 → 再找到目标特征值。
import { access } from '@kit.ConnectivityKit'; const client = access.bluetooth.ble.createGattClientDevice(deviceId); client.on('BLEConnectionStateChange', async (state) => { if (state === access.bluetooth.ble.ProfileConnectionState.STATE_CONNECTED) { const services = await client.getServices(); // 在 services 里按 UUID 匹配,缓存下来 this.cacheServices(services); } });服务发现的结果一定要缓存。很多项目的写法是每次读写之前都调一次getServices(),这在协议层是一次完整的 ATT 读请求遍历,非常耗时,也会占用 GATT 队列。正确做法是连接成功后发现一次,把服务、特征值、描述符的句柄都存进内存,后续直接用。
UUID 规划这块,如果你同时做设备端和 App 端,建议一开始就定好规范。我的习惯是:
- 基础 UUID 用短格式,比如
0000FFF0-0000-1000-8000-00805F9B34FB,中间那个FFF0是 16 位短 UUID。 - 服务分三组:信息组(设备型号、固件版本、序列号,只读)、控制组(写指令、读状态)、数据组(通知上报、大数据传输)。
- 每个特征值都配一个描述符,开启通知用
2902,用户描述用2901。
分成三组的好处是权限清晰。信息组全部只读,不需要鉴权;控制组需要写入,可以做加密校验;数据组是高频通知,单独走一条通道,避免和控制指令抢队列。
如果设备是第三方提供的,你没有 UUID 定义权,那就老老实实按对方的协议文档来,把每个 UUID 做成常量,别在代码里散落字符串。我见过一个项目把 UUID 写在五个文件里,后来设备升级改了 UUID,改到崩溃。
3.3 特征值读写与订阅通知的实现细节
读写操作看起来简单,实际上坑最多。
写操作分两种:有响应写(Write Request)和无响应写(Write Command)。有响应写会等对端回 ACK,可靠但慢;无响应写发出去就不管了,快但可能丢。对于控制指令,我强烈建议用有响应写;对于连续数据流,用无响应写配合序号校验。
const char: access.bluetooth.ble.BLECharacteristic = { serviceUuid: SERVICE_UUID, characteristicUuid: CHAR_CTRL_UUID, characteristicValue: buffer, descriptors: [] }; client.writeCharacteristicValue(char, access.bluetooth.ble.GattWriteType.WRITE, (err) => { if (err) { // 失败要重试,但重试次数必须有上限 } });GattWriteType里有WRITE和WRITE_NO_RESPONSE两种,选哪个取决于你的可靠性要求。
读操作要注意的是返回值的类型。很多接口返回ArrayBuffer,你需要自己转成Uint8Array或DataView再解析。直接对ArrayBuffer用[0]取值会得到undefined,这是新手常见错误。
通知订阅是最关键的一步,也是最容易失败的一步。开启通知分两件事:在本地打开通知开关,以及写 CCCD(客户端特征配置描述符)。鸿蒙的setCharacteristicChangeNotification接口一般把这两步封装了,但底层仍然是往2902描述符写0100。
await client.setCharacteristicChangeNotification(char, true); client.on('BLECharacteristicChange', (data) => { // 这里收到的是原始 ArrayBuffer,要做拆包 const frame = codec.decode(data.characteristicValue); });这里有个非常关键的经验:开启通知的调用必须是串行的,而且要在连接稳定之后。我遇到过在STATE_CONNECTED回调里立刻调三次setCharacteristicChangeNotification的情况,结果只有一个生效。原因是三个请求同时打到 ATT 层,前面的还没完成,后面的就被丢弃了。放进 GattQueue 串行执行就没问题。
还有一点是通知丢失。BLE 的通知是“发出去就不管”,如果主从两端连接间隔不匹配或者射频环境差,包是会丢的。所以协议里一定要有序号字段,App 端发现序号跳变就主动去读一次当前值做补偿。这个设计在数据传输场景几乎是必须的。
3.4 MTU 协商与分包策略(含参数计算)
默认 MTU 是 23 字节,减去 ATT 头部的 3 个字节,实际单次有效载荷只有 20 字节。传 1KB 数据要分 52 包,这个效率显然不行。所以连接后要协商更大的 MTU。
鸿蒙提供了设置 MTU 的接口,协商结果通过对端返回,上限一般是 517。
client.setBLEMtuSize(512); client.on('BLEMtuChange', (mtu: number) => { // 实际协商结果,注意不是一定等于你请求的值 this.currentMtu = mtu; this.payloadSize = mtu - 3; });这里的计算逻辑要讲清楚:
- 请求 MTU = 512
- 对端可能只支持 247,那协商结果就是 247
- 有效载荷 = 协商 MTU - 3
- 若协商结果为 247,有效载荷 244 字节
- 传 1024 字节数据,需要
ceil(1024 / 244) = 5个包
对比默认 MTU 的 52 个包,效率提升十倍。这就是为什么 MTU 协商值得花时间做。
但 MTU 不是越大越好。有些老旧设备对大于 247 的 MTU 支持很差,协商成 512 之后反而频繁出错。我的做法是分级协商:先请求 247,成功后再尝试 512,如果后续出现异常就降回 247。
分包策略还有几个要点:
包间隔。连续高速发送会导致对端缓冲区溢出,表现为丢包。实测下来,包间隔 20-30ms 比较稳妥,具体要看你设备固件的处理能力。可以做一个自适应机制:连续成功就缩短间隔,出现失败就拉长。
首包带长度。协议设计上,第一包携带总长度和校验方式,接收端据此分配缓冲区和判断结束。这样比“靠超时判断结束”可靠得多。
序号 + 重传。每包带 2 字节序号,接收端发现不连续就请求重传。这套机制看起来啰嗦,但实际项目里没有它,长数据传输成功率很难看。
提示:MTU 修改后要重新获取特征值句柄吗?不需要,MTU 协商不影响句柄。但如果连接断开重连,MTU 会回到默认值,必须重新协商。这是重连逻辑里最容易漏的一步。
4. 反向链路:把设备做成 GATT 服务端
前面讲的都是 App 连设备。但鸿蒙设备本身也可以当服务端,让别的手机或设备连过来。这个能力在配网、设备间直连、本机作为数据源这类场景很有用。
4.1 服务端与广播的搭建顺序
服务端的搭建顺序不能乱,正确顺序是:创建 GattServer → 添加服务 → 启动广播。很多人在添加服务之前就启动广播,结果对端连上来发现服务列表是空的。
const server = access.bluetooth.ble.createGattServer(); const service: access.bluetooth.ble.GattService = { serviceUuid: SERVICE_UUID, isPrimary: true, characteristics: [/* 特征值定义 */], includeServices: [] }; server.addService(service); const setting: access.bluetooth.ble.AdvertiseSetting = { interval: 320, // 单位 0.625ms,320 约等于 200ms txPower: 0, connectable: true }; const advData: access.bluetooth.ble.AdvertiseData = { serviceUuids: [SERVICE_UUID], manufactureData: [/* 自定义字段 */] }; server.startAdvertising(setting, advData);广播间隔的单位是 0.625ms,这是协议规定的。所以interval: 320对应 200ms,interval: 32对应 20ms。间隔越短,被发现越快,功耗越高。配网场景我一般用 100-200ms,长时间可被发现的状态用 500ms 以上。
还有一个细节:广播数据的总长度有限,服务 UUID 和厂商数据加起来不能超。超了会启动失败。我一般是先算好长度再拼,或者干脆把自定义信息精简到最少。
4.2 特征值权限与响应码的对应关系
服务端的特征值需要声明权限,这决定了客户端能做什么:
| 权限属性 | 含义 |
|---|---|
READABLE | 允许读 |
WRITEABLE | 允许写 |
WRITEABLE_WITHOUT_RESPONSE | 允许无响应写 |
NOTIFYABLE | 支持通知 |
INDICATEABLE | 支持指示(需要 ACK) |
权限没开对,客户端的操作会被拒绝,返回GATT_READ_NOT_PERMITTED或GATT_WRITE_NOT_PERMITTED。这种失败一定要在服务端的on('characteristicRead')/on('characteristicWrite')回调里返回明确的GattStatus,否则客户端只会看到一个含糊的失败。
我的习惯是在写回调里做三件事:校验长度、校验业务字段、更新内部状态。校验不过就返回对应错误码,同时打日志。这套日志在联调阶段能省掉大量扯皮,因为到底是“客户端写错了”还是“服务端没处理”,一看响应码就清楚。
4.3 连接数、并发与资源回收
服务端最容易被忽略的是连接数上限。手机同时连多台设备,每台都占内存和协议栈资源,超过上限后新的连接请求会失败。具体上限跟芯片和系统有关,没有统一数字,建议按 4-7 个来做设计。
连接关闭时一定要做资源回收:close()掉 GattServer、off掉所有监听、清空缓存的连接信息。蓝牙资源泄漏的症状是“用一会儿就连不上了,重启 App 就好”。这种问题在测试阶段不明显,上线后用户投诉不断。
5. 常见问题与排查技巧实录
这一章是我这些年攒下来的排查清单,基本覆盖了蓝牙开发 80% 的“玄学问题”。
5.1 高频问题速查表
| 现象 | 优先排查方向 | 处理方式 |
|---|---|---|
| 扫描无任何结果 | 权限是否授权、蓝牙是否开启 | 查checkAccessToken、查适配器状态 |
| 只能扫到部分设备 | 是否加了服务 UUID 过滤 | 临时去掉过滤验证 |
| 连接一直超时 | 设备地址类型、是否已被占用 | 换设备验证、检查对端连接数 |
| 连上后服务列表为空 | 是否等服务发现完成 | 在连接回调里再等一拍再取 |
| 写特征值必失败 | 特征值权限、GATT 是否就绪 | 确认WRITE权限、串行化写 |
| 通知收不到 | 是否写了 CCCD、是否串行 | 单独调一次开启,检查返回值 |
| 数据半截 | MTU 未协商、分包重组逻辑 | 打印 MTU、检查长度字段 |
| 用一会儿就连不上 | 连接未释放、监听未注销 | 查连接池、查off调用 |
| 模拟器上完全没反应 | 模拟器不支持蓝牙 | 换真机 |
5.2 通知丢失与粘包的排查方法
通知丢失是最头疼的问题,因为它是偶发的。我的排查套路是先确定丢在哪一层。
第一层,判断服务端到底发没发。在设备端加日志,记录每包序号和发送时间。如果服务端根本没发,那是业务逻辑问题。
第二层,判断系统有没有收到。在on('BLECharacteristicChange')里立刻打日志,记序号。如果序号有跳变,说明是射频链路丢的,属于物理层问题,只能通过协议层的重传补偿解决。
第三层,判断是不是应用层解析错了。有些情况是多包粘在一起到达,如果你按“一次回调一包”去解析,必然出错。正确做法是把每次回调的数据 append 到缓冲区,然后循环尝试从缓冲区里按帧头 + 长度字段切出完整帧,切不出来就等下次。
private buffer = new Uint8Array(0); onData(chunk: Uint8Array) { const merged = new Uint8Array(this.buffer.length + chunk.length); merged.set(this.buffer); merged.set(chunk, this.buffer.length); this.buffer = merged; while (this.buffer.length >= 4) { const frameLen = (this.buffer[2] << 8) | this.buffer[3]; if (this.buffer.length < frameLen + 4) break; const frame = this.buffer.slice(0, frameLen + 4); this.buffer = this.buffer.slice(frameLen + 4); this.onFrame(frame); } }这段逻辑看着朴素,但它是所有流式协议解析的通用骨架。我建议任何涉及 BLE 数据传输的项目,一开始就把这个缓冲区解析器写出来,别等到出问题再补。
5.3 多设备并发与线程调度的注意点
一个 App 同时连多台设备,是很多真实场景的刚需,比如同时采集多个传感器。这里有两个建议。
每台设备一个独立的队列和状态机,不要共用 GattQueue。共用的话,设备 A 的慢请求会阻塞设备 B,体验很差。
耗时解析放到 TaskPool。如果协议解析涉及大量字节操作或者解密运算,放在 UI 线程会掉帧。用@ohos.taskpool把解析任务丢出去,解析完再回主线程更新状态。
import { taskpool } from '@kit.ArkTS'; @Concurrent function parseFrame(data: ArrayBuffer): number[] { // 纯计算,不碰 UI const arr = new Uint8Array(data); return Array.from(arr); } const task = new taskpool.Task(parseFrame, rawBuffer); const result = await taskpool.execute(task);要注意的是,@Concurrent函数不能访问外部闭包变量,参数和返回值也要能被序列化。第一次写容易在这里卡住,报错信息不太直观。
5.4 真机联调的几条私房经验
必须用真机。模拟器没有蓝牙硬件,任何蓝牙接口调用都不会有真实结果。这条没有例外。
准备两台真机。一台跑 App,一台跑对端测试工具,或者干脆用另一台手机装个通用的 BLE 调试应用做对照。当你的 App 连不上时,用对照工具连一下,能快速判断是设备问题还是你的代码问题。
抓系统日志。蓝牙协议栈的日志通常在 hilog 里能看到,过滤蓝牙相关 TAG 能看到连接建立、服务发现、ATT 请求的详细过程。这些日志比你自己打的业务日志有用得多,尤其是排查“为什么服务发现失败”这类问题。
随身带一个已知良好的设备。我包里常年放着一个自己写的最小 BLE 从机模块,协议极简。线上问题复现时,先用它验证 App 的蓝牙链路是否正常,能省掉一半的扯皮时间。
最后分享一个我个人在实际项目里养成的习惯:给每一条蓝牙操作路径都加上耗时统计。扫描到发现多久、连接到服务发现多久、一次读写多久、MTU 协商多久。这些数字正常时是性能基线,异常时就是最直接的线索。我没有一次是靠猜解决问题的,都是靠这些数字定位的。