评审一套蓝牙接入实现,最后应该问的并不是“成功界面在哪里”,而是用户停止之后还会不会继续扫描,应用进入后台之后谁负责关闭通知,重新连接时有没有把上一台设备的服务对象带进来。前五篇已经分别建立准入、发现、连接、服务和数据边界,本篇把这些拥有者交给统一的前台会话,形成一个可以明确结束的工程闭环。

前台蓝牙会话的统一收尾

把责任放到能够看见全流程的位置

页面知道用户点了什么,EntryAbility 知道应用何时进入前台或后台,扫描器知道全局扫描是否由自己启动,通知拥有者知道使能请求有没有结束。任何单一页面回调都无法替代其余信息。因此最终结构新增 BleSession,它拥有前面三个资源模块,同时接受页面意图与 Ability 生命周期信号,向页面发布普通快照。

此处的范围仍然是 API 12 Stage 的前台联调工具,没有启用后台长时任务,也不在后台自动重连。应用进入后台就撤销当前意图,依次清理已有资源;返回前台只恢复可操作条件,不恢复曾经连接的目标。需要继续采样时,用户重新发现并选择设备,这个策略是明确的产品边界。

页面不再保存原始 GATT 客户端,Ability 也不直接操作特征值。它们都通过同一个会话入口请求结束,这样用户停止、页面离开和应用退后台不会各写一套半完整的清理代码。快照只包含列表、目标、样本文字、忙碌与连接状态,不把带有系统资源的对象暴露给声明式 UI。

UIAbility 生命周期说明提供前后台回调,而页面生命周期描述路由与组件变化。应用退到后台时,页面可能仍然存在;页面从路由中退出时,Ability 也可能继续运行。两种变化都需要传给会话,不能认为实现了 onPageHide 就等于覆盖了应用后台。

统一会话并不等于所有状态都共用一个字符串。界面需要的文字只是快照的一部分,资源拥有者仍保留扫描、通知和客户端各自的事实。清理过程中可能出现扫描已停、通知关闭失败、客户端已关闭这样的组合,不能因为某一行显示已结束就把其他责任抹掉。把资源状态留在相应模块,能够让重试指向实际尚未归还的对象。

会话也没有把外设协议写进 Ability。服务 UUID、通知对象与短帧规则仍分别位于前文模块,生命周期层只知道何时允许进入、何时必须退出。这样以后修改样本格式,只需更新协议和解析,不会顺便改变应用后台策略;更换界面布局,也不需要复制底层清理顺序。

一个结束入口串起三个拥有者

新增下面的 common/BleSession.ets,并保留前五篇全部公共模块。cleanupCore 按扫描、通知、客户端的顺序尝试归还资源,单项失败仍继续后面的步骤。cleanup 把并发清理合并到同一个 Promise,避免生命周期回调与用户按钮同时触发重复的异步通知关闭。

// common/BleSession.ets
import { common } from '@kit.AbilityKit';
import { ensureBle } from './BleGate';
import { Nearby, NearbyScan } from './NearbyScan';
import { BleLink } from './BleLink';
import { SampleFeed } from './SampleFeed';
import { selectSample } from './ServiceContract';
import { SampleFrame } from './SampleFrame';

export interface SessionView {
  status: string;
  rows: Nearby[];
  target: string;
  value: string;
  busy: boolean;
  connected: boolean;
}

export class BleSession {
  private foreground: boolean = false;
  private serial: number = 0;
  private busy: boolean = false;
  private connected: boolean = false;
  private status: string = '等待前台';
  private target: string = '';
  private value: string = '暂无样本';
  private rows: Nearby[] = [];
  private observer: ((view: SessionView) => void) | undefined = undefined;
  private closing: Promise<void> | undefined = undefined;
  private releaseError: string = '';
  private scan: NearbyScan = new NearbyScan(
    (rows: Nearby[]) => { if (this.foreground) { this.rows = rows; this.emit(); } },
    (status: string) => { if (this.foreground) { this.status = status; this.emit(); } }
  );
  private link: BleLink = new BleLink((status: string) => {
    this.connected = status === '已连接';
    if (this.foreground && !this.closing) { this.status = status; this.emit(); }
  });
  private feed: SampleFeed = new SampleFeed(
    (frame: SampleFrame) => {
      if (!this.foreground || this.closing) return;
      this.value = `${frame.temperature.toFixed(2)} °C · ${frame.humidity} %`;
      this.emit();
    },
    (message: string) => { if (this.foreground) { this.status = message; this.emit(); } }
  );

