HarmonyOS 6.0 PC端蓝牙开发全攻略:从设备扫描到数据收发

导读

蓝牙开发在鸿蒙 PC 端一直是个被忽视的角落------大多数教程只讲手机端,PC 端的蓝牙 API 细节、权限配置、设备配对流程几乎没有完整的中文资料。

本文聚焦 HarmonyOS 6.0 PC 端,系统讲清楚三件事:

  • @ohos.bluetooth.ble@ohos.bluetooth.connection 两套 API 的分工与配合
  • 完整的蓝牙工作流:开关状态检测 → 设备扫描 → 配对连接 → GATT 数据收发
  • 几个 PC 端特有的坑和对应处理方式

不依赖 ArkUI 组件,所有逻辑用纯 ArkTS 实现,可以独立封装成 Service 层在任何页面调用。


一、权限配置与环境准备

1.1 module.json5 权限声明

PC 端蓝牙需要声明的权限比手机端多,漏掉任何一个都会导致 API 调用静默失败:

复制代码
{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": [
      "phone"
    ],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "requestPermissions": [
      {
        "name": "ohos.permission.ACCESS_BLUETOOTH",
        "reason": "$string:permission_bluetooth",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.MANAGE_BLUETOOTH",
        "reason": "$string:permission_bluetooth",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.LOCATION",
        "reason": "$string:permission_location",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.APPROXIMATELY_LOCATION",
        "reason": "$string:permission_location",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ],
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": [
              "entity.system.home"
            ],
            "actions": [
              "ohos.want.action.home"
            ]
          }
        ]
      }
    ],
    "extensionAbilities": [
      {
        "name": "EntryBackupAbility",
        "srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
        "type": "backup",
        "exported": false,
        "metadata": [
          {
            "name": "ohos.extension.backup",
            "resource": "$profile:backup_config"
          }
        ],
      }
    ]
  }
}

为什么需要位置权限?

BLE 扫描在底层依赖位置信息(通过蓝牙信号强度可以推算设备位置),即使你的应用完全不使用定位功能,只要用到 BLE 扫描,就必须申请位置权限,否则 startBLEScan 会返回错误码 201(权限被拒绝)。这是 Android 系的惯例,鸿蒙沿用了这个设计。

1.2 API 分工说明

HarmonyOS 蓝牙 API 分两个模块:

|----------------------------|-------------------------|
| 模块 | 职责 |
| @ohos.bluetooth.connection | 经典蓝牙:开关状态、设备配对、连接管理 |
| @ohos.bluetooth.ble | BLE低功耗蓝牙:扫描、GATT连接、数据读写 |

PC 端外设(键鼠、耳机等)通常用经典蓝牙,IoT 传感器类设备用 BLE。本文两套都会讲,并提供统一的封装层。


二、蓝牙状态管理

2.1 检测与监听蓝牙开关

复制代码
// bluetooth/BluetoothManager.ets
import connection from '@ohos.bluetooth.connection';
import { BusinessError } from '@ohos.base';

export enum BtState {
  OFF       = 0,
  TURNING_ON  = 1,
  ON        = 2,
  TURNING_OFF = 3
}

export class BluetoothManager {

