把 connect 调用后的一行状态更新改成“已连接”,可以让页面看起来反应很快,却把整个接入流程最重要的事实省略了:请求被接收与链路建立并不是同一件事。本篇给 BleWorkbench 增加 GATT 客户端。页面只表达连接意图,系统事件确认连接事实,下一步的服务发现必须等这份事实成立。

GATT 连接意图与系统状态分离

先把连接输入固定下来

保留前两篇的 BleGate.ets、Protocol.ets 和权限配置,新增 common/BleLink.ets。本篇入口页允许填写第二篇当前扫描结果中的 deviceId,便于把连接过程单独观察清楚;它不是永久设备编号,也不是任意格式相似的字符串。选择目标之前应结束第二篇的扫描,确认候选记录的 connectable 为真,再把当前地址用于连接。

真实产品可以把发现列表的点击动作直接接到连接入口。这里手工填写地址的目的,是让最小页面不同时承担广播列表、连接过程和服务详情三个界面。输入框不会自动配对周边设备,也不会因为地址语法正确就虚构目标名称。外设必须处于可连接状态,不能把一个只发送广播的信标当成 GATT 服务端。

API 12 的 GATT 开发指南给出创建客户端、建立连接以及发现服务的关系。BleWorkbench 采用 createGattClientDevice 返回的客户端实例,不使用系统应用专用接口,也不调用经典蓝牙的其他 Profile 去代替 BLE GATT。准入检查仍由第一篇的函数完成,但外设是否接受连接必须在实际连接阶段确认。

一个客户端实例只属于当前连接代次。用户断开后重新发起连接,或者改选另一个地址,旧实例必须先结束。把实例放在全局数组里无限积累,会让事件回调、超时计时器和服务操作不再对应同一个目标。我们因此让 BleLink 成为唯一拥有者,页面不能直接保存并反复替换原始客户端。

本篇不主动调用配对接口。某些设备会在访问受保护特征值时提出配对或认证要求,这与建立基础 GATT 链路不是同一个阶段。连接页应先明确它已经完成哪些事实,后续访问失败再对照外设的权限策略处理。提前把所有失败解释成“需要配对”,会让用户面对不必要的操作,也可能掩盖缺少服务或特征值的问题。

创建客户端不等于连接成功

BleLink 接收一个普通状态回调,输出页面能够理解的阶段。它保留当前客户端、监听函数、连接事实、代次以及等待期限,方法 device 只有在已确认连接后才交出客户端供后续操作。这个入口能够让第四篇拒绝在连接中调用服务发现,避免每个按钮分别检查一遍底层状态。

// common/BleLink.ets
import { ble, constant } from '@kit.ConnectivityKit';
import { BusinessError } from '@kit.BasicServicesKit';

export class BleLink {
  private client: ble.GattClientDevice | undefined = undefined;
  private listener: ((state: ble.BLEConnectionChangeState) => void) | undefined = undefined;
  private connected: boolean = false;
  private epoch: number = 0;
  private timer: number = -1;
  private report: (status: string) => void;

  constructor(report: (status: string) => void) { this.report = report; }

  private clearDeadline(): void {
    if (this.timer !== -1) { clearTimeout(this.timer); this.timer = -1; }
  }

  connect(deviceId: string): void {
    if (!deviceId.trim()) throw new Error('请填写本轮发现的设备地址');
    this.close();
    const generation = ++this.epoch;
    const client = ble.createGattClientDevice(deviceId.trim());
    this.client = client;
    this.listener = (event: ble.BLEConnectionChangeState): void => {
      if (generation !== this.epoch || client !== this.client) return;
      if (event.state === constant.ProfileConnectionState.STATE_CONNECTED) {
        this.clearDeadline();
        this.connected = true;
        this.report('已连接');
      } else if (event.state === constant.ProfileConnectionState.STATE_DISCONNECTED) {
        this.clearDeadline();
        this.connected = false;
        this.report('已断开');
      } else if (event.state === constant.ProfileConnectionState.STATE_CONNECTING) {
        this.report('连接中');
      } else {
        this.connected = false;
        this.report('正在断开');
      }
    };
    try {
      client.on('BLEConnectionStateChange', this.listener);
      this.report('连接中');
      this.timer = setTimeout(() => {
        if (generation !== this.epoch || this.connected) return;
        try { this.close(); this.report('连接等待结束,请重新发现'); }
        catch (error) { this.report(`清理失败:${(error as BusinessError).code}`); }
      }, 12000);
      client.connect();
    } catch (error) {
      this.close();
      throw error;
    }
  }

  device(): ble.GattClientDevice {
    if (!this.connected || !this.client) throw new Error('尚未建立 GATT 连接');
    return this.client;
  }

