扫描列表为空,最常见的处理冲动是把过滤条件全部删掉,再把扫描功耗调到最高。这样或许能看到更多广播,却未必能找到当前协议需要的设备。真正值得先检查的是:目标外设有没有把服务标识放进广播,广播当前是否可连接,以及页面是否把不同广播错误合并成了同一条记录。BleWorkbench 的第二步只做有限时间的设备发现,连接仍然交给下一篇。

服务广播过滤与有限设备发现

先约定外设到底广播什么

沿用第一篇的 API 12 Stage 工程和 BleGate.ets、权限声明。准备一台由自己控制、能够配置 GATT 服务和广播数据的 BLE 测试外设。这个系列约定一个自定义主服务,以及用于样本数据的特征值;它们是本工程的协议常量,不是系统预置服务。外设应在广播中携带服务 UUID,允许客户端连接,后续才有条件发现相同的服务与特征值。

// common/Protocol.ets
export const SERVICE_UUID: string = 'd973f2e0-b19e-11ed-afa1-0242ac120002';
export const SAMPLE_UUID: string = 'd973f2e1-b19e-11ed-afa1-0242ac120002';
export const CCC_UUID: string = '00002902-0000-1000-8000-00805f9b34fb';

把服务 UUID 写进代码,并不会让附近的任何设备自动提供这项服务。若使用可配置的外设调试工具,应在服务编辑界面建立同一个完整 UUID,再在广播配置中加入该服务,最后开启广播。固件已经存在的设备则应先核对其协议,不能擅自拿本系列常量去解释另一家设备的数据。这里不给出一个假地址作为默认连接对象。

广播过滤与服务发现还不是同一次检查。过滤作用于外设发出的广播包,服务发现作用于连接后的 GATT 数据库;外设可能提供目标服务却没有在广播里公布它,也可能广播了一个标识但连接后的实现不完整。前者会被当前过滤规则排除,后者需要第四篇的契约检查识别,不能把其中任何一种情况简单称为手机扫描坏了。

BLE 扫描指南说明 API 14 及以前采用单通道扫描接口,API 15 起另有实例化扫描器。本文固定使用 API 12 的 startBLEScan 与 BLEDeviceFind,不把新版本的 BleScanner 混进同一份工程。应用内也只有一个 NearbyScan 拥有扫描权,两个页面同时调用全局开始或停止会互相影响。

为了减少联调歧义,记录外设配置时至少保留服务 UUID、广播是否包含服务列表、是否允许连接三项。名称可以帮助人在调试工具中找到设备,却不能替代这三项协议事实。同一个外设切换固件或广播模式之后,名称可能没有改变,真正影响发现行为的配置却已经不同,因此不能只凭屏幕上的名字判断配置一致。

空列表之外,还有三种误判

第一种是按名称去重。名称可以不广播,也可能由多个外设使用同一个值。页面展示名称帮助人识别,Map 的键则采用当前扫描结果的 deviceId。同一轮发现里,同地址的新报告更新原记录,信号和可连接标志随报告刷新,不再不断追加相同设备。没有名称时显示“未广播名称”,不把它变成“未知故障”。

第二种是把地址写入永久配置。官方接口说明中的地址可能是出于隐私保护而提供的随机地址,不能把一次发现的地址当成永远不变的物理序列号。本工程只让它在当前交互中作为连接目标。产品如果需要跨天认出同一设备,应另外设计经过授权的设备身份协议,而不是以为扫描列表的字符串天然具有业务主键的性质。

第三种是把 RSSI 换算成精确米数。RSSI 可以帮助观察当前接收强度,却同时受到遮挡、发射功率和环境变化影响。这里原样显示 dBm,不据此标记“距离两米”,也不把最强信号自动当成用户想连接的设备。选择动作需要明确由用户执行,接近手机只是观察线索,不是身份验证。

让扫描有开始,也有归还