  // 获取当前蓝牙状态
  static getState(): BtState {
    try {
      const state = connection.getBluetoothState();
      return state as unknown as BtState;
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[BT] getState failed: ${e.code} ${e.message}`);
      return BtState.OFF;
    }
  }

  // 监听蓝牙状态变化
  static onStateChange(callback: (state: BtState) => void): void {
    try {
      connection.on('stateChange', (state: connection.BluetoothState) => {
        callback(state as unknown as BtState);
      });
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[BT] onStateChange failed: ${e.code} ${e.message}`);
    }
  }

  // 取消监听
  static offStateChange(): void {
    try {
      connection.off('stateChange');
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[BT] offStateChange failed: ${e.code} ${e.message}`);
    }
  }

  // 开启蓝牙(需要 MANAGE_BLUETOOTH 权限)
  static enableBluetooth(): void {
    try {
      connection.enableBluetooth();
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[BT] enableBluetooth failed: ${e.code} ${e.message}`);
    }
  }

  // 关闭蓝牙
  static disableBluetooth(): void {
    try {
      connection.disableBluetooth();
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[BT] disableBluetooth failed: ${e.code} ${e.message}`);
    }
  }
}

代码要点:所有 API 调用都要 try/catch

蓝牙 API 在权限不足、蓝牙未开启等情况下会直接抛出 BusinessError,不是通过回调返回错误码,所以必须用 try/catch 包裹。BusinessError 包含 code(错误码)和 message(错误描述),打印出来能快速定位问题。


三、经典蓝牙:设备配对与连接

3.1 扫描已配对设备

PC 端连接蓝牙设备通常先查询已配对列表,不需要每次都重新扫描:

复制代码
// bluetooth/ClassicBluetooth.ets
import connection from '@ohos.bluetooth.connection';
import { BusinessError } from '@ohos.base';

export interface BtDevice {
  deviceId: string;    // MAC 地址,如 "XX:XX:XX:XX:XX:XX"
  deviceName: string;
  deviceClass: number; // 设备类型(耳机、键盘等)
}

export class ClassicBluetooth {

  // 获取已配对设备列表
  static getPairedDevices(): BtDevice[] {
    try {
      const devices = connection.getPairedDevices();
      return devices.map((deviceId: string) => {
        const name = ClassicBluetooth.getDeviceName(deviceId);
        const cls  = ClassicBluetooth.getDeviceClass(deviceId);
        return { deviceId, deviceName: name, deviceClass: cls };
      });
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[BT] getPairedDevices failed: ${e.code} ${e.message}`);
      return [];
    }
  }

  // 获取设备名称
  static getDeviceName(deviceId: string): string {
    try {
      return connection.getRemoteDeviceName(deviceId);
    } catch {
      return '未知设备';
    }
  }

  // 获取设备类型
  static getDeviceClass(deviceId: string): number {
    try {
      const cls = connection.getRemoteDeviceClass(deviceId);
      return cls.majorClass;
    } catch {
      return 0;
    }
  }

  // 设备类型转可读描述
  static getDeviceTypeDesc(majorClass: number): string {
    const map: Record<number, string> = {
      0x0100: '电脑',
      0x0200: '手机',
      0x0300: '网络设备',
      0x0400: '音频/视频',
      0x0500: '外设(键鼠)',
      0x0600: '图像设备',
      0x0700: '穿戴设备',
      0x0800: '玩具',
      0x0900: '健康设备',
    };
    return map[majorClass] ?? '其他设备';
  }

  // 发起配对请求
  static pairDevice(deviceId: string): Promise<boolean> {
    return new Promise((resolve) => {
      try {
        // 监听配对状态变化
        connection.on('bondStateChange', (state: connection.BondStateParam) => {
          if (state.deviceId !== deviceId) return;

          if (state.state === connection.BondState.BOND_STATE_BONDED) {
            connection.off('bondStateChange');
            resolve(true);
          } else if (state.state === connection.BondState.BOND_STATE_INVALID) {
            connection.off('bondStateChange');
            resolve(false);
          }
        });

        connection.pairDevice(deviceId);
      } catch (err) {
        const e = err as BusinessError;
        console.error(`[BT] pairDevice failed: ${e.code} ${e.message}`);
        resolve(false);
      }
    });
  }

  // 取消配对
  static unpairDevice(deviceId: string): boolean {
    try {
      connection.cancelPairedDevice(deviceId);
      return true;
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[BT] unpairDevice failed: ${e.code} ${e.message}`);
      return false;
    }
  }

  // 监听设备连接状态变化
  static onConnectionStateChange(
    callback: (deviceId: string, state: number) => void
  ): void {
    try {
      connection.on('connectionStateChange',
        (result: connection.BluetoothConnectionState) => {
          callback(result.deviceId, result.state);
        }
      );
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[BT] onConnectionStateChange failed: ${e.code} ${e.message}`);
    }
  }
}

