蓝牙接入(第 3 讲):HarmonyOS GATT 连接意图与事实状态【共创稿事节】
把 connect 调用后的一行状态更新改成“已连接”,可以让页面看起来反应很快,却把整个接入流程最重要的事实省略了:请求被接收与链路建立并不是同一件事。本篇给 BleWorkbench 增加 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 的第一步递增,让迟到事件立即失效;对象比较进一步表明这条事实属于当前被持有的实例。
等待期限是产品边界。本例允许一次连接意图等待十二秒,期限到达仍未确认连接就主动清理,并提示重新发现。它既不是蓝牙协议规定的建立时长,也不能用来推导设备性能。用户点击断开时立即结束等待,不继续把计时器留在后台,之后的连接必须从新的主动操作开始。

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

这里保留输入文本与连接事实的分工。输入框是一份待提交草稿,状态文字是一份已经确认的过程说明。目标提交之后,即使用户改动草稿,也不应把底层实例重新指向新文本。对于正式产品,可以在连接期间锁定输入框或者在界面上显示已提交目标,但不能只修改一段页面文字就假装已经切换设备。
页面的忙碌标志只覆盖权限检查与发起步骤,不负责表示链路是否已建立。连接中与已连接分别来自 BleLink 的回调,并参与按钮约束。二者分开后,授权弹窗结束不等于连接完成,连接断开也不意味着某个权限 Promise 重新开始。避免把这些过程塞进一个 loading,能够让错误处理更准确。
页面隐藏时主动释放客户端,销毁时首先撤销回写资格。入口页的普通 alive 和 visible 字段不会触发界面重绘,销毁回调也不再写状态变量。这里的 page hide 处理页面路由离开;应用进入后台的策略仍需要 Ability 生命周期配合,第六篇会把两者汇合到统一会话对象。
断开与释放是两件相关操作
disconnect 请求结束当前连接,close 归还客户端资源。代码会分别尝试,不能把调用 disconnect 当成所有资源已经回收。即使当前已经断开,客户端对象仍由 BleLink 持有,下一次操作前也必须完成 close。清理函数只有在 close 正常返回后才清空引用,因此清理失败时仍能由原拥有者重试。
事件监听采用创建时保存的同一函数引用解除,不使用无回调参数的 off 去影响其他订阅者。若解除监听失败,先记录失败并继续尝试断开和关闭;代次已经失效,迟到事件没有更新资格。关闭本身失败则向调用方抛出,使页面和上层清楚资源归还没有完成,而不是用正常状态覆盖这个事实。
这并不意味着任意错误都应该马上自动重连。权限撤销、蓝牙关闭、外设断电和目标暂时繁忙,恢复条件各不相同。反复调用连接既可能延长资源占用,也会让用户难以判断应用是否仍在操作他刚刚取消的设备。当前设计在失败后停住,由用户重新发现、核对目标并发出下一次连接意图。
按事件顺序验证这个最小闭环
先用第二篇得到可连接的当前地址,观察点击连接后状态先进入连接中,再由系统事件决定是否已连接。接着让外设停止广播或断电,确认无法仅凭 connect 没有抛错就进入服务操作。设备主动断开时,页面应显示已断开,device 方法拒绝交出可用于后续操作的客户端。
然后在连接等待期间点击断开,等待原来的期限过去,页面不应再次被旧计时器改写;迅速切换到另一个当前地址,也不应被旧客户端的断开事件覆盖。可以通过代码中代次与对象身份条件分析这些路径,并在目标设备上按相同步骤逐项核对。验证记录应注明操作顺序,不能只截一张“已连接”就宣称覆盖了竞态。
还应验证权限请求期间退出页面。授权结果可能迟到,但 serial 与可见性检查阻止它继续建立客户端;组件销毁之后也不会写入状态。这个用例特别适合发现“页面退出后蓝牙仍在连接”的问题,因为错误往往出在授权之后缺少一次归属检查,而不在 connect 方法本身。

本篇完成的是连接事实的建立与归还,不把连接成功解释成协议已经可用。外设可以接受链路,却缺少预期服务;下一篇会通过 getServices 找到真实返回的服务和特征值,再判断读取与通知能力。只有把这层证据补上,客户端才能知道随后传输的究竟是什么数据。
处理故障时,应把“用户取消”保留为一种明确意图。用户按下断开后,即使外设稍后又报告连接完成,旧代次也不再有资格把界面改回已连接。这里的优先级来自产品契约:用户已经撤回连接请求,任何晚到的成功都不能替他重新作出选择。这个原则比单纯检查某个字符串是否等于连接中更能覆盖异步边界。
更完整的设备应用可以把等待期限、断开原因和重试入口做成可观察状态,但当前教程没有凭空推导底层未提供的原因。如果回调只报告断开,就显示断开;想知道是否由于电量、距离或认证失败,需要额外协议或系统证据。用看似专业的具体解释替代未知信息,会把工程排查带向错误方向。
测试时还应让外设在连接后保持链路而不给出目标服务。页面可以正确显示已连接,第四篇却应拒绝进入数据操作。这不是前一篇判断错误,而是两个阶段分别完成了自己的验证。把这类看似矛盾的结果提前列入检查路径,可以防止后续开发为了追求一个统一绿色状态而跳过服务契约。
参考资料
更多推荐

所有评论(0)