BLE 最容易做成“扫描列表 + 点击连接”的 Demo,也最容易在真正接设备以后暴露问题。设备搜到了不等于能稳定连接,连接成功不等于服务已经可用,服务发现完成也不等于 Notify 已经建立。本文把这几段拆开,重点看 GATT 状态机、特征值通信和异常恢复。
一、BLE 工程最危险的写法,是用一个 boolean 表示“已连接”
很多 Demo 里只有一个isConnected。
点击设备后变成true,断开以后改回false。如果只做展示,这样当然够。但真实设备接入时,BLE 至少会经历扫描、发起连接、链路建立、服务发现、特征值确认、Notify 订阅、可通信、异常断开这些状态。
如果把这些过程压成一个布尔值,会出现大量“看起来已经连上,实际上还不能写数据”的问题。
所以我后来第一件事不是写扫描,而是先定义状态机。
enum BleState { IDLE = 'idle', SCANNING = 'scanning', CONNECTING = 'connecting', DISCOVERING = 'discovering', SUBSCRIBING = 'subscribing', READY = 'ready', DISCONNECTED = 'disconnected', RETRY_WAIT = 'retry_wait' } class BleSessionState { state: BleState = BleState.IDLE deviceId: string = '' retryCount: number = 0 transition(next: BleState): void { console.info(`[BLE] ${this.state} -> ${next}`) this.state = next } }状态机的价值不是为了“写得高级”,而是让每个按钮、回调和异常都有明确归属。
扫描阶段不能写特征值;正在连接时不能重复发起连接;服务还没发现时不能开始 Notify;只有进入READY,业务层才应该认为设备真正可用。
二、扫描不是“开着等结果”,而是一段有时间边界的发现过程
HarmonyOS 的 BLE 能力位于 Connectivity Kit,ArkTS 侧可以使用@ohos.bluetooth.ble等模块完成扫描和 GATT 相关操作。
扫描最容易出现两个问题:一直扫,以及重复设备不停刷新列表。
工程里我一般给扫描设置一个明确窗口,比如 8 到 12 秒。收到结果后先按设备地址去重,再根据名称、RSSI 或广播字段过滤。
下面代码用的是业务封装层写法,重点不是某个底层函数名,而是扫描逻辑的边界:
interface BleDeviceItem { id: string name: string rssi: number lastSeen: number } class BleScanner { private devices: Map<string, BleDeviceItem> = new Map() private scanning: boolean = false start(): void { if (this.scanning) { return } this.scanning = true this.devices.clear() // 底层调用 Connectivity Kit BLE 扫描接口 this.startNativeScan((result) => { this.devices.set(result.id, { id: result.id, name: result.name, rssi: result.rssi, lastSeen: Date.now() }) }) setTimeout(() => this.stop(), 10000) } stop(): void { if (!this.scanning) { return } this.scanning = false this.stopNativeScan() } }扫描页真正应该让开发者看到的是:设备名称、RSSI、当前连接状态,以及是否持续出现,而不是只看“搜到了几个”。
三、点击“连接”以后,真正的工作才刚开始
BLE 连接成功,通常只能说明链路已经建立。
下一步还要做服务发现。设备会暴露多个 GATT Service,每个 Service 下再有 Characteristic。业务协议真正读写的 UUID,通常落在这些 Characteristic 上。
所以我会把连接过程固定成一条流水线:
CONNECTING → DISCOVERING → SUBSCRIBING → READY
任何一步失败,都要知道失败在哪一段。
async connect(deviceId: string): Promise<void> { this.session.deviceId = deviceId this.session.transition(BleState.CONNECTING) try { await this.transport.connect(deviceId) this.session.transition(BleState.DISCOVERING) const services = await this.transport.discoverServices() const target = this.findTargetCharacteristic(services) if (!target) { throw new Error('目标 Characteristic 不存在') } this.session.transition(BleState.SUBSCRIBING) await this.transport.enableNotify(target.notifyUuid) this.session.transition(BleState.READY) } catch (error) { this.handleConnectionError(error) } }这里最值得保留的是“目标特征值检查”。
有些设备固件升级以后 Service UUID 不变,但 Characteristic 会调整。如果代码默认“第一个服务的第二个特征值就是我要的”,后面非常容易出问题。UUID 应该明确匹配,不要依赖数组顺序。
四、读、写、Notify 是三件不同的事情
初学 BLE 时很容易把“通信”理解成一个统一动作。实际上常用的数据通道至少有三种:主动读、主动写、服务端通知。
读适合偶发查询,例如读取设备版本;写适合发送控制命令;Notify 更适合设备主动上报,例如心率、传感器数据、进度状态。
如果设备每秒都要上报数据,客户端反复轮询读取通常不是好选择。Notify 建立以后,让设备在值变化时主动推送,链路会自然很多。
interface BlePacket { command: number payload: Uint8Array } class BleProtocol { encode(command: number, payload: Uint8Array): Uint8Array { const buffer = new Uint8Array(payload.length + 3) buffer[0] = 0xAA buffer[1] = command buffer.set(payload, 2) buffer[buffer.length - 1] = this.checksum(buffer.slice(0, -1)) return buffer } decode(raw: Uint8Array): BlePacket | null { if (raw.length < 3 || raw[0] !== 0xAA) { return null } return { command: raw[1], payload: raw.slice(2, -1) } } private checksum(data: Uint8Array): number { return data.reduce((sum, value) => (sum + value) & 0xFF, 0) } }我比较建议把 BLE 原始字节流和页面彻底隔开。页面不应该知道第 0 字节是帧头、第 1 字节是命令字。页面只消费“温度更新”“设备电量变化”“操作成功”这样的业务事件。
五、设备详情页应该显示“链路证据”
如果 BLE 页面只显示“已连接”,调试价值很有限。
我更喜欢在开发版详情页里把设备地址、RSSI、服务数量、目标 Service UUID、Notify 状态、最近一次收发时间都展示出来。这样现场联调时,很多问题不需要连接电脑看日志就能判断。
这张详情页里最重要的信息其实不是设备名字,而是“服务列表”和“当前链路状态”。
如果设备已经显示连接,但目标 Service 没有出现,那就应该回到服务发现阶段排查,而不是继续怀疑业务命令格式。
六、断线重连不能写成catch里立即connect()
这是 BLE 项目里另一个非常典型的坑。
链路断开以后马上重连,看起来恢复最快,但如果设备刚好关机、离开范围或者系统蓝牙状态异常,就会形成连续重试。结果是日志刷屏、功耗增加,甚至让状态机越来越乱。
我一般用退避策略处理自动重连:第一次 1 秒,第二次 2 秒,再往后逐步增加,并设置最大重试次数。
scheduleReconnect(): void { const maxRetry = 5 if (this.session.retryCount >= maxRetry) { this.session.transition(BleState.DISCONNECTED) return } const delay = Math.min(1000 * Math.pow(2, this.session.retryCount), 16000) this.session.retryCount += 1 this.session.transition(BleState.RETRY_WAIT) setTimeout(() => { this.connect(this.session.deviceId) }, delay) }重新连接以后不能直接回到 READY,而应该重新走服务发现和 Notify 订阅。因为新的 GATT 会话不能假设旧会话的特征值订阅仍然有效。
这也是为什么前面状态机一定要拆细。
七、BLE 日志要按“阶段”打,不要只打错误码
真正排查 BLE 时,我最需要的日志不是一大串底层错误,而是这条链走到哪里了。
比如:
scan started → device found → connect start → connected → service discovered → notify enabled → ready → disconnected → retry 1
只要阶段日志完整,大多数问题都能快速缩小范围。
DevEco Studio 联调时,我通常左边看扫描和状态机代码,右侧模拟器看设备列表,底部日志只保留 BLE 关键节点。比起把所有原始广播包都打印出来,这种日志更适合日常开发。
八、实际产品里还要补三个边界
第一个是系统蓝牙开关状态。用户关掉蓝牙时,应用要明确告诉用户,而不是把它当成普通“扫描不到设备”。
第二个是权限和系统版本差异。BLE 扫描、连接涉及的能力和授权要求要以目标 API 版本为准,最好在能力入口统一做检查。
第三个是设备协议版本。硬件固件升级以后,协议字段可能变化。建议在 GATT 连接完成后先读取设备版本,再决定后续命令解析策略。
九、本文小记
BLE 功能真正做稳定以后,我觉得最值得留下来的不是某一段扫描代码,而是这套状态思维。
设备连接不是一个瞬间,而是一条链:发现设备、建立链路、发现服务、确认特征值、建立 Notify、进入 READY、处理异常、重新恢复。
页面只看到一个“已连接”标签,工程内部却必须知道自己究竟处在哪一段。
如果这条链能被清楚建模,后面无论接手表、传感器、健康设备还是自定义硬件,很多复杂问题都会从“玄学断连”变成一个可以定位、可以恢复、可以验证的状态机问题。