pairDevice 为什么用 Promise 包装?

connection.pairDevice() 本身是同步触发配对请求,但配对结果是异步通过 bondStateChange 事件回调的(用户需要在弹窗里确认)。把监听和触发包装在同一个 Promise 里,调用方可以用 await 等待配对完成,代码逻辑更线性清晰。


四、BLE 低功耗蓝牙:扫描与 GATT 通信

4.1 BLE 设备扫描

复制代码
// bluetooth/BleScanner.ets
import ble from '@ohos.bluetooth.ble';
import { BusinessError } from '@ohos.base';

export interface BleDevice {
  deviceId: string;
  deviceName: string;
  rssi: number;           // 信号强度,越大越近(负数,如 -60)
  serviceUuids: string[]; // 设备广播的服务 UUID
}

export class BleScanner {
  private static scanning: boolean = false;
  private static deviceMap: Map<string, BleDevice> = new Map();
  private static onDeviceFound: (devices: BleDevice[]) => void = () => {};

  // 开始扫描
  static startScan(
    filterUuids: string[],   // 传空数组表示扫描所有设备
    onFound: (devices: BleDevice[]) => void
  ): void {
    if (BleScanner.scanning) return;

    BleScanner.deviceMap.clear();
    BleScanner.onDeviceFound = onFound;

    try {
      // 注册扫描结果回调
      ble.on('BLEDeviceFind', (devices: Array<ble.ScanResult>) => {
        for (const result of devices) {
          const device: BleDevice = {
            deviceId:    result.deviceId,
            deviceName:  result.data ? BleScanner.parseDeviceName(result.data) : '未知设备',
            rssi:        result.rssi,
            serviceUuids: result.serviceUuids ?? []
          };
          // 用 Map 去重,同一设备只保留最新扫描结果
          BleScanner.deviceMap.set(result.deviceId, device);
        }
        // 按信号强度排序,越近的排在前面
        const sorted = Array.from(BleScanner.deviceMap.values())
          .sort((a, b) => b.rssi - a.rssi);
        BleScanner.onDeviceFound(sorted);
      });

      // 构建扫描过滤器
      const filters: ble.ScanFilter[] = filterUuids.length > 0
        ? filterUuids.map(uuid => ({ serviceUuid: uuid }))
        : [];

      const scanOptions: ble.ScanOptions = {
        interval: 0,       // 0 表示持续扫描
        dutyMode: ble.ScanDuty.SCAN_MODE_LOW_LATENCY,  // 低延迟模式,更快发现设备
        matchMode: ble.MatchMode.MATCH_MODE_AGGRESSIVE
      };

      ble.startBLEScan(filters, scanOptions);
      BleScanner.scanning = true;
      console.info('[BLE] 扫描已启动');

    } catch (err) {
      const e = err as BusinessError;
      console.error(`[BLE] startScan failed: ${e.code} ${e.message}`);
    }
  }

