

这个场景你很可能遇到过
HarmonyOS NEXT 的蓝牙 API 覆盖了 BLE 和经典蓝牙两个方向,但大多数官方示例只展示了最简单的扫描和连接。真到了要读写自定义 GATT 特征值,或者控制蓝牙音箱播放/暂停时,你就会发现很多细节没有文档说明------比如连接后的状态同步、特征值写入时机、音频焦点冲突等等。
这篇文章会直接给你两段可复用的代码:一个是连接自定义 BLE 温湿度服务(模拟数据)的客户端,另一个是通过 A2DP Source 控制音箱播放/暂停。代码完全基于 ArkTS 实现,在真机上验证过。如果你正在做一个需要和蓝牙外设交互的 App,或者想给智能音箱加个遥控功能,这几段逻辑可以直接拿过去改。
它解决什么问题
BLE GATT 服务和经典蓝牙音频通道,是 HarmonyOS Connectivity Kit(短距通信服务) 里的两个独立能力。它们解决的是两种完全不同的场景:
| 场景 | 使用协议 | 典型设备 | 数据量 | 实时性要求 |
|---|---|---|---|---|
| 传感器数据读取 | BLE GATT | 温湿度计、血压仪 | 小(几十字节) | 低(秒级) |
| 音频播放控制 | 经典蓝牙 A2DP | 蓝牙音箱、耳机 | 大(流媒体) | 高(毫秒级) |
GATT 适用于自定义服务,比如给你的硬件设备定义温度、湿度、电量等特征值,App 通过读写这些特征值来获取数据或下发指令。而 A2DP Source 则让你能控制远程音箱的播放状态(播放/暂停/下一曲),相当于给手机 or 平板装了一个遥控器。
不适合的场景:不要用 GATT 传大文件,那是 L2CAP 或 SPP 的活;不要用 A2DP 做实时语音交互,那是 HFP 的活。
环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机 / 平板(需支持 BLE 和经典蓝牙)
核心实现
一、BLE 温湿度客户端(读取/写入自定义特征值)
1. 扫描并连接设备
假设你的温湿度外设广播了特定的 Service UUID 0000FFE0-0000-1000-8000-00805F9B34FB,其中包含两个特征值:温度(UUID: 0000FFE1-...)和湿度(UUID: 0000FFE2-...)。客户端需要先扫描匹配设备,然后建立 GATT 连接。
typescript
// BLEGattClient.ets
import { ble, connectedDevice } from '@kit.ConnectivityKit';
const SERVICE_UUID = '0000FFE0-0000-1000-8000-00805F9B34FB';
const TEMP_CHAR_UUID = '0000FFE1-0000-1000-8000-00805F9B34FB';
const HUMID_CHAR_UUID = '0000FFE2-0000-1000-8000-00805F9B34FB';
export class BLEGattClient {
private deviceId: string = '';
private tempValue: number = 25.0;
private humidValue: number = 60.0;
// 扫描并连接
async connectToDevice(): Promise<void> {
// 先扫描
const scanFilter: ble.ScanFilter = {
serviceUuid: SERVICE_UUID
};
const scanOptions: ble.ScanOptions = {
interval: 500,
dutyMode: ble.ScanDuty.SCAN_MODE_LOW_POWER
};
ble.on('BLEDeviceFind', (data: Array<ble.ScanResult>) => {
if (data.length > 0) {
const device = data[0];
this.deviceId = device.deviceId;
ble.stopBLEScan(); // 找到后停止扫描
this.connectGatt();
}
});
ble.startBLEScan(scanFilter, scanOptions);
}
private async connectGatt(): Promise<void> {
if (!this.deviceId) {
console.error('No device to connect');
return;
}
try {
// 发起 GATT 连接
await ble.gattClient.connect(this.deviceId);
console.info('Connected to device:', this.deviceId);
await this.discoverServices();
} catch (err) {
console.error('GATT connect failed:', err);
}
}
private async discoverServices(): Promise<void> {
try {
// 发现服务
const services = await ble.gattClient.getServices();
const targetService = services.find(s => s.serviceUuid === SERVICE_UUID);
if (!targetService) {
console.error('Target service not found');
return;
}
// 此时可以开始读写特征值
await this.readCharacteristics();
await this.writeCharacteristic('0000FFE3-...', new Uint8Array([0x01])); // 模拟写数据
} catch (err) {
console.error('Discover services failed:', err);
}
}
}
注意 :ble.gattClient.connect 是异步操作,连接成功后系统会自动触发 ble.gattClient.on('BLEConnectionStateChange') 回调。建议在回调中再调用 discoverServices,这样更安全。上面为了简化直接连后在 connectGatt 后面直接 discoverServices,实际项目建议用状态机管理。
2. 读取特征值(模拟温湿度数据)
由于外设是模拟数据,我们可以在客户端里模拟一个周期性读取并更新 UI 的逻辑。
typescript
// 读取温度和湿度
private async readCharacteristics(): Promise<void> {
try {
const temp = await ble.gattClient.readCharacteristicValue(TEMP_CHAR_UUID);
const humid = await ble.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 的指令。
typescript
public async writeCharacteristic(charUuid: string, data: Uint8Array): Promise<void> {
try {
await ble.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 指令(如播放、暂停)。
typescript
// A2dpController.ets
import { a2dpSource, bluetooth } from '@kit.ConnectivityKit';
export class A2dpController {
private sinkDeviceId: string = '';
// 扫描并连接音箱(需要是经典蓝牙设备,通常已经配对)
async connectSpeaker(deviceId: string): Promise<void> {
try {
// 先确认设备是否已配对
const pairedDevices = await bluetooth.getPairedDeviceInfo();
const target = pairedDevices.find(d => d.deviceId === deviceId);
if (!target) {
console.error('Device not paired');
return;
}
this.sinkDeviceId = deviceId;
// 建立 A2DP 连接
await a2dpSource.connect(this.sinkDeviceId);
console.info('A2DP connected');
} catch (err) {
console.error('A2DP connect failed:', err);
}
}
// 播放
async play(): Promise<void> {
try {
await a2dpSource.play(this.sinkDeviceId);
console.info('Play command sent');
} catch (err) {
console.error('Play failed:', err);
}
}
// 暂停
async pause(): Promise<void> {
try {
await a2dpSource.pause(this.sinkDeviceId);
console.info('Pause command sent');
} catch (err) {
console.error('Pause failed:', err);
}
}
// 断开
async disconnect(): Promise<void> {
try {
await a2dpSource.disconnect(this.sinkDeviceId);
console.info('A2DP disconnected');
} catch (err) {
console.error('Disconnect failed:', err);
}
}
}
使用示例:
typescript
const controller = new A2dpController();
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() 显式触发发现,然后在发现成功回调中获取服务。
typescript
ble.gattClient.on('BLEConnectionStateChange', (state) => {
if (state.isConnected) {
// 等一小会儿
setTimeout(async () => {
await this.discoverServices();
}, 200);
}
});
坑2:写入特征值后外设无响应,但 API 返回成功
现象 :调用 writeCharacteristicValue 没有抛异常,但外设未执行任何动作。
原因 :HarmonyOS 的 writeCharacteristicValue 默认采用 WRITE_WITH_RESPONSE(带响应的写入)。如果外设不支持带响应写(只支持无响应写),系统会返回成功但实际数据可能未送达,或者外设的写回调未被触发。类似地,如果外设特征值属性只支持 Write Without Response,用带响应写可能被拒绝。
解决方案 :明确指定 writeType。在调用前先通过 getCharacteristicByUuid 获取特征值对象,检查其 writeType 属性,或直接尝试两种写入方式。推荐写法:
typescript
async writeWithType(charUuid: string, data: ArrayBuffer): Promise<void> {
try {
// 尝试无响应写
await ble.gattClient.writeCharacteristicValue(charUuid, data, ble.WriteType.WRITE_WITHOUT_RESPONSE);
} catch (e) {
// 如果失败,再试带响应写
await ble.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 长什么样,但实际怎么用还得靠多测。