  snapshot(): SessionView {
    return { status: this.status, rows: this.rows.slice(), target: this.target,
      value: this.value, busy: this.busy, connected: this.connected };
  }
  observe(callback: (view: SessionView) => void): void {
    this.observer = callback; this.emit();
  }
  unobserve(callback: (view: SessionView) => void): void {
    if (this.observer === callback) this.observer = undefined;
  }
  private emit(): void { if (this.observer) this.observer(this.snapshot()); }
  resume(): void {
    this.foreground = true;
    if (!this.closing) {
      this.status = this.releaseError ? `清理待重试:${this.releaseError}` : '前台就绪';
    }
    this.emit();
  }
  suspend(): void {
    this.foreground = false;
    this.end('后台已结束会话').catch((error: Error) => console.error(error.message));
  }
  private current(ticket: number): boolean {
    return this.foreground && ticket === this.serial;
  }

  private async cleanupCore(): Promise<void> {
    let failure: Error | undefined = undefined;
    try { this.scan.stop(); }
    catch (error) { failure = error as Error; }
    try { await this.feed.stop(); }
    catch (error) { failure = error as Error; }
    try { this.link.close(); this.feed.forgetClosedClient(); }
    catch (error) { failure = error as Error; }
    this.connected = false;
    if (failure) throw failure;
  }
  private async cleanup(): Promise<void> {
    if (this.closing) return this.closing;
    const job = this.cleanupCore();
    this.closing = job;
    try { await job; this.releaseError = ''; }
    catch (error) { this.releaseError = (error as Error).message; throw error; }
    finally { if (this.closing === job) this.closing = undefined; }
  }

  async end(message: string = '会话已结束'): Promise<void> {
    const ticket = ++this.serial;
    this.busy = true; this.status = '正在结束'; this.emit();
    try {
      await this.cleanup();
      if (ticket === this.serial) {
        this.rows = []; this.target = ''; this.value = '暂无样本'; this.status = message;
      }
    } catch (error) {
      if (ticket === this.serial) this.status = `清理待重试:${(error as Error).message}`;
      throw error;
    } finally {
      if (ticket === this.serial) { this.busy = false; this.emit(); }
    }
  }

  async discover(context: common.UIAbilityContext): Promise<void> {
    if (!this.foreground || this.busy) return;
    const ticket = ++this.serial;
    this.busy = true; this.emit();
    try {
      await this.cleanup();
      if (!this.current(ticket)) return;
      const gate = await ensureBle(context);
      if (!this.current(ticket)) return;
      if (!gate.ready) throw new Error(`${gate.permission} · ${gate.radio}`);
      this.target = ''; this.value = '暂无样本';
      this.scan.start();
    } catch (error) {
      if (this.current(ticket)) this.status = (error as Error).message;
    } finally {
      if (this.current(ticket)) { this.busy = false; this.emit(); }
    }
  }

  async connect(context: common.UIAbilityContext, row: Nearby): Promise<void> {
    if (!this.foreground || this.busy || !row.connectable) return;
    const ticket = ++this.serial;
    this.busy = true; this.emit();
    try {
      await this.cleanup();
      if (!this.current(ticket)) return;
      const gate = await ensureBle(context);
      if (!this.current(ticket)) return;
      if (!gate.ready) throw new Error(`${gate.permission} · ${gate.radio}`);
      this.target = row.id; this.value = '暂无样本';
      this.link.connect(row.id);
    } catch (error) {
      if (this.current(ticket)) this.status = (error as Error).message;
    } finally {
      if (this.current(ticket)) { this.busy = false; this.emit(); }
    }
  }

  async subscribe(): Promise<void> {
    if (!this.foreground || this.busy || !this.connected) return;
    const ticket = ++this.serial;
    this.busy = true; this.emit();
    try {
      const client = this.link.device();
      const services = await client.getServices();
      if (!this.current(ticket) || client !== this.link.device()) return;
      await this.feed.start(client, selectSample(services), () => this.current(ticket));
    } catch (error) {
      if (this.current(ticket)) this.status = (error as Error).message;
    } finally {
      if (this.current(ticket)) { this.busy = false; this.emit(); }
    }
  }
}

export const bleSession: BleSession = new BleSession();

这里最重要的是清理与意图分别编号。公开 end 先递增 serial,令正在等待权限或服务发现的旧操作失效;内部 cleanup 只处理资源,不悄悄改变当前操作的身份。开始发现或连接时,可以先清理旧资源,再检查自己的意图是否仍然有效,避免“用户已经停止,但准备阶段结束后又自动开始”的问题。