  // 停止扫描
  static stopScan(): void {
    if (!BleScanner.scanning) return;
    try {
      ble.stopBLEScan();
      ble.off('BLEDeviceFind');
      BleScanner.scanning = false;
      console.info('[BLE] 扫描已停止');
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[BLE] stopScan failed: ${e.code} ${e.message}`);
    }
  }

  // 从广播数据中解析设备名称
  private static parseDeviceName(data: ArrayBuffer): string {
    try {
      const bytes = new Uint8Array(data);
      let i = 0;
      while (i < bytes.length) {
        const len  = bytes[i];
        const type = bytes[i + 1];
        // type 0x09 是完整设备名,0x08 是缩短设备名
        if ((type === 0x09 || type === 0x08) && len > 1) {
          const nameBytes = bytes.slice(i + 2, i + 1 + len);
          return new TextDecoder().decode(nameBytes);
        }
        i += len + 1;
      }
    } catch {
      // 解析失败不影响主流程
    }
    return '未知设备';
  }

  static isScanning(): boolean {
    return BleScanner.scanning;
  }
}

广播数据解析是什么原理?

BLE 设备在广播包里携带了设备名、服务 UUID 等信息,格式是「长度 + 类型 + 数据」的 TLV 结构。parseDeviceName 就是遍历这个字节数组,找到类型为 0x09(完整名称)的字段并解码成字符串。这是 BLE 协议的标准格式,所有 BLE 设备都遵循。

为什么用 Map 存扫描结果而不是数组?

BLE 扫描是持续进行的,同一个设备可能被多次发现(每次广播都会触发回调)。用 Map 以 deviceId 为 key 存储,自动实现去重,每次更新同一设备的最新信号强度,不会出现列表里同一个设备出现多次的情况。


4.2 GATT 连接与数据通信

GATT(Generic Attribute Profile)是 BLE 数据交换的核心协议。连接建立后,通过读/写 Characteristic 来收发数据:

复制代码
// bluetooth/GattClient.ets
import ble from '@ohos.bluetooth.ble';
import { BusinessError } from '@ohos.base';

export interface GattService {
  serviceUuid: string;
  characteristics: GattCharacteristic[];
}

export interface GattCharacteristic {
  uuid: string;
  properties: number;  // 读/写/通知 等属性位标志
}

// Characteristic 属性标志位
export const CHAR_PROPS = {
  READ:   0x02,
  WRITE:  0x08,
  NOTIFY: 0x10,
};

export class GattClient {
  private client: ble.GattClientDevice | null = null;
  private deviceId: string = '';

  // 连接设备
  async connect(deviceId: string): Promise<boolean> {
    return new Promise((resolve) => {
      try {
        this.deviceId = deviceId;
        this.client = ble.createGattClientDevice(deviceId);

        // 监听连接状态
        this.client.on('BLEConnectionStateChange',
          (state: ble.BLEConnectionChangeState) => {
            if (state.state === ble.ProfileConnectionState.STATE_CONNECTED) {
              console.info(`[GATT] 已连接:${deviceId}`);
              resolve(true);
            } else if (state.state === ble.ProfileConnectionState.STATE_DISCONNECTED) {
              console.info(`[GATT] 已断开:${deviceId}`);
              resolve(false);
            }
          }
        );

        this.client.connect();

      } catch (err) {
        const e = err as BusinessError;
        console.error(`[GATT] connect failed: ${e.code} ${e.message}`);
        resolve(false);
      }
    });
  }

  // 发现服务(连接成功后调用)
  async discoverServices(): Promise<GattService[]> {
    if (!this.client) return [];
    try {
      const services = await this.client.getServices();
      return services.map((svc: ble.GattService) => ({
        serviceUuid: svc.serviceUuid,
        characteristics: (svc.characteristics ?? []).map(
          (char: ble.BLECharacteristic) => ({
            uuid:       char.characteristicUuid,
            properties: char.properties?.write ? CHAR_PROPS.WRITE :
                        char.properties?.read  ? CHAR_PROPS.READ  : 0
          })
        )
      }));
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[GATT] getServices failed: ${e.code} ${e.message}`);
      return [];
    }
  }

  // 读取 Characteristic 数据
  async readCharacteristic(
    serviceUuid: string,
    charUuid: string
  ): Promise<Uint8Array | null> {
    if (!this.client) return null;
    try {
      const char: ble.BLECharacteristic = {
        serviceUuid,
        characteristicUuid: charUuid,
        characteristicValue: new ArrayBuffer(0),
        descriptors: []
      };
      const result = await this.client.readCharacteristicValue(char);
      return new Uint8Array(result.characteristicValue);
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[GATT] readChar failed: ${e.code} ${e.message}`);
      return null;
    }
  }

  // 写入 Characteristic 数据
  async writeCharacteristic(
    serviceUuid: string,
    charUuid: string,
    data: Uint8Array
  ): Promise<boolean> {
    if (!this.client) return false;
    try {
      const char: ble.BLECharacteristic = {
        serviceUuid,
        characteristicUuid: charUuid,
        characteristicValue: data.buffer,
        descriptors: []
      };
      await this.client.writeCharacteristicValue(char, ble.GattWriteType.WRITE);
      return true;
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[GATT] writeChar failed: ${e.code} ${e.message}`);
      return false;
    }
  }

  // 订阅 Characteristic 通知(设备主动推送数据)
  subscribeNotify(
    serviceUuid: string,
    charUuid: string,
    onData: (data: Uint8Array) => void
  ): void {
    if (!this.client) return;
    try {
      // 开启通知
      this.client.setCharacteristicChangeNotification(
        {
          serviceUuid,
          characteristicUuid: charUuid,
          characteristicValue: new ArrayBuffer(0),
          descriptors: []
        },
        true
      );

      // 监听数据推送
      this.client.on('BLECharacteristicChange',
        (char: ble.BLECharacteristic) => {
          if (char.characteristicUuid === charUuid) {
            onData(new Uint8Array(char.characteristicValue));
          }
        }
      );
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[GATT] subscribeNotify failed: ${e.code} ${e.message}`);
    }
  }

  // 断开连接并释放资源
  disconnect(): void {
    if (!this.client) return;
    try {
      this.client.off('BLEConnectionStateChange');
      this.client.off('BLECharacteristicChange');
      this.client.disconnect();
      this.client.close();
      this.client = null;
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[GATT] disconnect failed: ${e.code} ${e.message}`);
    }
  }

  isConnected(): boolean {
    return this.client !== null;
  }
}

