HarmonyOS技术精讲-蓝牙:BLE进阶:广播、多连接与功耗优化
一个常见的开发误区
HarmonyOS 的 BLE 开发,很多人的第一印象是“官方示例能跑”。但实际把广播、多连接、功耗优化这些能力组合在一起时,就会发现 API 组合里的坑比想象的多。
官方文档把基础流程讲得很清楚:开启蓝牙、扫描、连接、服务发现、读写。但当你需要同时连接两个体温计、一个心率带,还要让 App 在后台保持低功耗扫描时,文档里没有直接告诉你如何管理这些并发状态。
这篇文章会从三个实际使用场景出发:自定义广播数据包、多设备并发连接管理、扫描窗口与功耗平衡。每个场景都会给完整代码,并且标记出那些容易被忽略的边界情况。
本文解决的核心问题
| 问题 | 官方文档覆盖程度 | 实际开发难点 |
|---|---|---|
| 广播自定义UUID与厂商数据 | 有基础示例 | 广播包长度限制、周期广播时序 |
| 同时连接多个设备 | 有基本API | 连接池管理、回调分发、断连重连 |
| 扫描功耗优化 | 有参数说明 | 不同业务场景的参数组合策略 |
| 安全配对与绑定 | 有回调说明 | 绑定状态持久化、设备删除 |
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上 HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上 目标设备:手机+手表+蓝牙外设(支持GATT的BLE设备)核心实现:分步骤构建BLE多设备管理框架
第一步:广播配置与自定义数据
广播不仅仅是让设备可见。在实际项目中,广播数据里通常需要携带厂商身份标识、服务UUID、设备名称摘要等。HarmonyOS 的BleAdvertiserSettings提供了对广播参数的细粒度控制。
先看广播端的完整实现:
// BleAdvertiserService.etsimport{ble,conform}from'@kit.ConnectivityKit';classBleAdvertiserManager{privateadvertiser:ble.BLEAdvertiser|null=null;privateadvertiserId:number=-1;publicstartAdvertising(customData:Uint8Array):void{// 获取BLE广播器实例if(!this.advertiser){this.advertiser=ble.getBleAdvertiser();}// 配置广播参数constsettings:ble.BleAdvertiserSettings={// 广播间隔,单位0.625ms,这里设置100msinterval:160,// 发射功率:中等txPower:ble.GattTxpwer.TX_POWER_MEDIUM,// 可连接广播connectable:true,// 是否包含设备名称includeDeviceName:false,// 是否包含发射功率(建议包含,方便外设侧做信号滤波)includeTxPowerLevel:true,};// 构造广播数据// Manufacturer Specific Data的格式:Company ID (2字节) + 自定义数据constcompanyId:Uint8Array=newUint8Array([0x00,0x01]);constcombinedData=newUint8Array(companyId.length+customData.length);combinedData.set(companyId,0);combinedData.set(customData,companyId.length);constadvertiseData:ble.AdvertiseData={// 完整的128位服务UUIDserviceUuids:['0000180F-0000-1000-8000-00805F9B34FB'],// 制造商自定义数据manufacturerData:[{manufacturerId:0x0001,manufacturerSpecificData:combinedData,}],};// 启动广播this.advertiser.startAdvertising(this.advertiserId,settings,advertiseData,({data,rssi})=>{console.info(`广播回调监听,当前RSSI:${rssi}`);});}publicstopAdvertising():void{if(this.advertiser&&this.advertiserId>=0){this.advertiser.stopAdvertising(this.advertiserId);this.advertiserId=-1;}}}这个实现有几个地方值得注意:
includeDeviceName我设置了false。如果设备名称较长(超过8字节),加上serviceUuids和manufacturerData,广播包很容易超过31字节的上限。实际项目里建议把名称放入扫描响应包(scan response)里。interval160对应100ms(160×0.625ms)。更短的间隔(比如20ms)会让设备更容易被发现,但功耗会明显上升。startAdvertising的回调用于接收广播状态的反馈,可以用来检测广播是否异常中断。
第二步:构建多设备连接管理器
多连接管理的核心问题不是API调用,而是连接状态的跟踪和回调分发。如果让每个页面的组件独立管理连接,页面返回后连接状态会丢失,而且回调会触发一个已销毁的组件。
推荐的做法是用一个单例管理所有GATT连接:
// GattConnectionManager.etsimport{ble}from'@kit.ConnectivityKit';interfaceDeviceConnectionInfo{deviceId:string;deviceName:string;connection:ble.GattConnection|null;state:'disconnected'|'connecting'|'connected'|'disconnecting';services:ble.GattService[];}classGattConnectionManager{privatestaticinstance:GattConnectionManager;privateconnections:Map<string,DeviceConnectionInfo>=newMap();// 最大同时连接数,根据业务调整privatemaxConnections:number=3;publicstaticgetInstance():GattConnectionManager{if(!GattConnectionManager.instance){GattConnectionManager.instance=newGattConnectionManager();}returnGattConnectionManager.instance;}publicconnect(device:ble.BLEDevice):void{// 检查连接数上限if(this.getConnectedCount()>=this.maxConnections){console.error(`已达最大连接数(${this.maxConnections})`);return;}constinfo:DeviceConnectionInfo={deviceId:device.deviceId,deviceName:device.deviceName||'',connection:null,state:'connecting',services:[]};this.connections.set(device.deviceId,info);// 建立GATT连接constconn=ble.gatt.createGattConnection(device.deviceId);conn.on('connect',(deviceId:string)=>{console.info(`设备连接成功:${deviceId}`);constconnInfo=this.connections.get(deviceId);if(connInfo){connInfo.state='connected';connInfo.connection=conn;// 发现服务this.discoverServices(deviceId);}});conn.on('disconnect',(deviceId:string)=>{console.info(`设备断开连接:${deviceId}`);constconnInfo=this.connections.get(deviceId);if(connInfo){connInfo.state='disconnected';connInfo.connection=null;}});conn.connect();}// 分别对每个设备执行读写操作publicasyncreadCharacteristic(deviceId:string,charUUID:string):Promise<Uint8Array|null>{constconnInfo=this.connections.get(deviceId);if(!connInfo||!connInfo.connection){console.error(`设备未连接:${deviceId}`);returnnull;}constconn=connInfo.connection;// 遍历服务查找指定特征for(constserviceofconnInfo.services){constcharacteristic=service.serviceUuid==='0000180F-0000-1000-8000-00805F9B34FB'?service.characteristics.find(c=>c.characteristicUuid===charUUID):null;if(characteristic){try{constvalue=awaitconn.readCharacteristicValue(service,characteristic);console.info(`读取成功:${deviceId}, value:${value}`);returnvalue;}catch(err){console.error(`读取失败:${deviceId}, error:${err.message}`);returnnull;}}}console.warn(`未找到特征:${charUUID}`);returnnull;}privategetConnectedCount():number{letcount=0;this.connections.forEach((info)=>{if(info.state==='connected')count++;});returncount;}privatediscoverServices(deviceId:string):void{constconnInfo=this.connections.get(deviceId);if(!connInfo||!connInfo.connection)return;constconn=connInfo.connection;conn.discoverServices().then((services:ble.GattService[])=>{connInfo.services=services;console.info(`设备${deviceId}服务发现完成,服务数量:${services.length}`);});}}exportdefaultGattConnectionManager;这个管理器的设计要点:
- 连接池控制:
maxConnections上限,防止同时打开过多连接导致系统资源耗尽。 - 状态机:每个设备有独立的
state,断开、重建、重试的逻辑都有判断依据。 - 回调隔离:每个 GATT 连接实例的回调只处理对应 deviceId 的事件,不会出现 A 设备的数据跑到 B 设备的回调里。
第三步:扫描参数调优与功耗平衡
扫描是功耗大户。HarmonyOS 的setScanFilter可以在频率层做过滤,避免所有广播包都被唤醒 App。但 filter 设得太严格又会漏掉目标设备。
下面是一个兼顾功耗和发现成功率的扫描策略:
// BleScanner.etsimport{ble}from'@kit.ConnectivityKit';classBleScanner{privatescanResultCb:ble.BleScanCallback|null=null;publicstartLowPowerScan(targetServiceUuids:string[]):void{// 扫描过滤器:只监听包含指定Service UUID的设备constfilters=targetServiceUuids.map(uuid=>({serviceUUID:uuid,type:0// 精确匹配}));constsettings:ble.BleScanSettings={// 扫描窗口:30ms,扫描间隔:200ms// 这意味着每200ms中只扫描30ms,其余时间休眠scanInterval:400,// 单位0.625ms,实际200msscanWindow:60,// 单位0.625ms,实际37.5msscanMode:ble.BleScanMode.SCAN_MODE_LOW_POWER,};// 设置过滤器ble.setScanFilter(filters);// 注册扫描结果回调this.scanResultCb={onBleScanResult:(result:ble.BleScanResult)=>{console.info(`发现设备:${result.deviceId}, RSSI:${result.rssi}`);// 只在发现目标服务UUID时才处理if(result.serviceUuids.some(uuid=>targetServiceUuids.includes(uuid))){this.handleTargetDevice(result);}}};ble.startBleScan(filters,settings,this.scanResultCb);console.info(`低功耗扫描启动,scanWindow: 37.5ms,scanInterval: 200ms`);}publicstopScan():void{if(this.scanResultCb){ble.stopBleScan(this.scanResultCb);this.scanResultCb=null;}}privatehandleTargetDevice(result:ble.BleScanResult):void{// 将设备信息传递到UI层或连接管理器console.info(`目标设备到达,准备连接:${result.deviceName}`);}}参数选择逻辑:
| 业务场景 | scanInterval(ms) | scanWindow(ms) | 功耗等级 | 发现延迟 |
|---|---|---|---|---|
| 持续监控(如找车) | 100 | 50 | 中 | <1s |
| 后台发现(如步数同步) | 2000 | 50 | 低 | 2-5s |
| 快速配对(如点击搜索) | 50 | 30 | 高 | <0.5s |
scanWindow占scanInterval的比例建议控制在10%-20%。比例过高,功耗和扫描差异不大;比例过低,发现延迟会很不可控。
常见问题与深层原因分析
问题1:绑定状态回调bleBondStateChange触发后,设备重启需要重新配对
现象:首次配对成功,App退出再打开,或设备断电重启后,再次连接会要求重新输入配对码。
原因:绑定信息存储在蓝牙协议栈中,但App没有主动保存bondState。HarmonyOS 的bleBondStateChange回调仅在绑定状态发生变化时触发,不会在App启动时主动同步已经绑定的设备列表。
解法:收到BONDED状态时,将设备地址和名称写入本地Preferences或数据库。App启动时主动调用ble.getBondedDevices()获取已绑定设备列表,然后尝试恢复连接。
// 绑定状态回调处理ble.on('bleBondStateChange',(data:{deviceId:string,state:BondState})=>{if(data.state===BondState.BONDED){// 持久化绑定信息this.saveBondedDevice(data.deviceId);}elseif(data.state===BondState.BOND_NONE){// 绑定解除,清理本地记录this.removeBondedDevice(data.deviceId);}});// App启动时检查constbondedDevices=ble.getBondedDevices();bondedDevices.forEach(device=>{// 检查本地是否已连接,未连接则自动发起连接if(!this.connectionManager.isConnected(device.deviceId)){this.connectionManager.connect(device);}});问题2:广播数据长度超过31字节导致广播启动失败
现象:startAdvertising调用成功,但其他设备扫描不到广播包。或者广播能启动但持续几秒后自动停止。
原因:BLE广播包(ADV_IND)的有效载荷最大31字节。如果 serviceUuids(16字节/个)、deviceName(最多8字节,超过会截断)、manufacturerData 加起来超过31字节,系统会静默放弃广播或只发送部分内容。
解法:使用扫描响应包(Scan Response)分担数据。广播包放 serviceUUID 和必要标识,scan response 放 deviceName 和额外的厂商数据。
// 广播数据可以拆分constadvertiseData:ble.AdvertiseData={// 广播包只放一个UUID和制造商IDserviceUuids:['0000180F-0000-1000-8000-00805F9B34FB'],manufacturerData:[{manufacturerId:0x0001,manufacturerSpecificData:newUint8Array([0x00,0x01,0x02]),// 3字节标识}],};// Scan Response 放设备名称和扩展信息constscanResponseData:ble.ScanResponseData={deviceName:'MyBLEDevice_X001',// 完整名称放这里manufacturerData:[{manufacturerId:0x0001,manufacturerSpecificData:newUint8Array([0x10,0x20,0x30,0x40,0x50]),// 额外数据}],};this.advertiser.startAdvertising(this.advertiserId,settings,advertiseData,scanResponseData,callback);最佳实践总结
不要在主线程中处理蓝牙数据。无论是
onBleScanResult回调还是readCharacteristicValue返回,数据长度可能从几字节到几百字节不等。如果直接用于 UI 更新,ArkUI 重新渲染时会造成明显的卡顿。推荐先拷贝数据到共享变量,再通过@State触发 UI 更新。在页面
onPageHide或aboutToDisappear中主动断开未使用的连接。用户离开连接页面后,GATT 连接仍然维持。长时间不用的连接不仅耗电,还会占用蓝牙协议栈的连接槽位,导致新设备无法连接。扫描参数要根据当前页面状态动态调整。用户主动点击“搜索”时用高频扫描(window=50ms, interval=100ms),App 后台保活时用低频扫描(window=20ms, interval=1000ms)。用一个全局状态变量控制扫描参数的生命周期,而不是每次搜索都调用
startBleScan。
Demo 入口文件
// Index.ets,完整的页面入口importGattConnectionManagerfrom'./GattConnectionManager';import{BleScanner}from'./BleScanner';import{BleAdvertiserManager}from'./BleAdvertiserService';@Entry@Componentstruct Index{@StatedeviceList:string[]=[];privatescanner:BleScanner=newBleScanner();privateconnectionManager:GattConnectionManager=GattConnectionManager.getInstance();build(){Column(){Button('开始扫描').onClick(()=>{this.scanner.startLowPowerScan(['0000180F-0000-1000-8000-00805F9B34FB']);})Button('连接设备').onClick(()=>{// 模拟连接第一个发现的设备if(this.deviceList.length>0){constdeviceId=this.deviceList[0];// 假设已从扫描结果中构造了BLEDevice// this.connectionManager.connect(bleDevice);}})List(){ForEach(this.deviceList,(device:string)=>{ListItem(){Text(device).fontSize(16);}})}}.width('100%').height('100%').onPageHide(()=>{this.scanner.stopScan();})}}FAQ
Q:为什么我的设备扫描到了,但连接总是失败?
A:最常见的原因是设备端没有处于可连接广播状态。检查广播参数里connectable是否为 true,以及广播间隔是否太短导致设备端来不及处理连接请求。另外,部分设备的 GAP 层限制只能接受已配对的设备连接,需要先调用ble.pairDevice。
Q:同时连接3个设备后,新设备扫描不到了?
A:蓝牙协议栈有连接数上限,不同设备厂商的配置不同。HarmonyOS 基于高通或海思芯片的方案通常支持 7-8 个 BLE 连接。如果扫描不到新设备,先检查当前连接数是否已接近上限,然后主动断开不活跃的连接。
Q:为什么 setScanFilter 设置了但还有大量垃圾数据进来?
A:setScanFilter在部分芯片上有实现差异。有些情况下,filter 只作用在应用层,物理层仍然会接收所有广播包,CPU 仍然会被唤醒。可以考虑在回调里再做一次业务层过滤,只处理 serviceUuids 匹配的设备。目前还没有 API 能完全关闭未匹配设备的底层中断。