异步调用前后的检查也不能省略。申请权限返回后检查一次,服务发现返回后再检查一次,通知拥有者内部等待旧订阅关闭之后还会调用 current 判断。用户可以在每个等待间隙退出,只有把检查放在真正跨越异步边界的位置,才不会依赖某种恰好很快的设备行为。

清理失败时保留错误状态,下一次结束请求可以再次尝试,不能直接把它写成正常结束。通知关闭失败后仍尝试关闭客户端;只有客户端 close 成功,才允许 SampleFeed 清除对已关闭客户端的残余引用。扫描停止失败的标记则留在扫描拥有者中,后续开始仍然会先处理旧扫描,不悄悄启动另一份。

DevEco Studio 中的统一结束入口与设备会话页

在代码评审中,可以沿着每个 await 划一道边界,问它返回时用户是否仍然在请求同一件事。这种检查不依赖某种设备速度:权限弹窗可能持续很久,服务发现可能很快完成,通知使能也可能在退出之后才返回。无论具体耗时多少,serial 与前台资格都应给出相同的行为决定,避免只在慢设备上才暴露错误。

并发清理合并后,还需要保留最后一个意图的展示权。假设用户先点击结束,随后 Ability 进入后台,二者可以等待同一次资源归还,但最后的后台意图应决定终态。公开 end 因此各自取得序号,而资源清理只执行一次。这个区别让重复调用保持幂等,同时又不丢失最新的使用范围。

Ability 管范围,页面管交互

把 EntryAbility 改成下面的实现。模板中的窗口加载仍然指向 pages/Index,前台回调只调用 resume,后台和销毁回调都调用 suspend。suspend 会立即撤销前台资格,再异步执行清理,因此返回前台之前没有新的有效扫描或连接意图。清理并不依靠页面是否正好收到某个路由事件。

// entryability/EntryAbility.ets
import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { bleSession } from '../common/BleSession';

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(stage: window.WindowStage): void {
    stage.loadContent('pages/Index').catch((error: Error) => console.error(error.message));
  }
  onForeground(): void { bleSession.resume(); }
  onBackground(): void { bleSession.suspend(); }
  onDestroy(): void { bleSession.suspend(); }
}

最终页面使用统一快照,候选设备来自第二篇的扫描模块,连接仍使用第三篇的 BleLink,订阅之前重新执行第四篇的服务契约,样本格式由第五篇的解析器负责。新页面没有复制这些实现,而是把它们接成用户可以操作的一条路径:发现、选择、连接确认、订阅、结束。

// pages/Index.ets
import { common } from '@kit.AbilityKit';
import { Nearby } from '../common/NearbyScan';
import { bleSession, SessionView } from '../common/BleSession';

@Entry
@Component
struct Index {
  @State view: SessionView = bleSession.snapshot();
  private alive: boolean = true;
  private changed = (view: SessionView): void => { if (this.alive) this.view = view; };
  aboutToAppear(): void { bleSession.observe(this.changed); }
  onPageShow(): void { bleSession.observe(this.changed); }
  onPageHide(): void {
    bleSession.unobserve(this.changed);
    bleSession.end().catch((error: Error) => console.error(error.message));
  }
  aboutToDisappear(): void {
    this.alive = false;
    bleSession.unobserve(this.changed);
    bleSession.end().catch((error: Error) => console.error(error.message));
  }
  private context(): common.UIAbilityContext {
    return this.getUIContext().getHostContext() as common.UIAbilityContext;
  }

  build() {
    Column({ space: 12 }) {
      Text('设备会话').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#173B3C')
      Text('前台操作,离开即收尾').fontSize(14).fontColor('#647B7B')
      Row({ space: 12 }) {
        Button('发现设备').backgroundColor('#166F75').enabled(!this.view.busy)
          .onClick(() => { bleSession.discover(this.context()); })
        Button('订阅样本').backgroundColor('#166F75').enabled(!this.view.busy && this.view.connected)
          .onClick(() => { bleSession.subscribe(); })
      }
      Text(`候选设备 ${this.view.rows.length} 台`).fontSize(17).fontColor('#166F75')
      List({ space: 8 }) {
        ForEach(this.view.rows, (row: Nearby) => {
          ListItem() {
            Button(`${row.name} · ${row.rssi} dBm`).width('100%')
              .enabled(!this.view.busy && row.connectable)
              .onClick(() => { bleSession.connect(this.context(), row); })
          }
        }, (row: Nearby) => row.id)
      }.width('100%').height(160)
      Column({ space: 10 }) {
        Text(this.view.target || '尚未选择设备').fontSize(14).fontColor('#647B7B')
        Text(this.view.value).fontSize(24).fontColor('#166F75')
      }.width('100%').padding(18).backgroundColor(Color.White).borderRadius(16)
      Text(this.view.status).fontSize(16).fontColor('#166F75')
      Button('结束本次会话').width('100%').height(44).backgroundColor('#DCECED').fontColor('#173B3C')
        .onClick(() => { bleSession.end().catch((error: Error) => console.error(error.message)); })
    }.width('100%').height('100%').padding({ top: 36, left: 24, right: 24 })
      .backgroundColor('#EFF7F7')
  }
}