读/写/通知三种操作有什么区别?

  • 读(Read):主动向设备请求数据,适合查询当前状态(如电量)
  • 写(Write):主动向设备发送指令或数据,适合控制设备(如调节亮度)
  • 通知(Notify):订阅后设备会主动推送数据,适合持续监测场景(如心率、温度传感器)

三种操作是 GATT 协议的核心,BLE 设备的所有数据交互都通过这三种方式完成。


五、统一封装:BluetoothService

把以上三个模块整合成一个统一的 Service 入口,页面调用更方便:

复制代码
// bluetooth/BluetoothService.ets
import { BluetoothManager, BtState } from './BluetoothManager';
import { ClassicBluetooth, BtDevice } from './ClassicBluetooth';
import { BleScanner, BleDevice } from './BleScanner';
import { GattClient } from './GattClient';

export { BtState, BtDevice, BleDevice };

export class BluetoothService {
  private static gattClient: GattClient = new GattClient();

  // ── 状态管理 ──────────────────────────────

  static getState(): BtState {
    return BluetoothManager.getState();
  }

  static isEnabled(): boolean {
    return BluetoothManager.getState() === BtState.ON;
  }

  static onStateChange(cb: (state: BtState) => void): void {
    BluetoothManager.onStateChange(cb);
  }

  static offStateChange(): void {
    BluetoothManager.offStateChange();
  }

  // ── 经典蓝牙 ──────────────────────────────

  static getPairedDevices(): BtDevice[] {
    return ClassicBluetooth.getPairedDevices();
  }

  static async pairDevice(deviceId: string): Promise<boolean> {
    return ClassicBluetooth.pairDevice(deviceId);
  }

  static unpairDevice(deviceId: string): boolean {
    return ClassicBluetooth.unpairDevice(deviceId);
  }

  static onConnectionStateChange(
    cb: (deviceId: string, state: number) => void
  ): void {
    ClassicBluetooth.onConnectionStateChange(cb);
  }

  // ── BLE 扫描 ──────────────────────────────

  static startBleScan(
    filterUuids: string[],
    onFound: (devices: BleDevice[]) => void
  ): void {
    if (!BluetoothService.isEnabled()) {
      console.warn('[BT] 蓝牙未开启,无法扫描');
      return;
    }
    BleScanner.startScan(filterUuids, onFound);
  }

  static stopBleScan(): void {
    BleScanner.stopScan();
  }

  // ── GATT 通信 ──────────────────────────────

  static async connectGatt(deviceId: string): Promise<boolean> {
    BluetoothService.stopBleScan(); // 连接前停止扫描,节省资源
    return BluetoothService.gattClient.connect(deviceId);
  }

