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

把责任放到能够看见全流程的位置
页面知道用户点了什么,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 清除对已关闭客户端的残余引用。扫描停止失败的标记则留在扫描拥有者中,后续开始仍然会先处理旧扫描,不悄悄启动另一份。

在代码评审中,可以沿着每个 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 或多设备并行需要重新定义会话归属与扫描通道,不能把当前唯一观察者和唯一连接实例直接推广成全局多用户架构。
最后按退出位置逐项检查
第一组检查发生在准入阶段:点击发现后打开权限流程,随后退出页面或者让应用进入后台。权限结果回来时,不应重新启动扫描。第二组发生在发现阶段:扫描进行中点击结束,随后等待原本的发现期限,旧计时器不应再次影响状态,列表与目标在清理完成后归零。
第三组发生在连接与服务发现阶段:选择设备后立刻结束,或者等待服务数组时切到后台。旧回调与迟到数组都不能建立新的有效样本通道。第四组发生在通知使能阶段:请求还在进行时结束,必须等使能结果落定后继续关闭通知与客户端,不能仅清空页面数据就宣布资源已归还。
最后返回前台。页面应恢复可操作,但不会自动发现或连接;用户再次点击发现后,获得新的候选列表,选择目标后重新确认服务。这个用例同时检验地址寿命、特征值寿命和用户意图。如果界面一回来就展示旧样本,却没有任何来源与有效性说明,应该先检查是否错误保留了上一代数据。

六篇连载最后形成的是一条有明确进入和退出条件的接入链:授权确认访问范围,广播提供候选目标,连接事件建立传输事实,服务发现确认操作契约,解析器解释样本,前台会话负责收尾。每层都可以给出自己的证据,也都能够拒绝不满足条件的下一步,这比在一个页面里堆积蓝牙 API 更便于维护。
检查完退出流程以后,再检查正常返回路径。用户主动结束后仍留在页面,应该能再次点击发现;页面路由返回时重新注册观察者,应该立即看到当前快照;应用从后台回到前台,应该恢复按钮却保持未连接。三个场景都不是“继续之前所有工作”,它们只是重新具备发起下一次明确操作的条件。
对于资源清理异常,当前工具保留重试入口和错误原因,适合工程联调。产品化时可以调整用户文案,但不能删掉内部失败状态再假装一切正常。尤其是权限被撤销、系统蓝牙关闭或底层服务重启等场景,应用应先停止依赖旧资源继续工作,随后让用户重新检查条件。可解释的停止,比没有边界的自动恢复更容易获得稳定行为。
参考资料
更多推荐

所有评论(0)