HarmonyOS BLE 快速上手

一、什么是蓝牙 BLE?

蓝牙 BLE(Bluetooth Low Energy)就是我们手机连接蓝牙耳机、智能手环、智能家居时用的技术。

为什么叫"低功耗"? 因为它比传统蓝牙省电很多,一块纽扣电池就能用好几年。


二、第一步:导入需要的模块

在开始写代码之前,先要导入蓝牙相关的工具包,就像做饭前要先准备食材一样:

typescript 复制代码
// ====== common/BLEManager.ets ======
// 这个文件放在 entry/src/main/ets/common/ 目录下

// 蓝牙功能模块 - 用来扫描、连接、收发数据
import { ble, access } from '@kit.ConnectivityKit';

// 权限管理模块 - 用来向用户申请权限
import { abilityAccessCtrl, Permissions, common } from '@kit.AbilityKit';

// 错误处理模块 - 用来捕获错误信息
import { BusinessError } from '@kit.BasicServicesKit';

// 日志模块 - 用来打印调试信息(可选)
import { hilog } from '@kit.PerformanceAnalysisKit';

三、第二步:配置权限

蓝牙功能涉及用户隐私,所以必须先申请权限。这分两步:

  1. 静态声明:告诉系统你的应用需要什么权限
  2. 动态申请:弹窗让用户点击"允许"

3.1 静态权限声明(在配置文件里写)

json 复制代码
// ====== entry/src/main/module.json5 ======
// 打开这个文件,找到 "module" 部分,添加 "requestPermissions"
{
  "module": {
    // ... 其他配置 ...
    
    "requestPermissions": [
      {
        "name": "ohos.permission.ACCESS_BLUETOOTH",  // 权限1:访问蓝牙
        "reason": "$string:bluetooth_permission_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.DISCOVER_BLUETOOTH", // 权限2:发现蓝牙设备
        "reason": "$string:bluetooth_discover_permission_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.USE_BLUETOOTH",      // 权限3:使用蓝牙
        "reason": "$string:bluetooth_use_permission_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

💡 为什么要3个权限? 因为蓝牙功能分成了扫描、连接、传输三部分,每部分需要单独授权。

3.2 权限说明字符串(告诉用户为什么需要权限)

json 复制代码
// ====== entry/src/main/resources/base/element/string.json ======
// 这些文字会显示在权限申请弹窗里,让用户知道为什么需要这个权限
{
  "string": [
    {
      "name": "bluetooth_permission_reason",
      "value": "需要蓝牙权限以连接和控制蓝牙设备"
    },
    {
      "name": "bluetooth_discover_permission_reason",
      "value": "需要发现蓝牙权限以扫描附近的蓝牙设备"
    },
    {
      "name": "bluetooth_use_permission_reason",
      "value": "需要使用蓝牙权限以传输数据"
    }
  ]
}

3.3 动态权限申请(弹窗让用户确认)

光在配置文件里写还不够,还需要在代码里弹窗询问用户。下面这个工具类可以直接复制使用:

typescript 复制代码
// ====== common/PermissionManager.ets ======
import { abilityAccessCtrl, Permissions, common } from '@kit.AbilityKit';

export class PermissionManager {
  private static instance: PermissionManager;
  private context?: common.UIAbilityContext;

  public static getInstance(): PermissionManager {
    if (!PermissionManager.instance) {
      PermissionManager.instance = new PermissionManager();
    }
    return PermissionManager.instance;
  }

  public setContext(context: common.UIAbilityContext): void {
    this.context = context;
  }

  /**
   * 检查并申请蓝牙权限
   * @returns 是否已授权
   */
  public async checkAndRequestBlePermissions(): Promise<boolean> {
    if (!this.context) {
      console.error('Context not set');
      return false;
    }

    const permissions: Array<Permissions> = [
      'ohos.permission.USE_BLUETOOTH',
      'ohos.permission.ACCESS_BLUETOOTH',
      'ohos.permission.DISCOVER_BLUETOOTH'
    ];

    try {
      const atManager = abilityAccessCtrl.createAtManager();

      // 检查每个权限
      for (const permission of permissions) {
        const grantStatus = await atManager.checkAccessToken(
          this.context.applicationInfo.accessTokenId,
          permission
        );

        if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
          // 权限未授予,请求用户授权
          const requestResult = await atManager.requestPermissionsFromUser(
            this.context,
            [permission]
          );

          if (requestResult.authResults[0] !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
            console.error(`Permission ${permission} denied by user`);
            return false;
          }
        }
      }

      console.info('All BLE permissions granted');
      return true;
    } catch (error) {
      console.error(`Permission request failed: ${error}`);
      return false;
    }
  }

  /**
   * 检查单个权限状态
   */
  public async checkPermission(permission: Permissions): Promise<boolean> {
    if (!this.context) return false;

    try {
      const atManager = abilityAccessCtrl.createAtManager();
      const grantStatus = await atManager.checkAccessToken(
        this.context.applicationInfo.accessTokenId,
        permission
      );
      return grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
    } catch (error) {
      return false;
    }
  }
}

3.4 在 EntryAbility 中初始化(应用启动时设置)

应用启动时,需要把上下文(context)传给权限管理器和蓝牙管理器:

typescript 复制代码
// ====== EntryAbility.ets ======
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { PermissionManager } from '../common/PermissionManager';
import { BLEManager } from '../common/BLEManager';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    console.info('EntryAbility onCreate');
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    // 设置上下文
    PermissionManager.getInstance().setContext(this.context);
    BLEManager.getInstance().setContext(this.context);

    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        console.error('Failed to load content');
        return;
      }
    });
  }
}

