HarmonyOS技术精讲-Connectivity Kit:蓝牙进阶——GATT服务与音频通道

这个场景你很可能遇到过

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);
  }
}

最佳实践

  1. 不要在 build() 中频繁创建蓝牙对象

    蓝牙连接是一个长时间运行的任务,如果每次页面刷新都重新创建 BLEGattClient 实例,会导致重复注册事件监听,甚至内存泄漏。建议在 aboutToAppear 中初始化,页面销毁时移除监听。

  2. 使用状态机管理连接生命周期

    BLE 连接的状态变化(待连接、正在连接、已连接、已断开)需要同步到 UI。推荐用一个 @State 变量标记连接状态,connect 调用后更新为 connectingBLEConnectionStateChange 回调中更新为 connecteddisconnected。避免在异步回调外直接修改 UI 可能导致的 @State 不一致。

  3. 经典蓝牙音频控制前检查焦点

    调用 a2dpSource.play 前,如果设备正在播放其他音频(如来电),可能会被系统拦截。建议先申请音频焦点(audio.AudioManagerrequestAudioFocus),确保自己有控制权。

实际生产代码需要处理权限申请(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 长什么样,但实际怎么用还得靠多测。

相关推荐
Helen_cai6 小时前
HarmonyOS ArkTS 实战:实现一个校园快递代取与跑腿应用
华为·harmonyos
2301_768103496 小时前
HarmonyOS趣味相机实战第13篇:CameraPicker与CameraKit选型、系统拍照回退及架构边界
harmonyos·arkts·架构设计·camerapicker·camerakit
木易 士心7 小时前
深度解析 Android 音频焦点处理与实战开发
android·音视频
2301_768103497 小时前
HarmonyOS趣味相机实战第14篇:装饰素材强类型建模、锚点语义与套装组合校验
harmonyos·arkts·数据建模·arkui·相机贴纸
程序员老陆7 小时前
使用Qt+大华设备SDK预览大华摄像机视频
qt·算法·音视频·视频监控
byte轻骑兵7 小时前
【LE Audio】CSIS精讲[4]: 从加密原语到RSI解析全链路拆解
音视频·人机交互·蓝牙耳机·le audio·低功耗蓝牙音频
不羁的木木7 小时前
HarmonyOS技术精讲-Connectivity Kit:Wi-Fi进阶能力——热点、P2P与Aware
asp.net·harmonyos·p2p
Helen_cai8 小时前
HarmonyOS ArkTS 实战:实现一个校园宿舍管理与查寝应用
华为·harmonyos
listening7778 小时前
HarmonyOS ArkTS 实战:实现一个校园打印复印预约应用
华为·harmonyos