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、处理异常、重新恢复。

页面只看到一个“已连接”标签,工程内部却必须知道自己究竟处在哪一段。

如果这条链能被清楚建模,后面无论接手表、传感器、健康设备还是自定义硬件,很多复杂问题都会从“玄学断连”变成一个可以定位、可以恢复、可以验证的状态机问题。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