HarmonyOS 7 + Connectivity Kit + BLE 技术干货:设备扫描、GATT 通信与断线恢复机制【鸿蒙心迹】
发布时间:2026/9/30 7:25:24来源:尧图网络
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 KitArkTS 侧可以使用ohos.bluetooth.ble等模块完成扫描和 GATT 相关操作。扫描最容易出现两个问题一直扫以及重复设备不停刷新列表。工程里我一般给扫描设置一个明确窗口比如 8 到 12 秒。收到结果后先按设备地址去重再根据名称、RSSI 或广播字段过滤。下面代码用的是业务封装层写法重点不是某个底层函数名而是扫描逻辑的边界interface BleDeviceItem { id: string name: string rssi: number lastSeen: number } class BleScanner { private devices: Mapstring, 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): Promisevoid { 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、处理异常、重新恢复。页面只看到一个“已连接”标签工程内部却必须知道自己究竟处在哪一段。如果这条链能被清楚建模后面无论接手表、传感器、健康设备还是自定义硬件很多复杂问题都会从“玄学断连”变成一个可以定位、可以恢复、可以验证的状态机问题。
网站建设高端定制企业官网