  close(): void {
    this.epoch++;
    this.connected = false;
    this.clearDeadline();
    const client = this.client;
    if (!client) return;
    if (this.listener) {
      try { client.off('BLEConnectionStateChange', this.listener); }
      catch (error) { console.error(`off ${(error as BusinessError).code}`); }
    }
    try { client.disconnect(); }
    catch (error) { console.error(`disconnect ${(error as BusinessError).code}`); }
    client.close();
    this.client = undefined;
    this.listener = undefined;
  }
}

这段代码中,连接成功只有一个来源:BLEConnectionStateChange 返回 STATE_CONNECTED。connect 方法本身没有返回“远端已经准备好”的业务承诺,它可能同步抛错,也可能在稍后收到断开事件。前者表示发起阶段失败,后者表示链路状态改变;页面应保留这个区别,不能把所有断开都归结为输入地址错误。

回调除了比较代次,还比较客户端对象身份。原因是关闭旧客户端不一定能撤销已经进入事件队列的回调;当新客户端已经创建时,旧事件不能清掉新连接的状态。代次在 close 的第一步递增,让迟到事件立即失效;对象比较进一步表明这条事实属于当前被持有的实例。

等待期限是产品边界。本例允许一次连接意图等待十二秒,期限到达仍未确认连接就主动清理,并提示重新发现。它既不是蓝牙协议规定的建立时长,也不能用来推导设备性能。用户点击断开时立即结束等待,不继续把计时器留在后台,之后的连接必须从新的主动操作开始。

DevEco Studio 中按代次校验连接事件

代次编号属于本地控制信息,不发送给外设,也不能代替无线链路协议里的序号。它只用来区分应用先后创建的客户端,让同一条异步事件有明确归属。第五篇的数据帧还会有独立样本序号,那个序号描述外设数据顺序;把两者混为一个字段,会使重连后的样本判断依赖 UI 曾经点击多少次。

在这个封装中,连接已经断开并不会自动删除客户端引用。断开事件告诉我们链路事实改变,资源是否关闭仍由拥有者执行。这样的安排可以避免在系统事件回调里同时嵌套创建下一条连接,让错误来源保持单一。用户再次发起连接时先完成旧实例清理,清理不成功就不会继续累积新的客户端。

让按钮和链路状态各守边界

下面完整替换入口页。连接按钮只有在存在输入地址且当前不处于连接中或已连接时可用,断开按钮则可以撤销等待中的权限检查和已有客户端。快照在点击时取出,因此用户随后编辑输入框不会悄悄改变已经发起的连接目标;改选设备需要先结束旧会话再重新连接。

// pages/Index.ets:本篇入口页
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { ensureBle } from '../common/BleGate';
import { BleLink } from '../common/BleLink';

@Entry
@Component
struct Index {
  @State address: string = '';
  @State status: string = '未连接';
  @State busy: boolean = false;
  private alive: boolean = true;
  private visible: boolean = true;
  private serial: number = 0;
  private releaseError: string = '';
  private link: BleLink = new BleLink((status: string) => {
    if (this.alive && this.visible) this.status = status;
  });

