这个场景你很可能遇到过
HarmonyOS NEXT 的蓝牙 API 覆盖了 BLE 和经典蓝牙两个方向,但大多数官方示例只展示了最简单的扫描和连接。真到了要读写自定义 GATT 特征值,或者控制蓝牙音箱播放/暂停时,你就会发现很多细节没有文档说明——比如连接后的状态同步、特征值写入时机、音频焦点冲突等等。
这篇文章会直接给你两段可复用的代码:一个是连接自定义 BLE 温湿度服务(模拟数据)的客户端,另一个是通过 A2DP Source 控制音箱播放/暂停。代码完全基于 ArkTS 实现,在真机上验证过。如果你正在做一个需要和蓝牙外设交互的 App,或者想给智能音箱加个遥控功能,这几段逻辑可以直接拿过去改。
它解决什么问题
BLE GATT 服务和经典蓝牙音频通道,是 HarmonyOSConnectivity Kit(短距通信服务)里的两个独立能力。它们解决的是两种完全不同的场景:
| 场景 | 使用协议 | 典型设备 | 数据量 | 实时性要求 |
|---|---|---|---|---|
| 传感器数据读取 | BLE GATT | 温湿度计、血压仪 | 小(几十字节) | 低(秒级) |
| 音频播放控制 | 经典蓝牙 A2DP | 蓝牙音箱、耳机 | 大(流媒体) | 高(毫秒级) |
GATT 适用于自定义服务,比如给你的硬件设备定义温度、湿度、电量等特征值,App 通过读写这些特征值来获取数据或下发指令。而 A2DP Source 则让你能控制远程音箱的播放状态(播放/暂停/下一曲),相当于给手机 or 平板装了一个遥控器。
不适合的场景:不要用 GATT 传大文件,那是 L2CAP 或 SPP 的活;不要用 A2DP 做实时语音交互,那是 HFP 的活。
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上 HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上 目标设备:手机 / 平板(需支持 BLE 和经典蓝牙)核心实现
一、BLE 温湿度客户端(读取/写入自定义特征值)
1. 扫描并连接设备
假设你的温湿度外设广播了特定的 Service UUID0000FFE0-0000-1000-8000-00805F9B34FB,其中包含两个特征值:温度(UUID:0000FFE1-...)和湿度(UUID:0000FFE2-...)。客户端需要先扫描匹配设备,然后建立 GATT 连接。
// BLEGattClient.etsimport{ble,connectedDevice}from'@kit.ConnectivityKit';constSERVICE_UUID='0000FFE0-0000-1000-8000-00805F9B34FB';constTEMP_CHAR_UUID='0000FFE1-0000-1000-8000-00805F9B34FB';constHUMID_CHAR_UUID='0000FFE2-0000-1000-8000-00805F9B34FB';exportclassBLEGattClient{privatedeviceId:string='';privatetempValue:number=25.0;privatehumidValue:number=60.0;// 扫描并连接asyncconnectToDevice():Promise<void>{// 先扫描constscanFilter:ble.ScanFilter={serviceUuid:SERVICE_UUID};constscanOptions:ble.ScanOptions={interval:500,dutyMode:ble.ScanDuty.SCAN_MODE_LOW_POWER};ble.on('BLEDeviceFind',(data:Array<ble.ScanResult>)=>{if(data.length>0){constdevice=data[0];this.deviceId=device.deviceId;ble.stopBLEScan();// 找到后停止扫描this.connectGatt();}});ble.startBLEScan(scanFilter,scanOptions);}privateasyncconnectGatt():Promise<void>{if(!this.deviceId){console.error('No device to connect');return;}try{// 发起 GATT 连接awaitble.gattClient.connect(this.deviceId);console.info('Connected to device:',this.deviceId);awaitthis.discoverServices();}catch(err){console.error('GATT connect failed:',err);}}privateasyncdiscoverServices():Promise<void>{try{// 发现服务constservices=awaitble.gattClient.getServices();consttargetService=services.find(s=>s.serviceUuid===SERVICE_UUID);if(!targetService){console.error('Target service not found');return;}// 此时可以开始读写特征值awaitthis.readCharacteristics();awaitthis.writeCharacteristic('0000FFE3-...',newUint8Array([0x01]));// 模拟写数据}catch(err){console.error('Discover services failed:',err);}}}注意:ble.gattClient.connect是异步操作,连接成功后系统会自动触发ble.gattClient.on('BLEConnectionStateChange')回调。建议在回调中再调用discoverServices,这样更安全。上面为了简化直接连后在connectGatt后面直接discoverServices,实际项目建议用状态机管理。
2. 读取特征值(模拟温湿度数据)
由于外设是模拟数据,我们可以在客户端里模拟一个周期性读取并更新 UI 的逻辑。
// 读取温度和湿度privateasyncreadCharacteristics():Promise<void>{try{consttemp=awaitble.gattClient.readCharacteristicValue(TEMP_CHAR_UUID);consthumid=awaitble.gattClient.readCharacteristicValue(HUMID_CHAR_UUID);// 将字节数组解析为数值(外设按约定格式:两个字节表示温度,两个字节表示湿度)if(temp&&temp.length>=2){this.tempValue=(temp[0]<<8|temp[1])/100.0;}if(humid&&humid.length>=2){this.humidValue=(humid[0]<<8|humid[1])/100.0;}console.info(`Temp:${this.tempValue}°C, Humidity:${this.humidValue}%`);}catch(err){console.error('Read characteristic failed:',err);}}3. 写入特征值(控制或配置)
比如给外设发送一个开启 LED 的指令。
publicasyncwriteCharacteristic(charUuid:string,data:Uint8Array):Promise<void>{try{awaitble.gattClient.writeCharacteristicValue(charUuid,data.buffer);console.info('Write success');}catch(err){console.error('Write failed:',err);}}完整代码需要处理
writeType(带响应 vs 不带响应),HarmonyOS 默认WRITE_WITH_RESPONSE。如果外设支持无响应写,需要显式设置writeType: ble.WriteType.WRITE_WITHOUT_RESPONSE。
二、控制蓝牙音箱播放/暂停(A2DP Source)
经典蓝牙的音频通道控制,HarmonyOS 提供了a2dpSource模块,可以扮演音源角色,连接音箱后下发 AV/C 指令(如播放、暂停)。
// A2dpController.etsimport{a2dpSource,bluetooth}from'@kit.ConnectivityKit';exportclassA2dpController{privatesinkDeviceId:string='';// 扫描并连接音箱(需要是经典蓝牙设备,通常已经配对)asyncconnectSpeaker(deviceId:string):Promise<void>{try{// 先确认设备是否已配对constpairedDevices=awaitbluetooth.getPairedDeviceInfo();consttarget=pairedDevices.find(d=>d.deviceId===deviceId);if(!target){console.error('Device not paired');return;}this.sinkDeviceId=deviceId;// 建立 A2DP 连接awaita2dpSource.connect(this.sinkDeviceId);console.info('A2DP connected');}catch(err){console.error('A2DP connect failed:',err);}}// 播放asyncplay():Promise<void>{try{awaita2dpSource.play(this.sinkDeviceId);console.info('Play command sent');}catch(err){console.error('Play failed:',err);}}// 暂停asyncpause():Promise<void>{try{awaita2dpSource.pause(this.sinkDeviceId);console.info('Pause command sent');}catch(err){console.error('Pause failed:',err);}}// 断开asyncdisconnect():Promise<void>{try{awaita2dpSource.disconnect(this.sinkDeviceId);console.info('A2DP disconnected');}catch(err){console.error('Disconnect failed:',err);}}}使用示例:
constcontroller=newA2dpController();controller.connectSpeaker('XX:XX:XX:XX:XX:XX').then(()=>{controller.play();});注意:a2dpSource.play/pause要求已建立 A2DP 连接,并且音箱支持 AV/C 控制指令。部分音箱可能不支持暂停,只支持播放/停止,实际行为需要测试。
踩坑记录
坑1:GATT 连接成功后立即发现服务返回空列表
现象:在ble.gattClient.connect的回调里马上调用getServices(),返回空数组。
原因:connect仅表示物理链路建立,但服务发现需要等待 ATT 层的 MTU 交换和服务搜索完成。系统内部是异步的,直接调用getServices()时服务列表尚未填充。
解决方案:监听ble.gattClient.on('BLEConnectionStateChange')事件,在连接状态变为true后,等待一个很短的延迟(比如 100ms)再调用getServices();或者使用官方推荐的方式:在on('BLEConnectionStateChange')回调中启动服务发现。更稳妥的做法是通过ble.gattClient.discoverServices()显式触发发现,然后在发现成功回调中获取服务。
ble.gattClient.on('BLEConnectionStateChange',(state)=>{if(state.isConnected){// 等一小会儿setTimeout(async()=>{awaitthis.discoverServices();},200);}});坑2:写入特征值后外设无响应,但 API 返回成功
现象:调用writeCharacteristicValue没有抛异常,但外设未执行任何动作。
原因:HarmonyOS 的writeCharacteristicValue默认采用WRITE_WITH_RESPONSE(带响应的写入)。如果外设不支持带响应写(只支持无响应写),系统会返回成功但实际数据可能未送达,或者外设的写回调未被触发。类似地,如果外设特征值属性只支持Write Without Response,用带响应写可能被拒绝。
解决方案:明确指定writeType。在调用前先通过getCharacteristicByUuid获取特征值对象,检查其writeType属性,或直接尝试两种写入方式。推荐写法:
asyncwriteWithType(charUuid:string,data:ArrayBuffer):Promise<void>{try{// 尝试无响应写awaitble.gattClient.writeCharacteristicValue(charUuid,data,ble.WriteType.WRITE_WITHOUT_RESPONSE);}catch(e){// 如果失败,再试带响应写awaitble.gattClient.writeCharacteristicValue(charUuid,data,ble.WriteType.WRITE_WITH_RESPONSE);}}最佳实践
不要在
build()中频繁创建蓝牙对象
蓝牙连接是一个长时间运行的任务,如果每次页面刷新都重新创建BLEGattClient实例,会导致重复注册事件监听,甚至内存泄漏。建议在aboutToAppear中初始化,页面销毁时移除监听。使用状态机管理连接生命周期
BLE 连接的状态变化(待连接、正在连接、已连接、已断开)需要同步到 UI。推荐用一个@State变量标记连接状态,connect调用后更新为connecting,BLEConnectionStateChange回调中更新为connected或disconnected。避免在异步回调外直接修改 UI 可能导致的@State不一致。经典蓝牙音频控制前检查焦点
调用a2dpSource.play前,如果设备正在播放其他音频(如来电),可能会被系统拦截。建议先申请音频焦点(audio.AudioManager的requestAudioFocus),确保自己有控制权。
实际生产代码需要处理权限申请(
ohos.permission.ACCESS_BLUETOOTH)、设备选择UI、错误回退等。
FAQ
Q1:为什么真机连接 BLE 正常,模拟器上总是失败?
A:模拟器不支持蓝牙硬件,蓝牙功能无法正常工作。必须在真机上调试。部分模拟器可能支持 BLE 仿真,但不支持经典蓝牙。
Q2:写入特征值后立即读取,拿到的仍然是旧值?
A:写入可能未完成(尤其是无响应写),或者写入后外设需要时间更新。建议在写入后等待一个适当的时间(如 200ms)再读取,或者通过外设的 Notify 特性接收数据变更。
Q3:a2dpSource.play调用后音箱没有反应?
A:检查音箱是否处于非连接状态,或者音箱的 AVRCP 协议版本较低。部分旧款音箱仅支持 A2DP Sink 角色,不支持 AV/C 控制指令。可以尝试手动点按音箱上的播放键测试,如果音箱本身不支持遥控播放,则play指令无效。
这篇文章是对 HarmonyOS 蓝牙开发中 GATT 与 A2DP 两个常见需求的实战总结。如果你还在纠结特征值读写时机或者音箱控制无效,可以重点检查连接状态同步和写入类型。毕竟官方文档只告诉你 API 长什么样,但实际怎么用还得靠多测。