  static async discoverServices() {
    return BluetoothService.gattClient.discoverServices();
  }

  static async readCharacteristic(svcUuid: string, charUuid: string) {
    return BluetoothService.gattClient.readCharacteristic(svcUuid, charUuid);
  }

  static async writeCharacteristic(
    svcUuid: string,
    charUuid: string,
    data: Uint8Array
  ): Promise<boolean> {
    return BluetoothService.gattClient.writeCharacteristic(svcUuid, charUuid, data);
  }

  static subscribeNotify(
    svcUuid: string,
    charUuid: string,
    onData: (data: Uint8Array) => void
  ): void {
    BluetoothService.gattClient.subscribeNotify(svcUuid, charUuid, onData);
  }

  static disconnect(): void {
    BluetoothService.gattClient.disconnect();
  }

  // ── 工具方法 ──────────────────────────────

  // Uint8Array 转十六进制字符串(调试用)
  static bytesToHex(data: Uint8Array): string {
    return Array.from(data)
      .map(b => b.toString(16).padStart(2, '0').toUpperCase())
      .join(' ');
  }

  // 字符串转 Uint8Array(发送文本指令用)
  static stringToBytes(str: string): Uint8Array {
    return new TextEncoder().encode(str);
  }

  // Uint8Array 转字符串(接收文本数据用)
  static bytesToString(data: Uint8Array): string {
    return new TextDecoder().decode(data);
  }
}

六、在页面中使用

封装好之后,页面里调用非常简洁:

复制代码
// pages/BluetoothPage.ets
import { BluetoothService, BtState, BleDevice } from '../bluetooth/BluetoothService';

@Entry
@Component
struct BluetoothPage {
  @State btEnabled: boolean = false;
  @State bleDevices: BleDevice[] = [];
  @State scanning: boolean = false;
  @State connectedDeviceId: string = '';
  @State receivedData: string = '';

  aboutToAppear() {
    // 检查蓝牙状态
    this.btEnabled = BluetoothService.isEnabled();

    // 监听蓝牙开关变化
    BluetoothService.onStateChange((state: BtState) => {
      this.btEnabled = state === BtState.ON;
      if (!this.btEnabled && this.scanning) {
        this.scanning = false;
      }
    });
  }

  aboutToDisappear() {
    BluetoothService.offStateChange();
    BluetoothService.stopBleScan();
    BluetoothService.disconnect();
  }

  // 开始/停止 BLE 扫描
  toggleScan() {
    if (this.scanning) {
      BluetoothService.stopBleScan();
      this.scanning = false;
    } else {
      this.bleDevices = [];
      BluetoothService.startBleScan([], (devices: BleDevice[]) => {
        this.bleDevices = devices;
      });
      this.scanning = true;
    }
  }

  // 连接 BLE 设备并订阅通知
  async connectDevice(deviceId: string) {
    BluetoothService.stopBleScan();
    this.scanning = false;

    const connected = await BluetoothService.connectGatt(deviceId);
    if (!connected) {
      console.error('[PAGE] 连接失败');
      return;
    }

    this.connectedDeviceId = deviceId;

    // 发现服务
    const services = await BluetoothService.discoverServices();
    console.info(`[PAGE] 发现 ${services.length} 个服务`);

    // 假设目标设备的通知 Characteristic UUID(根据实际设备替换)
    const TARGET_SVC  = '0000180D-0000-1000-8000-00805F9B34FB'; // 心率服务
    const TARGET_CHAR = '00002A37-0000-1000-8000-00805F9B34FB'; // 心率测量

    // 订阅数据通知
    BluetoothService.subscribeNotify(TARGET_SVC, TARGET_CHAR, (data: Uint8Array) => {
      const hex = BluetoothService.bytesToHex(data);
      this.receivedData = hex;
      console.info(`[PAGE] 收到数据:${hex}`);
    });
  }

