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_BLUETOOTH 和 LOCATION 两个权限,缺一不可。

Q:getServices 返回空数组?

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

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

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

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

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


总结

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

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

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

相关推荐
梦想不只是梦与想12 小时前
HarmonyOS应用分层架构设计
harmonyos·分层架构·一次开发,多端部署
MardaWang15 小时前
当滚动逃离了框架 ——HarmonyOS Web 与原生混排滚动的冲突本质与解法
harmonyos·arkts·鸿蒙·deveco studio
tsqtsqtsq030916 小时前
DevEco Studio 介绍
harmonyos
HwJack2019 小时前
【共创稿事节】HarmonyOS 7文旅展陈展厅大空间 3DGS 重建的分块策略与拼接踩坑
3d·华为·harmonyos
熊猫钓鱼>_>21 小时前
Kotlin Multiplatform for OpenHarmony 实战:为 kotlin-inject 实现依赖注入适配
开发语言·华为·kotlin·ai编程·inject·鸿蒙·openharmony
m0_7381858221 小时前
Flutter 鸿蒙化实战:media_info 适配 OpenHarmony,媒体信息与缩略图
flutter·华为·harmonyos·鸿蒙·媒体
m0_738185821 天前
Flutter 鸿蒙化实战:just_audio 适配 OpenHarmony,功能强大的播放器
flutter·华为·harmonyos·鸿蒙
翼辉cto1 天前
Kotlin Multiplatform 三方库 SQLDelight 的 OpenHarmony 鸿蒙化适配实战
开发语言·kotlin·harmonyos
SuperHeroWu71 天前
TraeCode 国内版接入 DevEco CLI:用官方知识开发鸿蒙应用
ai编程·harmonyos·知识库·trae·aicoding·skills·deveco cli