四、第三步:蓝牙管理器(核心代码)

这是整个蓝牙功能的核心,包含扫描、连接、收发数据等所有功能。

注意:代码看着很长,但其实是一个完整的工具类,可以直接复制到项目里使用(我很喜欢把管理器放src/main/ets/common里面。

typescript 复制代码
// ====== common/BLEManager.ets ======
// 这个文件放在 entry/src/main/ets/common/ 目录下(

import { ble, access } from '@kit.ConnectivityKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

// ========== 第一部分:定义数据类型 ==========

// 设备信息(扫描到的设备长什么样)
export interface DeviceInfo {
  deviceId: string;   // 设备唯一标识
  name: string;       // 设备名称,比如"小米手环7"
  address: string;    // MAC地址
  rssi: number;       // 信号强度,负数,越大越强(比如-50比-80信号强)
}

// 连接状态(设备当前是什么状态)
export enum ConnectionState {
  DISCONNECTED = 0,    // 已断开
  CONNECTING = 1,      // 连接中...
  CONNECTED = 2,       // 已连接
  DISCONNECTING = 3    // 断开中...
}

// GATT服务(一个设备可能有多个服务)
export interface BLEService {
  serviceUuid: string;           // 服务UUID
  characteristics: BLECharacteristic[];  // 这个服务下的所有特征值
}

// GATT特征值(数据就存在这里)
export interface BLECharacteristic {
  characteristicUuid: string;    // 特征值UUID
  properties: number;            // 属性(可读/可写/可通知)
  descriptors: BLEDescriptor[];  // 描述符列表
}

// GATT描述符
export interface BLEDescriptor {
  descriptorUuid: string;
}

// ========== 第二部分:蓝牙管理器类 ==========

export class BLEManager {
  // 单例模式:确保全局只有一个蓝牙管理器实例
  private static instance: BLEManager;
  
  // 重要变量
  private context?: common.UIAbilityContext;  // 应用上下文
  private gattClient: ble.GattClientDevice | null = null;  // GATT客户端(用来连接设备)
  private isScanning: boolean = false;  // 是否正在扫描
  private connectedDevice: DeviceInfo | null = null;  // 当前连接的设备
  private discoveredServices: BLEService[] = [];  // 发现的服务列表

  // 回调函数(用来通知页面状态变化)
  private onDeviceFoundCallback?: (device: DeviceInfo) => void;  // 发现设备时调用
  private onConnectionStateChangedCallback?: (device: DeviceInfo, state: ConnectionState) => void;  // 连接状态变化时调用
  private onDataReceivedCallback?: (data: ArrayBuffer) => void;  // 收到数据时调用

  private constructor() {}  // 私有构造函数,防止外部直接 new

  // 获取单例(整个应用只用这一个实例)
  public static getInstance(): BLEManager {
    if (!BLEManager.instance) {
      BLEManager.instance = new BLEManager();
    }
    return BLEManager.instance;
  }

  // 设置上下文(在 EntryAbility 里调用)
  public setContext(context: common.UIAbilityContext): void {
    this.context = context;
  }

  // ========== 第三部分:检查蓝牙状态 ==========
  
  // 检查蓝牙是否开启
  public isBluetoothEnabled(): boolean {
    try {
      const state: access.BluetoothState = access.getState();
      return state === access.BluetoothState.STATE_ON;  // STATE_ON 表示已开启
    } catch (error) {
      console.error(`检查蓝牙状态失败: ${error}`);
      return false;
    }
  }

  // ========== 第四部分:设置回调 ==========
  // 这些回调函数会在特定事件发生时被调用,比如发现了新设备、连接成功等
  
  public setOnDeviceFoundCallback(callback: (device: DeviceInfo) => void): void {
    this.onDeviceFoundCallback = callback;
  }

  public setOnConnectionStateChangedCallback(callback: (device: DeviceInfo, state: ConnectionState) => void): void {
    this.onConnectionStateChangedCallback = callback;
  }

  public setOnDataReceivedCallback(callback: (data: ArrayBuffer) => void): void {
    this.onDataReceivedCallback = callback;
  }

  // ========== 第五部分:扫描设备 ==========

  // 开始扫描(扫描附近所有蓝牙设备)
  public startScan(): void {
    // 防止重复扫描
    if (this.isScanning) {
      console.warn('已经在扫描中,不要重复启动');
      return;
    }

    // 检查蓝牙是否开启
    if (!this.isBluetoothEnabled()) {
      console.error('蓝牙未开启,请先开启蓝牙');
      return;
    }

    try {
      // 配置扫描参数
      const scanOptions: ble.ScanOptions = {
        interval: 50,  // 扫描间隔,单位毫秒
        dutyMode: ble.ScanDuty.SCAN_MODE_BALANCED,  // 平衡模式(速度和省电平衡)
        matchMode: ble.MatchMode.MATCH_MODE_AGGRESSIVE  // 积极匹配模式(优先发现设备)
      };

      // 注册扫描结果回调(关键!)
      ble.on('BLEDeviceFind', (data: Array<ble.ScanResult>) => {
        // 每次发现设备,这个函数都会被调用
        data.forEach((result: ble.ScanResult) => {
          // 把扫描结果转换成我们定义的格式
          const device: DeviceInfo = {
            deviceId: result.deviceId,
            name: result.deviceName || '未知设备',  // 有些设备没有名字
            address: result.deviceId,
            rssi: result.rssi  // 信号强度
          };

          // 通知页面"发现了新设备"
          if (this.onDeviceFoundCallback) {
            this.onDeviceFoundCallback(device);
          }
        });
      });

      // 开始扫描(空过滤器表示扫描所有设备)
      const scanFilters: ble.ScanFilter[] = [{}];
      ble.startBLEScan(scanFilters, scanOptions);
      this.isScanning = true;
      console.info('✅ 开始扫描蓝牙设备');
    } catch (error) {
      console.error(`启动扫描失败: ${error}`);
    }
  }

  // 带设备名过滤的扫描
  public startScanWithFilter(deviceName: string): void {
    if (this.isScanning) return;
    if (!this.isBluetoothEnabled()) return;

    try {
      const scanOptions: ble.ScanOptions = {
        interval: 50,
        dutyMode: ble.ScanDuty.SCAN_MODE_BALANCED,
        matchMode: ble.MatchMode.MATCH_MODE_AGGRESSIVE
      };

      ble.on('BLEDeviceFind', (data: Array<ble.ScanResult>) => {
        data.forEach((result: ble.ScanResult) => {
          // 过滤设备名
          if (result.deviceName && result.deviceName.includes(deviceName)) {
            const device: DeviceInfo = {
              deviceId: result.deviceId,
              name: result.deviceName,
              address: result.deviceId,
              rssi: result.rssi
            };

            if (this.onDeviceFoundCallback) {
              this.onDeviceFoundCallback(device);
            }
          }
        });
      });

      const scanFilters: ble.ScanFilter[] = [{}];
      ble.startBLEScan(scanFilters, scanOptions);
      this.isScanning = true;
    } catch (error) {
      console.error(`Start scan with filter failed: ${error}`);
    }
  }

  // 停止扫描
  public stopScan(): void {
    if (!this.isScanning) return;

    try {
      ble.stopBLEScan();
      ble.off('BLEDeviceFind');
      this.isScanning = false;
      console.info('BLE scan stopped');
    } catch (error) {
      console.error(`Stop scan failed: ${error}`);
    }
  }

  // 连接设备
  public async connectDevice(device: DeviceInfo): Promise<boolean> {
    try {
      if (this.gattClient) {
        this.gattClient.close();
      }

      this.gattClient = ble.createGattClientDevice(device.deviceId);

      // 监听连接状态
      this.gattClient.on('BLEConnectionStateChange', (state: ble.BLEConnectionChangeState) => {
        let connectionState: ConnectionState;
        switch (state.state) {
          case 0:
            connectionState = ConnectionState.DISCONNECTED;
            this.connectedDevice = null;
            break;
          case 1:
            connectionState = ConnectionState.CONNECTING;
            break;
          case 2:
            connectionState = ConnectionState.CONNECTED;
            this.connectedDevice = device;
            this.discoverServices();
            break;
          case 3:
            connectionState = ConnectionState.DISCONNECTING;
            break;
          default:
            connectionState = ConnectionState.DISCONNECTED;
        }

        if (this.onConnectionStateChangedCallback) {
          this.onConnectionStateChangedCallback(device, connectionState);
        }
      });

      await this.gattClient.connect();
      return true;
    } catch (error) {
      console.error(`Connect failed: ${error}`);
      return false;
    }
  }

  // 服务发现
  private async discoverServices(): Promise<void> {
    if (!this.gattClient) return;

    try {
      const services = await this.gattClient.getServices();

      this.discoveredServices = services.map((service: ble.GattService): BLEService => ({
        serviceUuid: service.serviceUuid,
        characteristics: service.characteristics.map((char: ble.BLECharacteristic): BLECharacteristic => ({
          characteristicUuid: char.characteristicUuid,
          properties: 0,
          descriptors: char.descriptors.map((desc: ble.BLEDescriptor): BLEDescriptor => ({
            descriptorUuid: desc.descriptorUuid
          }))
        }))
      }));

      console.info(`Discovered ${services.length} services`);

      // 设置特征值通知
      await this.setupNotification();
    } catch (error) {
      console.error(`Discover services failed: ${error}`);
    }
  }

  // 设置特征值通知
  private async setupNotification(): Promise<void> {
    if (!this.gattClient) return;

    try {
      // 监听特征值变化
      this.gattClient.on('BLECharacteristicChange', (char: ble.BLECharacteristic) => {
        if (this.onDataReceivedCallback && char.characteristicValue) {
          this.onDataReceivedCallback(char.characteristicValue);
        }
      });

      // 查找目标服务并启用通知
      for (const service of this.discoveredServices) {
        for (const char of service.characteristics) {
          const characteristic: ble.BLECharacteristic = {
            serviceUuid: service.serviceUuid,
            characteristicUuid: char.characteristicUuid,
            characteristicValue: new ArrayBuffer(0),
            descriptors: []
          };

          await this.gattClient.setCharacteristicChangeNotification(characteristic, true);
        }
      }
    } catch (error) {
      console.error(`Setup notification failed: ${error}`);
    }
  }

  // 读取特征值
  public async readCharacteristic(serviceUuid: string, charUuid: string): Promise<ArrayBuffer | null> {
    if (!this.gattClient) return null;

    try {
      const characteristic: ble.BLECharacteristic = {
        serviceUuid: serviceUuid,
        characteristicUuid: charUuid,
        characteristicValue: new ArrayBuffer(0),
        descriptors: []
      };

      const result = await this.gattClient.readCharacteristicValue(characteristic);
      return result.characteristicValue;
    } catch (error) {
      console.error(`Read characteristic failed: ${error}`);
      return null;
    }
  }

  // 写入特征值
  public async writeCharacteristic(serviceUuid: string, charUuid: string, data: ArrayBuffer): Promise<boolean> {
    if (!this.gattClient) return false;

    try {
      const characteristic: ble.BLECharacteristic = {
        serviceUuid: serviceUuid,
        characteristicUuid: charUuid,
        characteristicValue: data,
        descriptors: []
      };

      // 双重写入策略
      try {
        await this.gattClient.writeCharacteristicValue(characteristic, ble.GattWriteType.WRITE);
        return true;
      } catch (writeError) {
        await this.gattClient.writeCharacteristicValue(characteristic, ble.GattWriteType.WRITE_NO_RESPONSE);
        return true;
      }
    } catch (error) {
      console.error(`Write characteristic failed: ${error}`);
      return false;
    }
  }

  // 断开连接
  public async disconnect(): Promise<void> {
    if (this.gattClient) {
      try {
        await this.gattClient.disconnect();
        this.gattClient.close();
        this.gattClient = null;
        this.connectedDevice = null;
        this.discoveredServices = [];
      } catch (error) {
        console.error(`Disconnect failed: ${error}`);
      }
    }
  }

  // Getter 方法
  public getIsScanning(): boolean {
    return this.isScanning;
  }

  public getConnectedDevice(): DeviceInfo | null {
    return this.connectedDevice;
  }

  public getDiscoveredServices(): BLEService[] {
    return this.discoveredServices;
  }
}

五、第四步:UI 界面开发(复制即用)

下面是完整的设备卡片和设备列表页面,可以直接复制到你的项目中。

5.1 设备卡片组件(显示每个扫描到的设备)

typescript 复制代码
// ====== pages/ConnectionPage.ets ======
import { DeviceInfo, ConnectionState } from '../common/BLEManager';

@Component
struct DeviceCard {
  @Prop device: DeviceInfo;
  @Prop isConnected: boolean = false;
  onConnect?: () => void;

  // 根据信号强度获取颜色
  private getSignalColor(rssi: number): string {
    if (rssi >= -50) return '#4CAF50';      // 优秀 - 绿色
    if (rssi >= -70) return '#FF9800';      // 良好 - 橙色
    return '#FF5722';                        // 较弱 - 红色
  }

  build() {
    Row() {
      // 左侧:设备信息
      Column() {
        Text(this.device.name)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor(this.isConnected ? '#2196F3' : '#333333')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Row() {
          Text(this.device.address)
            .fontSize(12)
            .fontColor('#999999')
            .layoutWeight(1)

          Text(this.isConnected ? '已连接' : '未连接')
            .fontSize(12)
            .fontColor(this.isConnected ? '#4CAF50' : '#999999')
            .margin({ left: 8 })
        }
        .width('100%')
        .margin({ top: 4 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)

      // 右侧:信号强度
      Column() {
        Text(`${this.device.rssi}`)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.getSignalColor(this.device.rssi))

        Text('dBm')
          .fontSize(12)
          .fontColor('#999999')
      }
      .width(60)
      .alignItems(HorizontalAlign.Center)

      // 连接按钮
      Button(this.isConnected ? '断开' : '连接')
        .fontSize(14)
        .backgroundColor(this.isConnected ? '#FF5722' : '#2196F3')
        .fontColor(Color.White)
        .height(36)
        .width(70)
        .margin({ left: 12 })
        .onClick(() => {
          if (this.onConnect) {
            this.onConnect();
          }
        })
    }
    .width('100%')
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius(12)
    .shadow({ radius: 4, color: 'rgba(0,0,0,0.1)', offsetY: 2 })
  }
}

5.2 设备列表页面(主页面,显示所有扫描到的设备)

typescript 复制代码
// ====== pages/ConnectionPage.ets ======
// 这是蓝牙设备列表页面,显示扫描到的所有设备

import { BLEManager, DeviceInfo, ConnectionState } from '../common/BLEManager';
import { PermissionManager } from '../common/PermissionManager';
import { promptAction } from '@kit.ArkUI';

@Entry
@Component
struct ConnectionPage {
  @State discoveredDevices: DeviceInfo[] = [];  // 发现的设备列表
  @State isScanning: boolean = false;  // 是否正在扫描
  @State connectedDevice: DeviceInfo | null = null;  // 当前连接的设备

  private bleManager: BLEManager = BLEManager.getInstance();

  // 页面加载时调用
  aboutToAppear(): void {
    // 设置设备发现回调(发现新设备时添加到列表)
    this.bleManager.setOnDeviceFoundCallback((device: DeviceInfo) => {
      // 去重:如果已经存在就不加了
      const exists = this.discoveredDevices.find(d => d.deviceId === device.deviceId);
      if (!exists) {
        this.discoveredDevices = [...this.discoveredDevices, device];
      }
    });

    // 设置连接状态回调(连接成功/断开时提示用户)
    this.bleManager.setOnConnectionStateChangedCallback((device: DeviceInfo, state: ConnectionState) => {
      if (state === ConnectionState.CONNECTED) {
        this.connectedDevice = device;
        promptAction.showToast({ message: `✅ 已连接: ${device.name}` });
      } else if (state === ConnectionState.DISCONNECTED) {
        this.connectedDevice = null;
        promptAction.showToast({ message: '设备已断开' });
      }
    });
  }

  // 页面销毁时调用(清理资源)
  aboutToDisappear(): void {
    this.bleManager.stopScan();
  }

  // 开始扫描(带权限检查)
  private async startScan(): Promise<void> {
    // 第一步:检查并申请权限
    const hasPermission = await PermissionManager.getInstance().checkAndRequestBlePermissions();
    if (!hasPermission) {
      promptAction.showToast({ message: '请授予蓝牙权限后重试' });
      return;
    }

    // 第二步:检查蓝牙是否开启
    if (!this.bleManager.isBluetoothEnabled()) {
      promptAction.showToast({ message: '请先开启蓝牙' });
      return;
    }

    // 第三步:清空之前的设备列表,开始扫描
    this.discoveredDevices = [];
    this.bleManager.startScan();
    this.isScanning = true;

    // 10秒后自动停止扫描(防止一直扫描耗电)
    setTimeout(() => {
      this.stopScan();
    }, 10000);
  }

  private stopScan(): void {
    this.bleManager.stopScan();
    this.isScanning = false;
  }

  private async onDeviceClick(device: DeviceInfo): Promise<void> {
    if (this.connectedDevice?.deviceId === device.deviceId) {
      // 已连接,断开
      await this.bleManager.disconnect();
    } else {
      // 未连接,连接
      this.stopScan();
      await this.bleManager.connectDevice(device);
    }
  }

  build() {
    Column() {
      // 标题栏
      Row() {
        Text('蓝牙设备')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)

        Blank()

        Button(this.isScanning ? '停止扫描' : '开始扫描')
          .fontSize(14)
          .backgroundColor(this.isScanning ? '#FF5722' : '#2196F3')
          .onClick(() => {
            if (this.isScanning) {
              this.stopScan();
            } else {
              this.startScan();
            }
          })
      }
      .width('100%')
      .padding(16)

      // 扫描状态
      if (this.isScanning) {
        Row() {
          LoadingProgress()
            .width(20)
            .height(20)
            .color('#2196F3')

          Text('正在扫描设备...')
            .fontSize(14)
            .fontColor('#666666')
            .margin({ left: 8 })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
        .padding(16)
      }

      // 设备列表
      if (this.discoveredDevices.length === 0) {
        Column() {
          Text('📡')
            .fontSize(48)
            .margin({ bottom: 16 })

          Text(this.isScanning ? '正在搜索设备...' : '未发现设备')
            .fontSize(16)
            .fontColor('#999999')

          if (!this.isScanning) {
            Text('点击"开始扫描"按钮搜索')
              .fontSize(14)
              .fontColor('#CCCCCC')
              .margin({ top: 8 })
          }
        }
        .width('100%')
        .height(200)
        .justifyContent(FlexAlign.Center)
      } else {
        List({ space: 12 }) {
          ForEach(this.discoveredDevices, (device: DeviceInfo) => {
            ListItem() {
              DeviceCard({
                device: device,
                isConnected: this.connectedDevice?.deviceId === device.deviceId,
                onConnect: () => this.onDeviceClick(device)
              })
            }
          }, (device: DeviceInfo) => device.deviceId)
        }
        .width('100%')
        .layoutWeight(1)
        .padding({ left: 16, right: 16 })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

5.3 服务/UUID 卡片组件(显示设备提供的服务和特征值)

typescript 复制代码
// ====== pages/DeviceDetailPage.ets ======
import { BLEManager, BLEService, BLECharacteristic } from '../common/BLEManager';
import { router } from '@kit.ArkUI';

@Entry
@Component
struct DeviceDetailPage {
  @State services: BLEService[] = [];
  @State selectedServiceUuid: string = '';
  @State selectedCharUuid: string = '';

  private bleManager: BLEManager = BLEManager.getInstance();

  aboutToAppear(): void {
    this.services = this.bleManager.getDiscoveredServices();
  }

  // 导入UUID到设置页面
  private importUUID(serviceUuid: string, charUuid: string): void {
    router.pushUrl({
      url: 'pages/SettingsPage',
      params: {
        importServiceUUID: serviceUuid,
        importCharacteristicUUID: charUuid
      }
    });
  }

  @Builder
  ServiceCard(service: BLEService, index: number) {
    Column() {
      // 服务标题
      Row() {
        Text(`服务 ${index + 1}`)
          .fontSize(14)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333')

        Blank()

        Text('GATT Service')
          .fontSize(12)
          .fontColor('#2196F3')
          .backgroundColor('rgba(33, 150, 243, 0.1)')
          .padding({ left: 8, right: 8, top: 2, bottom: 2 })
          .borderRadius(4)
      }
      .width('100%')
      .margin({ bottom: 8 })

      // 服务 UUID
      Text(service.serviceUuid)
        .fontSize(12)
        .fontColor('#666666')
        .width('100%')
        .margin({ bottom: 12 })

      // 特征值列表
      if (service.characteristics.length > 0) {
        Text('特征值列表')
          .fontSize(13)
          .fontColor('#999999')
          .width('100%')
          .margin({ bottom: 8 })

        ForEach(service.characteristics, (char: BLECharacteristic, charIndex: number) => {
          Row() {
            Column() {
              Text(`特征 ${charIndex + 1}`)
                .fontSize(12)
                .fontColor('#333333')

              Text(char.characteristicUuid)
                .fontSize(11)
                .fontColor('#999999')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Button('导入')
              .fontSize(12)
              .height(28)
              .backgroundColor('#2196F3')
              .fontColor(Color.White)
              .onClick(() => {
                this.importUUID(service.serviceUuid, char.characteristicUuid);
              })
          }
          .width('100%')
          .padding(8)
          .backgroundColor('#F5F5F5')
          .borderRadius(4)
          .margin({ bottom: 4 })
        }, (char: BLECharacteristic) => char.characteristicUuid)
      }
    }
    .width('100%')
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius(12)
    .shadow({ radius: 2, color: 'rgba(0,0,0,0.1)', offsetY: 1 })
  }

  build() {
    Column() {
      // 标题栏
      Row() {
        Button('返回')
          .backgroundColor(Color.Transparent)
          .fontColor('#2196F3')
          .onClick(() => router.back())

        Text('设备详情')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)

        Text('')
          .width(60)
      }
      .width('100%')
      .padding(16)

      // 服务列表
      if (this.services.length === 0) {
        Column() {
          Text('未发现服务')
            .fontSize(16)
            .fontColor('#999999')
        }
        .width('100%')
        .height(200)
        .justifyContent(FlexAlign.Center)
      } else {
        Scroll() {
          Column({ space: 12 }) {
            ForEach(this.services, (service: BLEService, index: number) => {
              this.ServiceCard(service, index)
            }, (service: BLEService) => service.serviceUuid)
          }
          .width('100%')
          .padding(16)
        }
        .layoutWeight(1)
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

5.4 UUID 配置页面

typescript 复制代码
// ====== pages/SettingsPage.ets ======
import { router } from '@kit.ArkUI';

@Entry
@Component
struct SettingsPage {
  @State serviceUUID: string = '';
  @State characteristicUUID: string = '';
  @State filterEnabled: boolean = false;
  @State filterDeviceName: string = '';

  aboutToAppear(): void {
    // 接收导入的 UUID
    const params = router.getParams() as Record<string, string>;
    if (params) {
      if (params.importServiceUUID) {
        this.serviceUUID = params.importServiceUUID;
      }
      if (params.importCharacteristicUUID) {
        this.characteristicUUID = params.importCharacteristicUUID;
      }
    }
  }

  @Builder
  UUIDConfigSection() {
    Column() {
      Text('UUID 配置')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .alignSelf(ItemAlign.Start)
        .margin({ bottom: 16 })

      // 服务 UUID
      Row() {
        Text('服务UUID')
          .fontSize(14)
          .fontColor('#666666')
          .width(80)

        TextInput({ placeholder: '请输入服务UUID', text: this.serviceUUID })
          .layoutWeight(1)
          .margin({ left: 12, right: 12 })
          .onChange((value: string) => {
            this.serviceUUID = value;
          })

        Button('重置')
          .fontSize(12)
          .backgroundColor('#F5F5F5')
          .fontColor('#2196F3')
          .width(60)
          .height(32)
          .onClick(() => {
            this.serviceUUID = '';
          })
      }
      .width('100%')
      .margin({ bottom: 12 })

      // 特征值 UUID
      Row() {
        Text('特征值UUID')
          .fontSize(14)
          .fontColor('#666666')
          .width(80)

        TextInput({ placeholder: '请输入特征值UUID', text: this.characteristicUUID })
          .layoutWeight(1)
          .margin({ left: 12, right: 12 })
          .onChange((value: string) => {
            this.characteristicUUID = value;
          })

        Button('重置')
          .fontSize(12)
          .backgroundColor('#F5F5F5')
          .fontColor('#2196F3')
          .width(60)
          .height(32)
          .onClick(() => {
            this.characteristicUUID = '';
          })
      }
      .width('100%')
      .margin({ bottom: 8 })

      Text('UUID格式:0000ffe1-0000-1000-8000-00805f9b34fb')
        .fontSize(12)
        .fontColor('#999999')
        .alignSelf(ItemAlign.Start)
    }
    .width('100%')
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius(12)
  }

  @Builder
  DeviceFilterSection() {
    Column() {
      Text('设备过滤器')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .alignSelf(ItemAlign.Start)
        .margin({ bottom: 16 })

      // 过滤器开关
      Row() {
        Text('启用设备名过滤')
          .fontSize(14)
          .fontColor('#666666')
          .layoutWeight(1)

        Toggle({ type: ToggleType.Switch, isOn: this.filterEnabled })
          .onChange((isOn: boolean) => {
            this.filterEnabled = isOn;
          })
      }
      .width('100%')
      .margin({ bottom: 12 })

      // 设备名输入
      if (this.filterEnabled) {
        TextInput({ placeholder: '请输入设备名关键词', text: this.filterDeviceName })
          .width('100%')
          .onChange((value: string) => {
            this.filterDeviceName = value;
          })
      }
    }
    .width('100%')
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius(12)
  }

  build() {
    Column() {
      // 标题栏
      Row() {
        Button('返回')
          .backgroundColor(Color.Transparent)
          .fontColor('#2196F3')
          .onClick(() => router.back())

        Text('设置')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)

        Button('保存')
          .backgroundColor(Color.Transparent)
          .fontColor('#2196F3')
          .onClick(() => {
            // 保存配置逻辑
            router.back();
          })
      }
      .width('100%')
      .padding(16)

      Scroll() {
        Column({ space: 16 }) {
          this.UUIDConfigSection()
          this.DeviceFilterSection()
        }
        .width('100%')
        .padding(16)
      }
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

六、常见 UUID 参考(查文档时常用)

什么是 UUID? UUID 是服务和特征值的唯一标识,就像身份证号码一样。每个蓝牙设备都有自己的 UUID。

服务名称 UUID 用途说明
电池服务 0000180F-0000-1000-8000-00805F9B34FB 读取电池电量
设备信息 0000180A-0000-1000-8000-00805F9B34FB 读取设备名称、厂商等
心率服务 0000180D-0000-1000-8000-00805F9B34FB 读取心率数据
通用访问 00001800-0000-1000-8000-00805F9B34FB 设备名称、外观等
客户端配置描述符 00002902-0000-1000-8000-00805F9B34FB 启用通知时需要写入

💡 小贴士 :不知道设备的 UUID?可以先连接设备,然后调用 getServices() 查看所有服务的 UUID。


七、最佳实践(踩坑总结)

7.1 必须遵守的规则

  1. 权限先行:扫描前必须检查并申请权限,否则扫描会静默失败
  2. 检查蓝牙状态:扫描前要检查蓝牙是否开启
  3. 资源释放:页面销毁时停止扫描、断开连接,否则会内存泄漏
  4. 状态同步:使用回调机制同步 UI 状态

7.2 常见问题排查

问题 可能原因 解决方法
扫描不到设备 权限未授予 检查应用设置里的权限
扫描不到设备 蓝牙未开启 下拉状态栏检查蓝牙开关
扫描不到设备 设备没在广播 确认设备处于可发现状态
连接失败 设备已被其他手机连接 断开其他连接后重试
读写失败 UUID 不对 检查服务UUID和特征值UUID
读写失败 没有读/写权限 检查特征值的 properties

7.3 写入数据的技巧

typescript 复制代码
// 双重写入策略:先尝试 WRITE,失败后尝试 WRITE_NO_RESPONSE
public async sendData(serviceId: string, charId: string, data: ArrayBuffer): Promise<boolean> {
  try {
    // 第一次尝试:带响应的写入(更可靠)
    await this.gattClient?.writeCharacteristicValue(char, ble.GattWriteType.WRITE);
    return true;
  } catch (error) {
    try {
      // 第二次尝试:不带响应的写入(更快但不确认)
      await this.gattClient?.writeCharacteristicValue(char, ble.GattWriteType.WRITE_NO_RESPONSE);
      return true;
    } catch (e) {
      return false;
    }
  }
}

为什么要双重写入? 因为不同设备支持的写入方式不同,有的只支持带响应,有的只支持不带响应。


八、学习路径建议

初学者建议按这个顺序学习:

  1. 先跑通权限 - 能成功弹出权限申请弹窗
  2. 再跑通扫描 - 能扫描到蓝牙设备
  3. 然后跑通连接 - 能连接到设备
  4. 最后跑通读写 - 能收发数据

学习资源


九、常见问题 FAQ

Q1: 为什么扫描不到任何设备?

按这个顺序检查:

  1. 应用设置 → 权限 → 蓝牙权限是否开启
  2. 下拉状态栏 → 蓝牙是否开启
  3. 设备是否在广播模式(可被发现)
  4. 设备是否已被其他手机连接

Q2: 连接成功但读写失败?

可能原因:

  1. UUID 不对 - 检查服务UUID和特征值UUID
  2. 没有具备读写权限 - 检查特征值的 properties
  3. 需要先启用通知 - 调用 setCharacteristicChangeNotification

Q3: 如何知道设备的 UUID?

两种方法:

  1. 查看设备说明书或压商文档
  2. 先连接设备,调用 getServices() 查看所有服务

  • 作者声明:个人拙作,仅总结了本人开发经验,专业性指导请参考官方文档,欢迎各位大佬斧正。本文档不是最简蓝牙调试工具开发文档,代码截取自成熟应用,欢迎下载易管闪联(星闪端,蓝牙端正在突破3.5)体验。
相关推荐
禅思院2 小时前
在win10上配置 Rust以及修改默认位置问题
开发语言·前端·后端·rust·cargo·mingw64·cargo安装位置
武子康2 小时前
大数据-188 Logstash Output 插件实战:stdout/file/Elasticsearch 输出配置与调优
大数据·后端·logstash
我命由我123452 小时前
Python Flask 开发问题:ImportError: cannot import name ‘escape‘ from ‘flask‘
服务器·开发语言·后端·python·flask·学习方法·python3.11
爱吃烤鸡翅的酸菜鱼3 小时前
Spring Boot 注解全栈指南:涵盖 Bean 注册、配置加载、请求映射、事务控制、数据校验等一网打尽
java·开发语言·spring boot·后端·spring
running up3 小时前
Spring IOC与DI核心注解速查表
java·后端·spring
洛阳泰山3 小时前
快速上手 MaxKB4J:开源企业级 Agentic 工作流系统在 Sealos 上的完整部署指南
java·人工智能·后端
bybitq3 小时前
string,byte,rune,character?详解Golang编码-UTF-8
开发语言·后端·golang
无限进步_3 小时前
【C语言】栈(Stack)数据结构的实现与应用
c语言·开发语言·数据结构·c++·后端·visual studio
czlczl200209253 小时前
Spring Boot + Redis :如何设计“登出”功能
spring boot·redis·后端