  build() {
    Column() {
      // 蓝牙状态指示
      Row() {
        Text('蓝牙状态:')
          .fontSize(16).fontColor('#1e293b')
        Text(this.btEnabled ? '已开启 ✅' : '已关闭 ❌')
          .fontSize(16)
          .fontColor(this.btEnabled ? '#22c55e' : '#ef4444')
      }
      .padding(20)

      // 扫描按钮
      Button(this.scanning ? '停止扫描' : '开始扫描 BLE 设备')
        .onClick(() => this.toggleScan())
        .backgroundColor(this.scanning ? '#ef4444' : '#3b82f6')
        .fontColor(Color.White)
        .borderRadius(24)
        .margin({ bottom: 16 })
        .enabled(this.btEnabled)

      // 设备列表
      List({ space: 8 }) {
        ForEach(this.bleDevices, (device: BleDevice) => {
          ListItem() {
            Row() {
              Column() {
                Text(device.deviceName)
                  .fontSize(15).fontWeight(FontWeight.Medium)
                Text(device.deviceId)
                  .fontSize(12).fontColor('#94a3b8')
                Text('信号:' + device.rssi + ' dBm')
                  .fontSize(12).fontColor('#64748b')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)

              Button('连接')
                .fontSize(13)
                .height(32)
                .backgroundColor('#3b82f6')
                .fontColor(Color.White)
                .borderRadius(16)
                .onClick(() => this.connectDevice(device.deviceId))
            }
            .padding(14)
            .backgroundColor(Color.White)
            .borderRadius(12)
          }
        })
      }
      .padding({ left: 16, right: 16 })
      .layoutWeight(1)

      // 接收到的数据
      if (this.receivedData !== '') {
        Text('收到数据:' + this.receivedData)
          .fontSize(14).fontColor('#7c3aed')
          .padding(16)
          .backgroundColor('#ede9fe')
          .borderRadius(8)
          .margin(16)
      }
    }
    .height('100%')
    .backgroundColor('#f8fafc')
  }
}

七、代码讲解

7.1 事件监听必须配对 off

所有 on 注册的监听器,在页面或组件销毁时必须调用对应的 off 取消。如果不取消,即使页面已经销毁,回调函数依然持有对页面组件的引用,导致内存泄漏,还可能触发在已销毁组件上更新状态的报错。

本文所有监听器都在 aboutToDisappear 里统一清理,这是标准做法。

7.2 连接前停止扫描

connectGatt 里第一步就调用 stopBleScan(),原因是 BLE 扫描和 GATT 连接共用底层无线资源,同时进行会互相干扰,导致连接成功率下降、连接耗时增加。先停扫描再连接,是 BLE 开发的标准实践。

7.3 Uint8Array 与 ArrayBuffer 的转换

BLE 数据在 API 层面是 ArrayBuffer 类型,但直接操作 ArrayBuffer 不方便,通常转成 Uint8Array 后再处理:

复制代码
// ArrayBuffer → Uint8Array(读取数据时)
const bytes = new Uint8Array(result.characteristicValue);

// Uint8Array → ArrayBuffer(写入数据时)
const buffer = bytes.buffer;

// 字符串 → Uint8Array(发文本指令)
const bytes = new TextEncoder().encode('Hello');

// Uint8Array → 字符串(收文本数据)
const str = new TextDecoder().decode(bytes);

BluetoothService 里封装了这几个工具方法,实际项目里直接调用即可。

7.4 GATT UUID 的标准格式

BLE 服务和 Characteristic 的 UUID 有两种格式:

  • 标准 UUID :128位,格式如 0000180D-0000-1000-8000-00805F9B34FB(心率服务)
  • 短 UUID :16位,如 0x180D,是标准 UUID 的缩写形式

HarmonyOS 的 GATT API 接受两种格式,但建议统一用完整的 128位格式,避免兼容性问题。常见的标准服务 UUID 可以在 Bluetooth SIG 官网查询。


八、真实运行效果

文件清单:

复制代码
entry/src/main/ets/
├── bluetooth/
│   ├── BluetoothManager.ets
│   ├── ClassicBluetooth.ets
│   ├── BleScanner.ets
│   ├── GattClient.ets
│   └── BluetoothService.ets
└── pages/
    └── BluetoothPage.ets

