HarmonyOS 7 + Connectivity Kit + BLE 技术干货:设备扫描、GATT 通信与断线恢复机制【鸿蒙心迹】
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、处理异常、重新恢复。
页面只看到一个“已连接”标签,工程内部却必须知道自己究竟处在哪一段。
如果这条链能被清楚建模,后面无论接手表、传感器、健康设备还是自定义硬件,很多复杂问题都会从“玄学断连”变成一个可以定位、可以恢复、可以验证的状态机问题。
更多推荐





所有评论(0)