这次我没有继续做 GATT 连接,而是把 BLE 扫描这一步单独拿出来重写。原因很实际:同一个扫描页,我遇到了三个看起来互不相关的问题——筛选条件为空时报 401;同一个设备短时间刷出十几条广播;页面来回进入几次以后,回调次数越来越多。

最后发现,它们其实都属于同一条链路:参数对象怎么构造、广播结果怎么合并、页面退出后扫描资源怎么释放。

我做了一个 BleScanGuardLab,固定一次会话为 ble_scan_20261001_14。业务扫描窗口 8 秒,使用低功耗扫描,原始报告 23 条,合并后 7 台设备,重复广播 16 条,无效参数 0,扫描阶段活动监听器始终保持 1 个。

一、最先修的是一个很隐蔽的 401

页面允许用户按厂商 ID 过滤设备。最初我直接把可选值放进 ScanFilter,没有填写时就是 undefined。从 ArkTS 看,这只是一个空值;但底层 NAPI 解析参数时,{ manufactureId: undefined } 和“对象里没有这个键”不是一回事。

所以我改成按需挂载:

private buildFilter(
  manufacturerId?: number
): ble.ScanFilter {
  const data = new Uint8Array([0x12, 0x34])

  const filter: ble.ScanFilter = {
    manufactureData: data.buffer
  }

  if (manufacturerId !== undefined) {
    filter.manufactureId = manufacturerId
  }

  return filter
}

这个改动解决的是参数形态,不是扫描业务。没有有效值就不写键,避免可选属性显式带 undefined 进入底层强类型校验。

本次 Demo 使用 manufacturerId=0x1234,所以诊断页显示 Filter Keys=2。正式项目里我会把 deviceName、serviceUuid、manufactureId 都统一放进 Builder,页面只传业务条件,不直接拼底层对象。

二、系统扫描参数和业务“8 秒窗口”要分开

扫描器使用 Connectivity Kit 的 BleScanner。我把系统参数固定在 Controller 里:

private scanner: ble.BleScanner =
  ble.createBleScanner()

private startScan(): void {
  const filter = this.buildFilter(0x1234)

  const options: ble.ScanOptions = {
    interval: 500,
    dutyMode: ble.ScanDuty.SCAN_MODE_LOW_POWER,
    matchMode: ble.MatchMode.MATCH_MODE_AGGRESSIVE,
    reportMode:
      ble.ScanReportMode.FENCE_SENSITIVITY_LOW
  }

  this.scanner.on(
    'BLEDeviceFind',
    this.onReceiveEvent
  )

  this.scanner.startScan([filter], options)
  this.state = 'SCANNING'
}

界面上的 Scan Mode=LOW_POWER 对应 dutyMode。Scan Window=8 s 则是业务层自己的计时,不是系统扫描参数。我刻意把这两个概念分开,否则后面看日志时很容易误以为“8 秒”是 BLE API 的配置项。

这次用低功耗模式,是因为 Demo 目标是验证稳定性,不追求最快发现。正式产品里的配网页、后台刷新页、诊断页完全可以用不同策略。

三、23 条广播不能直接变成 23 行列表

BLE 广播天然会重复。一个设备在几秒内被收到多次是正常现象。如果每次回调都直接 push() 到 List,同一设备会出现多行,RSSI 每变化一次还可能触发整段列表刷新。

我用 deviceId 做本次扫描会话的去重键:

private deviceMap:
  Map<string, ScanDevice> = new Map()

private onReceiveEvent = (
  report: ble.ScanReport
): void => {
  this.rawReports++

  const old = this.deviceMap.get(report.deviceId)

  if (old) {
    old.rssi = report.rssi
    old.lastSeen = Date.now()
    this.duplicatesMerged++
    return
  }

  this.deviceMap.set(report.deviceId, {
    id: report.deviceId,
    name: this.resolveName(report),
    rssi: report.rssi,
    lastSeen: Date.now()
  })
}

最终 Raw Reports=23、Unique Devices=7、Duplicates Merged=16。这里的“合并”不是丢数据,而是把“广播事件”和“设备实体”分开。

正式项目还可以继续做一层节流:RSSI 更新先进入 Map,每 300~500ms 再批量刷新 UI,而不是每个广播都立即触发界面变化。

四、页面退出时要把扫描和监听成对收掉

第三个问题更像生命周期 bug。第一次进入页面注册一次 BLEDeviceFind,如果退出只停扫描、不 off 回调,第二次进入又会再注册一次。设备数量看起来没变,但一条广播会执行两次业务代码。

所以我把释放收口到一个方法:

private stopScan(): void {
  try {
    this.scanner.stopScan()
  } finally {
    this.scanner.off(
      'BLEDeviceFind',
      this.onReceiveEvent
    )
    this.state = 'STABLE'
  }
}

aboutToDisappear(): void {
  this.stopScan()
}

顺序是先停止扫描,再注销监听。即使 stopScan() 抛异常,finally 仍尽量回收回调。

扫描期间诊断页显示 Active Listeners=1;进入最终 STABLE 后监听实际会被释放。这个计数我专门留下来,就是为了防止以后回归成重复订阅。

五、我还给业务扫描窗口加了一层 generation

用户快速点两次“开始扫描”,页面自己的 8 秒计时也可能重复。新扫描开始时我会递增 generation,旧计时器醒来后发现 generation 已变化就直接退出;手动停止时同样先让旧 generation 失效。

这个细节不影响 BLE API,却决定页面状态机会不会出现“第二轮刚开始,第一轮计时器突然把它停掉”的问题。

我最后把状态整理成:

IDLE → SCANNING → MERGING → STOPPING → STABLE

六、调试时我只看几组能说明问题的数字

工程拆成 BleScanPage.ets、BleScanController.ets、ScanFilterBuilder.ets 和 ScanDeviceStore.ets。页面不负责拼扫描参数,也不直接维护底层 listener。

HiLog 会连续出现:startScan filters=1 mode=LOW_POWER、filter keys=2 manufacturer=0x1234、raw=23 unique=7 merged=16、invalidParams=0 listener=1、stopScan、State: SCANNING -> STABLE。

七、最终结果里最值得看的是 16 和 0

手机运行页最终显示 Raw Reports=23、Unique Devices=7、Duplicates Merged=16、Invalid Params=0、Active Listeners=1、Scan Window=8 s、Last Device=Sensor_A7。

其中 16 说明重复广播已经被归并,0 说明可选参数构造没有再触发无效参数错误。发现的 7 台设备仍然保留最新 RSSI,而不是堆出 23 行原始事件。

八、Demo 到正式产品还有几个边界

扫描前要确认蓝牙与权限状态,不能把“扫描为空”直接解释成附近没有设备。RSSI 也不适合直接换算成精确距离,它更适合作为近远趋势和排序参考。

deviceId 适合做本次扫描会话的去重键;如果产品要长期识别设备,还应结合业务协议、服务 UUID 或设备自己的稳定身份字段。

页面进入后台是否继续扫描,也要看产品场景。普通设备选择页我倾向于停止;确实需要后台发现时,应使用与系统规则匹配的后台能力,而不是靠页面 listener 长期存活。

九、这次真正收口的是扫描生命周期

BLE 扫描接口本身并不难,难的是不把隐含前提带进工程:筛选字段不一定有值,同一个设备一定会重复广播,页面也一定会多次进入退出。

把过滤器按需构造、结果按设备合并、扫描与监听成对释放以后,这个页面才从“能扫到设备”变成“可以反复使用的工程组件”。

Logo

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

更多推荐