前台设备会话页面的初始状态

观察者使用同一函数引用注册与解除,页面离开先解除观察,再请求结束;组件销毁先把 alive 设为假,后续清理不会回写已销毁组件。Ability 进入后台时,页面可能尚未销毁,快照仍可以记录结束过程,返回后观察者拿到的是会话当前状态,而不是旧页面缓存的已连接文字。

清理完成以前,busy 保持为真,发现和订阅入口都不能开始新工作。结束按钮仍然允许调用,因为它用于表达撤回意图,并由同一个清理 Promise 合并请求。重复结束只会让最后一份有效结果拥有展示权,不会因为按钮点了两次,就创建两条新的资源释放路径。

显式重连比自动猜测更可审查

本工程把重连定义为一次新的用户操作,不尝试把上一台设备地址永久保存后在后台循环连接。地址可能变化,外设服务可能修改,用户也可能已经把设备交给其他应用。重新发现、重新确认连接和重新发现服务,能够让新的会话重新取得当前事实,而不是依赖过去的成功记录。

如果产品确实需要自动恢复,应先补充恢复条件、次数限制、用户可见的停止入口以及后台运行依据,再把它作为新的策略接入会话。不能仅在断开事件中调用 connect,就称为完整重连机制。那样无法区分用户主动停止与意外断开,也无法说明权限撤销之后为什么还在尝试。

同样,资源拥有者的寿命不是越长越好。这个全局会话实例只适用于当前单 UIAbility、单前台操作场景,它通过 Ability 的前后台范围约束资源使用。多窗口、多 Ability 或多设备并行需要重新定义会话归属与扫描通道,不能把当前唯一观察者和唯一连接实例直接推广成全局多用户架构。

最后按退出位置逐项检查

第一组检查发生在准入阶段:点击发现后打开权限流程,随后退出页面或者让应用进入后台。权限结果回来时,不应重新启动扫描。第二组发生在发现阶段:扫描进行中点击结束,随后等待原本的发现期限,旧计时器不应再次影响状态,列表与目标在清理完成后归零。

第三组发生在连接与服务发现阶段:选择设备后立刻结束,或者等待服务数组时切到后台。旧回调与迟到数组都不能建立新的有效样本通道。第四组发生在通知使能阶段:请求还在进行时结束,必须等使能结果落定后继续关闭通知与客户端,不能仅清空页面数据就宣布资源已归还。

最后返回前台。页面应恢复可操作,但不会自动发现或连接;用户再次点击发现后,获得新的候选列表,选择目标后重新确认服务。这个用例同时检验地址寿命、特征值寿命和用户意图。如果界面一回来就展示旧样本,却没有任何来源与有效性说明,应该先检查是否错误保留了上一代数据。

用户、页面、Ability 到统一资源归还的关系

六篇连载最后形成的是一条有明确进入和退出条件的接入链:授权确认访问范围,广播提供候选目标,连接事件建立传输事实,服务发现确认操作契约,解析器解释样本,前台会话负责收尾。每层都可以给出自己的证据,也都能够拒绝不满足条件的下一步,这比在一个页面里堆积蓝牙 API 更便于维护。

检查完退出流程以后,再检查正常返回路径。用户主动结束后仍留在页面,应该能再次点击发现;页面路由返回时重新注册观察者,应该立即看到当前快照;应用从后台回到前台,应该恢复按钮却保持未连接。三个场景都不是“继续之前所有工作”,它们只是重新具备发起下一次明确操作的条件。

对于资源清理异常,当前工具保留重试入口和错误原因,适合工程联调。产品化时可以调整用户文案,但不能删掉内部失败状态再假装一切正常。尤其是权限被撤销、系统蓝牙关闭或底层服务重启等场景,应用应先停止依赖旧资源继续工作,随后让用户重新检查条件。可解释的停止,比没有边界的自动恢复更容易获得稳定行为。

参考资料

Logo

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

更多推荐