蓝牙接入(第 4 讲):HarmonyOS 服务发现与特征值契约【共创稿事节】
先读一段筛选代码:找到服务,再找到特征值,最后检查它支持的操作。这里没有给“名称里带温度”的外设开后门,也没有从数组第一项猜测数据位置。GATT 连接只建立传输关系;要让 BleWorkbench 的后续样本解析具有意义,客户端还必须从外设返回的服务结构中找到双方真正约定的对象。

从返回结构里选出唯一对象
沿用第三篇的 BleLink、第一篇的 BleGate 和第二篇的 Protocol 常量。外设配置仍使用自定义主服务以及样本特征值,本篇约定样本特征值同时具有 read 和 notify 能力,并包含客户端特征配置描述符。这个约定是 BleWorkbench 的最小协议要求,不是所有 BLE 外设都必须满足的通用规范。
BLE 接口参考将服务、特征值与描述符分成不同结构。服务把相关功能组织在一起,特征值承载具体数据与访问能力,描述符补充特征值的行为信息。代码应按这些关系寻找目标,不能把扫描广播中的服务 UUID 当成已经拿到了可读取的数据通道。
下面的函数接收 getServices 返回的数组,返回找到的真实特征值对象。使用该对象而不是重新拼一个只有 UUID 的对象,是为了保留发现阶段返回的描述符及属性。不同设备的实例结构可能不同,客户端不应该用自己猜测的数据替换系统确认过的服务结构。
// common/ServiceContract.ets
import { ble } from '@kit.ConnectivityKit';
import { SERVICE_UUID, SAMPLE_UUID, CCC_UUID } from './Protocol';
export function selectSample(services: ble.GattService[]): ble.BLECharacteristic {
const targets = services.filter((s: ble.GattService) =>
s.serviceUuid.toLowerCase() === SERVICE_UUID && s.isPrimary);
if (targets.length !== 1) throw new Error('目标主服务缺失或不唯一');
const matches = targets[0].characteristics.filter((c: ble.BLECharacteristic) =>
c.characteristicUuid.toLowerCase() === SAMPLE_UUID);
if (matches.length !== 1) throw new Error('样本特征值缺失或不唯一');
const sample = matches[0];
if (sample.properties?.read !== true || sample.properties?.notify !== true) {
throw new Error('样本特征值需要同时支持 read 与 notify');
}
const hasCcc = sample.descriptors.some((d: ble.BLEDescriptor) =>
d.descriptorUuid.toLowerCase() === CCC_UUID);
if (!hasCcc) throw new Error('缺少通知配置描述符');
return sample;
}
把这个函数逐行拆开看,第一处约束是主服务与完整 UUID 同时匹配。UUID 在这里先统一大小写,避免同一标识因为字母大小写被误判;没有根据设备名称推导 UUID,也没有把部分字符串作为匹配条件。前缀相同不代表服务相同,随意缩短标识会让相邻的自定义服务互相冒充。
第二处约束是唯一性。没有匹配项当然不能继续,多于一个匹配项也不能靠取第一项解决。这个小型协议没有定义同 UUID 多实例的选择规则,因此把歧义显式拒绝,要求外设配置或者协议设计者作出决定。若未来确实支持多实例,应增加实例选择依据,而不是让数组顺序替业务决策。
第三处约束是操作能力。存在一个特征值不意味着所有操作都被允许,read、write、notify、indicate 各有含义。当前外设协议要求样本可读且可通知,所以检查 read 与 notify;本系列页面采用通知通道,read 是协议能力约束,没有把 indicate 当成等价替代,也不因为某个能力字段缺失就默认它为真。字段没有确认时,客户端缺少继续操作的依据。
检查函数返回特征值而不是布尔值,还有一个实际收益:通过检查的对象直接成为下一步操作输入。如果先返回 true,调用方再用同一 UUID 重新寻找对象,校验与使用之间就出现了第二份选择逻辑。未来维护者可能只更新其中一处,导致“检查了甲对象,实际用了乙对象”。让结果携带已验证对象,可以缩短这条容易分叉的路径。
在错误消息中保留不满足的条件,有助于外设与应用两侧协作。比如“需要 read 与 notify”对应特征值属性,“缺少通知配置描述符”对应服务定义中的附加结构。工程人员可以据此检查实际配置,不需要猜测是哪一层出了问题。正常页面可简化提示,联调记录仍应保留具体失败位置,但不必打印整份广播和所有周边设备地址。
发现是异步工作,结果也有归属
getServices 只有在 GATT 已连接后调用,因此服务入口先通过 BleLink.device 取得当前客户端。服务发现期间可能发生断开、离开页面或重新选择设备,返回的数组仍然属于开始时的那个客户端。页面在接收结果时同时检查操作序号、可见性与客户端身份,确保旧设备的服务结构不会被挂到新设备上。
服务结构是当前连接中的对象,不适合序列化后长期当作可调用句柄使用。重连时重新发现,重新确认契约,再保存当前特征值引用。手机或外设固件升级、服务配置变化,都可能改变实际结构;一份以前正确的发现结果不能自动证明现在仍然正确。
下面替换本篇入口页。连接成功后“发现服务”按钮才可用,契约卡片初始为未核对。检查开始先清除旧特征值引用,把状态切到正在核对;失败后保留明确原因,不让上一次成功时的对象继续作为本次有效通道。外设断开时也会清空它,连接状态与协议状态因此不会分裂。
// pages/Index.ets
import { ble } from '@kit.ConnectivityKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { ensureBle } from '../common/BleGate';
import { BleLink } from '../common/BleLink';
import { selectSample } from '../common/ServiceContract';
@Entry
@Component
struct Index {
@State address: string = '';
@State status: string = '未连接';
@State contract: string = '未核对';
@State connected: boolean = false;
@State busy: boolean = false;
private alive: boolean = true;
private visible: boolean = true;
private serial: number = 0;
private releaseError: string = '';
private sample: ble.BLECharacteristic | undefined = undefined;
private link: BleLink = new BleLink((status: string) => {
if (!this.alive || !this.visible) return;
this.status = status;
this.connected = status === '已连接';
if (!this.connected) { this.sample = undefined; this.contract = '未核对'; }
});
private stopWork(): boolean {
this.serial++;
this.sample = undefined;
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.connected = false;
this.status = this.releaseError ? `清理待重试:${this.releaseError}` : '未连接';
this.contract = '未核对';
}
aboutToDisappear(): void { this.alive = false; this.stopWork(); }
private async connect(): Promise<void> {
if (this.busy) return;
const ticket = ++this.serial;
const address = 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(address);
} 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;
}
}
private async inspect(): Promise<void> {
if (this.busy || !this.connected) return;
const ticket = ++this.serial;
this.busy = true;
this.sample = undefined;
this.contract = '正在核对';
try {
const client = this.link.device();
const services = await client.getServices();
if (!this.alive || !this.visible || ticket !== this.serial) return;
if (client !== this.link.device()) return;
this.sample = selectSample(services);
this.contract = '主服务、read、notify 与 CCC 均匹配';
} catch (error) {
if (this.alive && this.visible && ticket === this.serial) {
this.contract = `契约不满足:${(error as Error).message}`;
}
} finally {
if (this.alive && this.visible && ticket === this.serial) this.busy = false;
}
}
build() {
Column({ space: 16 }) {
Text('核对服务契约').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#49321F')
Text('连接完成后再读取').fontSize(14).fontColor('#7B6D60')
TextInput({ text: this.address, placeholder: '当前设备地址' }).height(48)
.onChange((value: string) => { this.address = value; })
Text(this.status).fontSize(20).fontColor('#A55B23')
Row({ space: 12 }) {
Button('连接').backgroundColor('#A55B23')
.enabled(!this.busy && !this.connected && this.status !== '连接中' && this.address.trim().length > 0)
.onClick(() => { this.connect(); })
Button('发现服务').backgroundColor('#A55B23').enabled(this.connected && !this.busy)
.onClick(() => { this.inspect(); })
}
Column({ space: 14 }) {
Text('协议检查').fontSize(18).fontWeight(FontWeight.Bold)
Text(this.contract).fontSize(17).fontColor('#A55B23')
}.padding(24).width('100%').backgroundColor(Color.White).borderRadius(18)
Button('结束连接').width('100%').height(44).backgroundColor('#EDE3D8').fontColor('#49321F')
.onClick(() => {
const stopped = this.stopWork(); this.busy = false; this.connected = false;
this.status = stopped ? '未连接' : `清理待重试:${this.releaseError}`;
this.contract = '未核对';
})
Text('服务存在,还要核对操作能力').fontSize(13).fontColor('#7B6D60')
}.width('100%').height('100%').padding({ top: 48, left: 24, right: 24 })
.backgroundColor('#FAF5EE')
}
}