权限配置后的运行表现:

应用启动后页面顶部显示当前蓝牙开关状态。如果蓝牙未开启,扫描按钮呈灰色禁用状态,用户无法误触。

点击「开始扫描 BLE 设备」后,列表开始动态更新,附近的 BLE 设备逐个出现,按信号强度从强到弱排序,信号强度数值实时刷新(越靠近 0 信号越强,如 -45 dBm 比 -80 dBm 更近)。

点击某个设备的「连接」按钮,扫描立即停止,GATT 连接建立后自动发现服务列表,控制台打印服务数量。如果目标设备支持心率通知,订阅后数据会以十六进制字符串形式显示在页面底部。

关闭蓝牙开关,stateChange 事件立即触发,页面状态指示切换为「已关闭」,扫描按钮变灰。

PC 端与手机端的主要差异:

PC 端蓝牙适配器功率通常比手机低,扫描范围约 5~8 米,超出范围的设备信号强度会低于 -90 dBm,实际连接成功率很低,属于正常现象。PC 端不支持蓝牙 5.0 的长距离模式(LE Long Range),最大可靠通信距离约 10 米。


九、常见问题

Q:startBLEScan 报错码 201?

权限问题。检查 module.json5 里是否同时声明了 ACCESS_BLUETOOTHLOCATION 两个权限,缺一不可。

Q:getServices 返回空数组?

连接成功后需要等待约 500ms~1s 让 GATT 服务发现完成,再调用 getServices。可以加一个 setTimeout 延迟调用,或者监听连接状态稳定后再操作。

Q:writeCharacteristic 报错「不支持的操作」?

目标 Characteristic 可能不具备 Write 属性。先调用 getServices 查看该 Characteristic 的 properties,只有 writewriteNoResponsetrue 的 Characteristic 才能写入。

Q:订阅通知后收不到数据?

检查是否正确写入了 Client Characteristic Configuration Descriptor(CCCD)。部分设备需要显式向 CCCD(UUID: 00002902)写入 0x01 0x00 才能激活通知,setCharacteristicChangeNotification 在大多数设备上会自动处理,但少数设备需要手动操作。


总结

本文完整覆盖了 HarmonyOS 6.0 PC 端蓝牙开发的核心链路:

权限配置是一切的前提,INTERNETLOCATION 缺一不可。connection 模块管经典蓝牙,ble 模块管 BLE,职责分明不要混用。事件监听一定要成对 on/off,在 aboutToDisappear 里统一清理。BLE 连接前先停止扫描,提升连接成功率。GATT 数据以 ArrayBuffer 传输,用 Uint8Array 桥接操作更方便。

这套封装可以直接带入实际项目,根据目标设备的 GATT Profile 替换对应的服务和 Characteristic UUID 即可。

相关推荐
大雷神2 小时前
HarmonyOS APP<玩转React>开源教程二十四:错题本功能
react.js·面试·开源·harmonyos
程序猿追3 小时前
HarmonyOS 6.0 游戏开发实战:用 ArkUI 从零打造消消乐小游戏
大数据·人工智能·harmonyos
¥-oriented3 小时前
【鸿蒙学习资源】
harmonyos
想你依然心痛4 小时前
HarmonyOS 5.0教育行业解决方案:基于分布式能力的沉浸式智慧课堂系统
分布式·wpf·harmonyos
程序猿追4 小时前
从零到上线:用 ArkUI 打造 HarmonyOS 5.0 记账应用全流程实战
华为·harmonyos
程序猿追4 小时前
HarmonyOS 6.0 PC 实战:从零构建一款高性能 Markdown 生产力工具
华为·harmonyos
key_3_feng4 小时前
鸿蒙元服务ArkTS开发方案
华为·harmonyos
Gorit5 小时前
使用 AI + Flutter-OH 开发 HarmonyOS 应用
人工智能·flutter·harmonyos
程序猿追5 小时前
声影共振:深度解析 HarmonyOS PC 端高刷音频视觉化引擎的底层实现
华为·音视频·harmonyos