下面的拥有者保存本轮结果、事件回调和停止计时器。回调必须保留同一函数引用,停止时只解除自己的监听。先安装监听,再启动扫描;启动失败时归还已经安装的监听。十秒是产品选择的发现窗口,用来限定一次用户操作的范围,不是接口承诺的发现延迟,也不意味着十秒内没找到就能证明设备不存在。

// common/NearbyScan.ets
import { ble } from '@kit.ConnectivityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { SERVICE_UUID } from './Protocol';

export interface Nearby {
  id: string;
  name: string;
  rssi: number;
  connectable: boolean;
}

export class NearbyScan {
  private active: boolean = false;
  private listening: boolean = false;
  private started: boolean = false;
  private epoch: number = 0;
  private found: ((items: ble.ScanResult[]) => void) | undefined = undefined;
  private timer: number = -1;
  private rows: Map<string, Nearby> = new Map();
  private onRows: (rows: Nearby[]) => void;
  private onStatus: (status: string) => void;
  private collect(items: ble.ScanResult[]): void {
    if (!this.active) return;
    for (const item of items) {
      this.rows.set(item.deviceId, {
        id: item.deviceId, name: item.deviceName || '未广播名称',
        rssi: item.rssi, connectable: item.connectable
      });
    }
    this.onRows(Array.from(this.rows.values()));
  }

  constructor(onRows: (rows: Nearby[]) => void, onStatus: (status: string) => void) {
    this.onRows = onRows;
    this.onStatus = onStatus;
  }

  start(): void {
    this.stop();
    this.rows.clear();
    this.onRows([]);
    const generation = ++this.epoch;
    this.found = (items: ble.ScanResult[]): void => {
      if (generation === this.epoch && this.active) this.collect(items);
    };
    ble.on('BLEDeviceFind', this.found);
    this.listening = true;
    try {
      this.active = true;
      ble.startBLEScan([{ serviceUuid: SERVICE_UUID }], {
        interval: 500,
        dutyMode: ble.ScanDuty.SCAN_MODE_LOW_POWER,
        matchMode: ble.MatchMode.MATCH_MODE_AGGRESSIVE
      });
      this.started = true;
      this.onStatus('正在发现');
      this.timer = setTimeout(() => {
        if (generation !== this.epoch) return;
        try { this.stop(); this.onStatus('本次发现结束'); }
        catch (error) { this.onStatus(`停止失败:${(error as BusinessError).code}`); }
      }, 10000);
    } catch (error) {
      this.stop();
      throw error;
    }
  }

  stop(): void {
    this.epoch++;
    this.active = false;
    if (this.timer !== -1) { clearTimeout(this.timer); this.timer = -1; }
    let failure: Error | undefined = undefined;
    if (this.started) {
      try { ble.stopBLEScan(); this.started = false; }
      catch (error) { failure = error as Error; }
    }
    if (this.listening && this.found) {
      try {
        ble.off('BLEDeviceFind', this.found);
        this.listening = false; this.found = undefined;
      }
      catch (error) { failure = error as Error; }
    }
    if (failure) throw failure;
  }
}

interval 控制结果上报相关的配置,不应与扫描窗口混为一谈。页面的十秒计时器决定何时结束本次操作,低功耗模式表达扫描策略,两者没有“相加得到设备发现耗时”的关系。初版使用官方提供的低功耗枚举,先把过滤、连接条件和数据更新解释清楚,之后再根据真实设备表现讨论不同策略的代价。

停止方法先递增代次并关掉接收资格,再处理系统扫描和监听。每轮创建独立监听函数和计时回调,旧函数即使已经排队,也会被代次检查拒绝,不会在下一轮开始后重新取得写入资格。每种资源的清理独立尝试,停止扫描失败不妨碍解除监听;失败的资源标记保留为待处理,下一次开始仍会先尝试停止。这里没有在异常后把所有标志强行清空,所以应用不会假装已经归还了未确认归还的资源。