页面把服务核对结果作为一张独立卡片,是为了让读者看见两个阶段:上方可以已经连接,下方仍然未核对或不满足契约。这种界面比一个总的成功勾选更接近实际过程。用户需要知道应用正在等连接事件,还是已经连上但设备协议不符合预期,才能选择正确的下一步。
请求期间重复点击由 busy 限制,连接回调则独立维护 connected。页面返回时清除之前的协议结果,因为隐藏时已经结束连接。后续若产品决定在页面切换期间保留连接,就必须把拥有者提升到更长的生命周期,并在返回页面时重新取得有效快照;不能只删掉清理调用,留下过期的界面数据。
这里没有遍历 includeServices 再把所有内容重复加入候选数组。当前协议要求目标是返回服务列表中的唯一主服务,发现入口据此选择。如果未来设备通过包含服务组织复杂协议,必须先定义目标究竟应从哪一层解析,以及同一服务重复出现时的去重方式。扩大遍历范围并不天然提高兼容性,它也可能把原本明确的身份变得含糊。
相反,缺少自定义服务的设备仍然可以在发现列表和连接页面中正常出现。应用不应该为了美化结果,把它从历史记录中无声删除。连接是传输层事实,契约是业务层约定,页面让两项结果并列,能够保留完整的排查线索。只有当产品决定不支持这类目标时,才在选择界面解释限制,而不是倒推扫描和连接从未成功。
描述符说明了通知怎样建立
客户端特征配置描述符使用完整的标准 UUID。函数检查它是否存在,是为了提前识别外设服务配置缺失;真正使能通知将在下一篇调用 setCharacteristicChangeNotification。此处不手工写入描述符,也不把描述符当前值直接解释成应用已经订阅成功。服务发现回答“能否尝试”,订阅接口完成后才回答“本次是否建立”。
外设如果只有 read 能力,仍然可以成为另一个只读产品的合法服务,但不满足当前项目的通知契约。错误信息应该明确是项目要求未满足,而不是给设备贴上不合格标签。同样,如果设备只支持 indicate,应重新设计当前协议与 API 调用,不在检查函数里把 notify 的要求偷偷放宽。
特征值属性只描述支持的操作,还可能受到访问权限、配对或认证条件影响。即使检查通过,后面的读取与订阅仍必须处理异常,不能把属性校验理解成网络请求一定成功。把能力检查与实际操作分开,既能提前拒绝明显不匹配的设备,又保留真实访问时的错误信息。
对于没有匹配项的结果,排查应从外设服务数据库开始,而不是不停修改页面类型断言。检查完整 UUID 是否一致、主服务标记是否正确、特征值是否挂在目标服务下面、通知描述符是否存在。类型断言只能改变编译器如何看待对象,不能给远端设备增加缺失的功能。
用配置差异验证筛选规则
先使用完整协议的外设,确认检查卡片能够走到匹配状态。然后逐项改变外设配置:删除目标服务、替换特征值 UUID、关闭 notify、移除通知配置描述符。每一项都应由相应条件拒绝,不能全部落到模糊的连接失败。修改外设配置之后重新建立连接并发现服务,避免拿上一次连接的缓存结构验证新配置。
再检查一个对照情况:外设存在多个无关服务,而目标服务处于数组中间。代码仍应找到目标,不受返回顺序影响。对于大小写差异,UUID 统一小写后应保持匹配;对于只共享前缀的另一个 UUID,则必须拒绝。这样可以确认筛选规则依据的是协议身份,而不是偶然的数组位置。
发现请求尚未返回时结束连接,页面不应把迟到结果写成匹配状态;换一个设备重新连接,旧特征值引用也不应参与下一次读取。测试这些边界时,应记录连接代次和触发顺序,帮助区分远端协议不匹配与本地结果归属错误。二者最终都可能让卡片没有成功,但修复位置不同。

完成这一层之后,应用手里拥有的不是一个自信的字符串,而是一份来自当前连接、符合当前协议的特征值对象。下一篇将用它订阅样本值,再把二进制数据交给独立解析器。发现层不处理温湿度,解析层不猜测设备地址,两边用明确的对象与数据边界交接。
服务检查也有清晰的验证范围:它证明远端公布了当前协议需要的结构,尚未证明样本内容符合格式。一个具备读取和通知能力的特征值,仍然可能返回空数据、错误版本或不合法的数值。因此第五篇会继续做长度、标记和校验检查,不能因为服务层全部匹配就省略字节层验证。分层越清楚,错误越容易落到具体模块。
这也说明为什么本篇没有自动降级到“找到任意可读特征值就继续”。可读只是访问能力,无法说明数据含义。把另一项电量、设备名称或厂商信息当成温湿度短帧读取,可能得到一组看似合理的数字,却没有任何协议依据。严格匹配明确的服务与特征值,是防止这种静默误读的第一道条件。
参考资料
更多推荐

所有评论(0)