  private stopWork(): boolean {
    this.serial++;
    try { this.link.close(); this.releaseError = ''; return true; }
    catch (error) {
      this.releaseError = (error as Error).message;
      console.error(`close ${(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 connect(): Promise<void> {
    if (this.busy || this.status === '连接中' || this.status === '已连接') return;
    const ticket = ++this.serial;
    const target = this.address.trim();
    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.link.connect(target);
    } catch (error) {
      if (this.alive && this.visible && ticket === this.serial) {
        this.status = `连接请求失败:${(error as Error).message}`;
      }
    } finally {
      if (this.alive && this.visible && ticket === this.serial) this.busy = false;
    }
  }

  build() {
    Column({ space: 16 }) {
      Text('建立 GATT 连接').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#243B5A')
      Text('填入本轮发现的设备地址').fontSize(14).fontColor('#66758B')
      TextInput({ text: this.address, placeholder: '当前设备地址' }).height(48)
        .onChange((value: string) => { this.address = value; })
      Text(this.status).fontSize(24).fontColor('#315CA8').margin({ top: 28, bottom: 20 })
      Button('连接设备').width('100%').height(44).backgroundColor('#315CA8')
        .enabled(!this.busy && this.address.trim().length > 0 && this.status !== '连接中' && this.status !== '已连接')
        .onClick(() => { this.connect(); })
      Button('断开并释放').width('100%').height(44).backgroundColor('#E0E7F2').fontColor('#243B5A')
        .onClick(() => {
          const stopped = this.stopWork(); this.busy = false;
          this.status = stopped ? '未连接' : `清理待重试:${this.releaseError}`;
        })
      Text('连接状态由系统事件确认').fontSize(13).fontColor('#66758B')
    }.width('100%').height('100%').padding({ top: 48, left: 24, right: 24 })
      .backgroundColor('#F1F4FB')
  }
}

GATT 连接页的初始状态

这里保留输入文本与连接事实的分工。输入框是一份待提交草稿,状态文字是一份已经确认的过程说明。目标提交之后,即使用户改动草稿,也不应把底层实例重新指向新文本。对于正式产品,可以在连接期间锁定输入框或者在界面上显示已提交目标,但不能只修改一段页面文字就假装已经切换设备。

页面的忙碌标志只覆盖权限检查与发起步骤,不负责表示链路是否已建立。连接中与已连接分别来自 BleLink 的回调,并参与按钮约束。二者分开后,授权弹窗结束不等于连接完成,连接断开也不意味着某个权限 Promise 重新开始。避免把这些过程塞进一个 loading,能够让错误处理更准确。

页面隐藏时主动释放客户端,销毁时首先撤销回写资格。入口页的普通 alive 和 visible 字段不会触发界面重绘,销毁回调也不再写状态变量。这里的 page hide 处理页面路由离开;应用进入后台的策略仍需要 Ability 生命周期配合,第六篇会把两者汇合到统一会话对象。

断开与释放是两件相关操作

disconnect 请求结束当前连接,close 归还客户端资源。代码会分别尝试,不能把调用 disconnect 当成所有资源已经回收。即使当前已经断开,客户端对象仍由 BleLink 持有,下一次操作前也必须完成 close。清理函数只有在 close 正常返回后才清空引用,因此清理失败时仍能由原拥有者重试。

事件监听采用创建时保存的同一函数引用解除,不使用无回调参数的 off 去影响其他订阅者。若解除监听失败,先记录失败并继续尝试断开和关闭;代次已经失效,迟到事件没有更新资格。关闭本身失败则向调用方抛出,使页面和上层清楚资源归还没有完成,而不是用正常状态覆盖这个事实。

这并不意味着任意错误都应该马上自动重连。权限撤销、蓝牙关闭、外设断电和目标暂时繁忙,恢复条件各不相同。反复调用连接既可能延长资源占用,也会让用户难以判断应用是否仍在操作他刚刚取消的设备。当前设计在失败后停住,由用户重新发现、核对目标并发出下一次连接意图。

按事件顺序验证这个最小闭环

先用第二篇得到可连接的当前地址,观察点击连接后状态先进入连接中,再由系统事件决定是否已连接。接着让外设停止广播或断电,确认无法仅凭 connect 没有抛错就进入服务操作。设备主动断开时,页面应显示已断开,device 方法拒绝交出可用于后续操作的客户端。

然后在连接等待期间点击断开,等待原来的期限过去,页面不应再次被旧计时器改写;迅速切换到另一个当前地址,也不应被旧客户端的断开事件覆盖。可以通过代码中代次与对象身份条件分析这些路径,并在目标设备上按相同步骤逐项核对。验证记录应注明操作顺序,不能只截一张“已连接”就宣称覆盖了竞态。

还应验证权限请求期间退出页面。授权结果可能迟到,但 serial 与可见性检查阻止它继续建立客户端;组件销毁之后也不会写入状态。这个用例特别适合发现“页面退出后蓝牙仍在连接”的问题,因为错误往往出在授权之后缺少一次归属检查,而不在 connect 方法本身。

连接请求、系统事件与清理之间的时序关系

本篇完成的是连接事实的建立与归还,不把连接成功解释成协议已经可用。外设可以接受链路,却缺少预期服务;下一篇会通过 getServices 找到真实返回的服务和特征值,再判断读取与通知能力。只有把这层证据补上,客户端才能知道随后传输的究竟是什么数据。

处理故障时,应把“用户取消”保留为一种明确意图。用户按下断开后,即使外设稍后又报告连接完成,旧代次也不再有资格把界面改回已连接。这里的优先级来自产品契约:用户已经撤回连接请求,任何晚到的成功都不能替他重新作出选择。这个原则比单纯检查某个字符串是否等于连接中更能覆盖异步边界。

更完整的设备应用可以把等待期限、断开原因和重试入口做成可观察状态,但当前教程没有凭空推导底层未提供的原因。如果回调只报告断开,就显示断开;想知道是否由于电量、距离或认证失败,需要额外协议或系统证据。用看似专业的具体解释替代未知信息,会把工程排查带向错误方向。

测试时还应让外设在连接后保持链路而不给出目标服务。页面可以正确显示已连接,第四篇却应拒绝进入数据操作。这不是前一篇判断错误,而是两个阶段分别完成了自己的验证。把这类看似矛盾的结果提前列入检查路径,可以防止后续开发为了追求一个统一绿色状态而跳过服务契约。

参考资料

Logo

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

更多推荐