Map 转成数组是交给 UI 的快照,不让组件直接遍历一个正在更新的可变容器。当前实现保留发现顺序,没有每次都按 RSSI 重新排序,列表因此不会在用户准备点选时频繁跳动。若业务需要排名,可以增加稳定的排序策略,但应先决定更新频率和选中项如何保持,不能因为信号变了一点就换掉用户正在看的位置。

发现测试设备页面的初始状态

回调中的广播数据也没有直接进入 UI。当前页面只需要地址、名称、信号和可连接性,所以把 ScanResult 转成小型记录;完整广播包留给确有解析需求的模块。如果每次报告都序列化整个数组并写入可见日志,重复广播会放大页面更新和日志压力,也容易把无关设备信息留在调试输出里。先保存必要字段,是这个发现页明确的数据边界。

对于停止失败,界面提示和内部拥有权应分开处理。用户可以看到停止请求已经提出,但失败标志仍留在扫描拥有者里,下一次操作会再次尝试归还。应用不能因为想让按钮恢复成正常颜色,就把所有资源标志重置为 false。界面状态容易修改,底层扫描是否确实停止必须根据调用结果判断。

把开始按钮接到真实准入条件

替换 pages/Index.ets,公共模块保留第一篇的 BleGate 和本篇的两个新文件。列表初始为空,底部明确显示尚未发现,开始按钮会先检查权限和开关。图中的零台是初始数据,不是一次扫描结果;只有 BLEDeviceFind 返回的记录才会被加入列表。停止按钮也会使等待中的准入结果失效,避免用户停止之后异步流程又重新启动扫描。

// pages/Index.ets:本篇完整替换入口页
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { ensureBle } from '../common/BleGate';
import { Nearby, NearbyScan } from '../common/NearbyScan';

@Entry
@Component
struct Index {
  @State rows: Nearby[] = [];
  @State status: string = '尚未发现';
  @State busy: boolean = false;
  private alive: boolean = true;
  private visible: boolean = true;
  private serial: number = 0;
  private releaseError: string = '';
  private scan: NearbyScan = new NearbyScan(
    (rows: Nearby[]) => { if (this.alive && this.visible) this.rows = rows; },
    (status: string) => { if (this.alive && this.visible) this.status = status; }
  );

  private stopWork(): boolean {
    this.serial++;
    try { this.scan.stop(); this.releaseError = ''; return true; }
    catch (error) {
      this.releaseError = (error as Error).message;
      console.error(`scan stop ${(error as BusinessError).code}`);
      return false;
    }
  }
  onPageHide(): void { this.visible = false; this.stopWork(); }
  onPageShow(): void {
    this.visible = true; this.busy = false;
    this.status = this.releaseError ? `停止待重试:${this.releaseError}` : '尚未发现';
  }
  aboutToDisappear(): void { this.alive = false; this.stopWork(); }

  private async start(): Promise<void> {
    if (this.busy) return;
    const ticket = ++this.serial;
    this.busy = true;
    try {
      const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
      const gate = await ensureBle(context);
      if (!this.alive || !this.visible || ticket !== this.serial) return;
      if (!gate.ready) { this.status = `${gate.permission} · ${gate.radio}`; return; }
      this.scan.start();
    } catch (error) {
      if (this.alive && this.visible && ticket === this.serial) {
        this.status = `发现失败:${(error as BusinessError).code}`;
      }
    } finally {
      if (this.alive && this.visible && ticket === this.serial) this.busy = false;
    }
  }

  build() {
    Column({ space: 14 }) {
      Text('发现测试设备').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#133A36')
      Text('按服务广播过滤').fontSize(14).fontColor('#64746F')
      Row({ space: 12 }) {
        Button('开始发现').backgroundColor('#147D70').enabled(!this.busy)
          .onClick(() => { this.start(); })
        Button('停止').backgroundColor('#DDEBE7').fontColor('#133A36')
          .onClick(() => {
            const stopped = this.stopWork(); this.busy = false;
            this.status = stopped ? '已停止' : `停止待重试:${this.releaseError}`;
          })
      }
      Text(`发现 ${this.rows.length} 台`).fontSize(20).fontColor('#147D70')
      List({ space: 12 }) {
        ForEach(this.rows, (row: Nearby) => {
          ListItem() {
            Column({ space: 6 }) {
              Text(row.name).fontSize(18)
              Text(row.id).fontSize(13).copyOption(CopyOptions.InApp)
              Text(`${row.rssi} dBm · ${row.connectable ? '可连接' : '不可连接'}`).fontSize(14)
            }.alignItems(HorizontalAlign.Start).padding(16).width('100%')
              .backgroundColor(Color.White).borderRadius(14)
          }
        }, (row: Nearby) => row.id)
      }.width('100%').height(240)
      Text(this.status).fontSize(16).fontColor('#147D70')
      Text('每次最多发现 10 秒').fontSize(13).fontColor('#64746F')
    }.width('100%').height('100%').padding({ top: 48, left: 24, right: 24 })
      .backgroundColor('#F0F8F4')
  }
}

页面隐藏与组件销毁时都关闭当前扫描。隐藏时可能仍然保留页面实例,所以代码单独维护可见字段;销毁时则首先撤销 alive,让任何后续回调失去回写资格。销毁回调不再修改状态变量。这个页面级清理解决路由离开时的问题,应用退到后台的统一策略将在第六篇通过 EntryAbility 接入,不能把两个生命周期混为一谈。

扫描期间突然关闭系统蓝牙,底层可能抛错或停止产生报告,界面不能把没有新数据等同于设备离线。当前列表表示“本轮曾发现”,不是持续在线清单;停止后仍保留结果供用户核对。下一轮开始主动清空,是为了避免旧地址混入新一轮选择。若需要在线状态,需要独立的连接或业务心跳证据。

按过滤层逐项排查

先让自有外设只广播目标服务,确认页面能够接收记录,再修改名称,观察同一地址是否更新而不是新增。接着保留服务但从广播中移除服务 UUID,此时当前过滤器预期不再选中它;恢复广播配置后重新开始。这组步骤验证的是筛选契约,不需要拿所有周边设备数量作为对错标准。

然后让外设使用不可连接广播。若设备仍匹配过滤条件,记录可以出现,但 connectable 应阻止下一阶段提供连接动作。广播可被收到与可以建立 GATT 连接是两项不同能力,提前把这个区别放进数据模型,下一篇就不必再从 UI 文案猜测设备能不能连。

最后检查快速重复开始、立即停止、离开页面以及启动接口抛错的情形。新的开始先归还旧扫描;停止清掉计时器;旧监听函数的迟到调用不会修改新一轮状态;重新进入页面也不会自动恢复扫描。对于空列表,按权限、开关、外设广播、服务过滤、接收回调的顺序查找证据,比直接增加扫描强度更能定位问题。

从外设广播到页面记录的分步检查

发现页的价值是交付一个用户可以核对的候选目标,而不是替用户保证连接成功。下一篇接受当前发现所得的 deviceId,建立独立 GATT 客户端,把连接请求和系统确认分开。扫描在选择目标后结束,连接拥有自己的状态与关闭路径,两个阶段不会争用同一个含糊的“正在工作”标志。

验证时应把系统返回的地址当作当次结果使用,不要手工编造格式正确的字符串来证明扫描闭环。格式正确只能说明输入像一个地址,不能证明它对应当前可访问的外设。下一篇保留手工输入入口用于聚焦连接代码,但地址来源必须是本篇的真实扫描结果,应用也不会为输入字符串补造名称或信号值。

设备比较多时,列表容量也是一个需要提前约定的条件。本系列通过目标服务过滤缩小范围,适用于少量自有外设联调。如果产品场景允许大量同服务设备,应另加容量上限、更新节流与可见记录淘汰规则,并说明用户选中的条目如何保留。不能无界保存全部历史广播,也不能在没有数据的情况下宣称某个容量配置已经通过压力验证。

参考资料

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