大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~

前言

——HarmonyOS 蓝牙(BLE)开发从权限到 IoT 全流程实战

说句心里话,BLE 这种东西真是典型的:概念不难、细节够你崩溃
你可能已经经历过这些场景:

  • 扫描列表里一个设备都没有,以为硬件坏了,结果是——权限少配了一个。
  • 连接老是失败,有时候能连、有时候不能连,像掷骰子。
  • 写入特征值控制设备时,明明代码执行成功,但设备就是不鸟你。
  • 开了 Notify 却收不到任何回调,最后发现 UUID 写错一位。

所以这篇咱们就别玩虚的,按你给的大纲,一条线把鸿蒙 BLE 开发从 权限 → 扫描 / 连接 → 读写特征值 → Notify 通信 → 常见坑 → IoT 接入示例 串起来。
代码以 ArkTS + HarmonyOS Stage 模型 为基础,API 名字有版本差异的地方我都会提示“以实际 SDK 为准”,你照着思路落地就行。

一、蓝牙权限:80% 的“啥都扫不到”问题都死在这

先把最容易忽略但最致命的说清楚:权限

1.1 BLE 相关常用权限

典型 BLE 应用(扫描 + 连接 + 收发数据)通常会用到这几类权限:

  • 基础蓝牙能力

    • ohos.permission.BLUETOOTH
    • ohos.permission.MANAGE_BLUETOOTH(有的版本类似 USE_BLUETOOTH / MANAGE_BLUETOOTH
  • 定位权限(非常关键)

    • ohos.permission.LOCATION

为啥要定位?因为像 Android 一样,系统认为“通过 BLE 扫描附近设备,本质上可以推断用户位置”,所以必须要定位权限支持,否则你可能 一点设备都扫不到

如果你后面还要做:

  • 记录连接记录
  • 访问系统存储保存绑定信息

那还可能涉及媒体 / 存储权限,这里先不展开。


1.2 静态权限:module.json5 里必须先写一遍

entry/src/main/module.json5 里把权限写上,不写直接用 BLE API,基本是白搭:

{
  "module": {
    // ...
    "requestPermissions": [
      {
        "name": "ohos.permission.BLUETOOTH"
      },
      {
        "name": "ohos.permission.MANAGE_BLUETOOTH"
      },
      {
        "name": "ohos.permission.LOCATION"
      }
    ]
  }
}

这一步只是告诉系统和审核:

“我这个 App 需要这些能力。”

系统并不会因此默认授权给你,还得来一遍动态申请。


1.3 动态权限:真正决定“能不能用”的一票

鸿蒙的动态权限流程通过 abilityAccessCtrl 模块来做。建议你封装一个通用方法,别在每个页面重复写。

import abilityAccessCtrl from '@ohos.abilityAccessCtrl';

async function requestSinglePermission(context, permission: string): Promise<boolean> {
  const atManager = abilityAccessCtrl.createAtManager();

  const grantStatus = await atManager.checkAccessToken(
    context.tokenId,
    permission
  );

  if (grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
    return true;
  }

  const result = await atManager.requestPermissionsFromUser(context, [permission]);
  return result.authResults[0] === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
}

export async function ensureBlePermissions(context): Promise<boolean> {
  const list = [
    'ohos.permission.BLUETOOTH',
    'ohos.permission.MANAGE_BLUETOOTH',
    'ohos.permission.LOCATION'
  ];

  for (const p of list) {
    const ok = await requestSinglePermission(context, p);
    if (!ok) {
      console.error(`BLE 权限被拒绝:${p}`);
      return false;
    }
  }
  return true;
}

实战建议:

  • 不要 App 一启动就弹一堆权限。
  • 在用户点击“连接设备”“扫描设备”之类入口时再申请,顺便解释一句“为了搜索附近蓝牙设备,需要蓝牙及定位权限”。
  • 如果用户拒绝,给出明确提示(比如引导去系统设置开启)。

二、蓝牙扫描 / 连接:从“看见设备”到“握手成功”

搞定权限之后,才轮到 BLE 的主戏:扫描、发现设备、连接、建立 GATT 会话

下面代码用的是类似 @ohos.bluetooth / @ohos.bluetooth.ble 这类模块的典型风格(具体命名以你当前 SDK 为准,但流程是一样的)。

2.1 初始化 BLE 客户端

建议封装一个 BleClientBleManager 类,把所有扫描 / 连接逻辑集中管理,不要到处散落。

import ble from '@ohos.bluetooth.ble'; // 名称示意

export class BleManager {
  private gattClient: any | null = null;
  private scanning: boolean = false;

  constructor() {
    this.init();
  }

  private init() {
    if (!this.gattClient) {
      this.gattClient = ble.createGattClient();
    }
  }
}

你可以在:

  • MainAbilityonCreate / onForeground 时创建
  • 或进入某个 BLE 页面时按需创建

记得生命周期:不用的时候要适当释放或断开连接。


2.2 扫描附近设备(Scan)

扫描的目标:不断收到“发现设备”的回调,然后按条件过滤,展示给用户选择

type DeviceFoundCallback = (device: {
  deviceId: string;
  deviceName: string;
  rssi: number;
  // ... 其它字段看 SDK
}) => void;

startScan(onDeviceFound: DeviceFoundCallback) {
  if (this.scanning) return;
  this.scanning = true;

  // 监听扫描结果
  this.gattClient.on('scanResult', (result) => {
    // 常见字段:deviceId, deviceName, rssi, serviceUuids 等
    console.info(`发现设备:${result.deviceName} (${result.deviceId}) rssi=${result.rssi}`);
    onDeviceFound(result);
  });

  // 启动扫描,可带过滤条件
  this.gattClient.startScan({
    // serviceUuids: ['your-service-uuid'], // 如果你只关心特定服务,可以过滤一下
    interval: 1000
  });
}

stopScan() {
  if (!this.scanning) return;
  this.scanning = false;
  this.gattClient.stopScan();
  this.gattClient.off('scanResult'); // 移除监听,避免泄露
}

实战经验:

  • BLE 设备一般是定期广播的,扫不到时先确认硬件是不是处在广播模式(很多设备需要按实体按钮才能进入配对 / 广播)。
  • 建议扫描加个时长上限(例如 15 秒),结束时给提示:“未发现设备,请靠近设备或检查设备是否已进入配对模式”。

2.3 连接指定设备(Connect)

一般流程是:

  1. 扫描列表展示给用户
  2. 用户点选某个设备
  3. deviceId 建立 GATT 连接
connect(deviceId: string): Promise<void> {
  return new Promise((resolve, reject) => {
    this.gattClient.connect(deviceId, (err, data) => {
      if (err) {
        console.error('连接设备失败:', JSON.stringify(err));
        reject(err);
        return;
      }
      console.info('连接成功:', JSON.stringify(data));
      resolve();
    });
  });
}

disconnect(deviceId: string) {
  if (!this.gattClient) return;
  this.gattClient.disconnect(deviceId);
}

连接成功之后,别急着以为完事了:
下一步才是重点——发现服务(Service)和特征值(Characteristic)


2.4 发现服务(Discover Services)

连接成功只是“建立了管道”,真正的功能还在每个 Service / Characteristic 里。

discoverServices(deviceId: string): Promise<any[]> {
  return new Promise((resolve, reject) => {
    this.gattClient.discoverServices(deviceId, (err, services) => {
      if (err) {
        console.error('发现服务失败:', JSON.stringify(err));
        reject(err);
        return;
      }
      console.info('发现服务:', JSON.stringify(services));
      resolve(services);
    });
  });
}

你通常会从硬件文档拿到这些信息,例如:

  • Service UUID: 0000FFF0-0000-1000-8000-00805F9B34FB
  • Write Characteristic UUID: 0000FFF1-0000-1000-8000-00805F9B34FB
  • Notify Characteristic UUID: 0000FFF2-0000-1000-8000-00805F9B34FB

一定要先跟硬件同学确认,别自己瞎猜 UUID,不然你会在“为什么写入没反应”这个问题上耗掉半天人生。


三、读取 & 写入特征值:真正“控制设备”的地方

BLE 的核心数据交互靠的是 Characteristic(特征值),你可以把它想象成一个“带属性的小变量”:

  • 有读权限(Read)
  • 有写权限(Write / Write Without Response)
  • 有通知权限(Notify / Indicate)

3.1 读取特征值(Read Characteristic)

readCharacteristic(deviceId: string, serviceUuid: string, charUuid: string): Promise<ArrayBuffer> {
  return new Promise((resolve, reject) => {
    this.gattClient.readCharacteristicValue(
      deviceId,
      serviceUuid,
      charUuid,
      (err, data) => {
        if (err) {
          console.error('读取特征值失败:', JSON.stringify(err));
          reject(err);
          return;
        }
        console.info('读取特征值成功:', JSON.stringify(data));
        // data.value 通常是 ArrayBuffer
        resolve(data.value);
      }
    );
  });
}

拿到的 ArrayBuffer 一般需要按协议解析,比如:

function parseTemperature(buf: ArrayBuffer): number {
  const view = new DataView(buf);
  // 假设第 0 字节为有符号温度值
  return view.getInt8(0);
}

3.2 写入特征值(Write Characteristic)

写入是控制类设备(灯泡、插座、风扇、空调面板等)最常用的操作。

writeCharacteristic(
  deviceId: string,
  serviceUuid: string,
  charUuid: string,
  payload: Uint8Array
): Promise<void> {
  return new Promise((resolve, reject) => {
    this.gattClient.writeCharacteristicValue(
      deviceId,
      serviceUuid,
      charUuid,
      payload.buffer,
      (err, data) => {
        if (err) {
          console.error('写入特征值失败:', JSON.stringify(err));
          reject(err);
          return;
        }
        console.info('写入特征值成功:', JSON.stringify(data));
        resolve();
      }
    );
  });
}

构造 payload 的方式完全取决于硬件协议,比如:

// 示例协议:0xA0 0x01 0x01 表示打开设备
const turnOnCmd = new Uint8Array([0xA0, 0x01, 0x01]);
await bleManager.writeCharacteristic(deviceId, SERVICE_UUID, WRITE_CHAR_UUID, turnOnCmd);

务必牢记:

  • 写入前确认该特征值支持写(硬件给的文档会标明属性)。
  • 有的设备只支持 “Write Without Response”,这时不要期望有写入回调数据,从 Notify 里看结果就行。
  • 写入频率不要太高,尤其是控制类命令,建议加防抖或队列。

四、BLE Notify 通信:设备主动推数据的“心跳线”

轮询读 特征值既浪费电又不优雅,BLE 的精华在于 Notify / Indicate:设备主动往手机推数据。

常见场景:

  • IoT 传感器定时上报温湿度、电量等信息
  • 状态型设备(门锁开合、灯亮灭状态变化)主动推新状态

4.1 开启 Notify(订阅特征值通知)

一般需要两步:

  1. 调用 API 告诉系统“我想订阅这个特征值的 Notify”
  2. 有些协议还要求对 Descriptor(比如 CCCD)写特定值启用通知(这一部分一般由 BLE 栈封装好了,必要时硬件同学会说明)
enableNotify(deviceId: string, serviceUuid: string, charUuid: string): Promise<void> {
  return new Promise((resolve, reject) => {
    this.gattClient.setCharacteristicNotification(
      deviceId,
      serviceUuid,
      charUuid,
      true,
      (err) => {
        if (err) {
          console.error('开启 Notify 失败:', JSON.stringify(err));
          reject(err);
          return;
        }
        console.info('开启 Notify 成功');
        resolve();
      }
    );
  });
}

关闭则传 false 即可。


4.2 监听 Notify 回调

建立全局监听,根据 characteristicUuid 路由到不同的处理逻辑:

subscribeNotify(onData: (res: {
  deviceId: string;
  serviceUuid: string;
  characteristicUuid: string;
  value: ArrayBuffer;
}) => void) {
  this.gattClient.on('characteristicChange', (res) => {
    console.info('收到 Notify:', JSON.stringify(res));
    onData(res);
  });
}

unsubscribeNotify() {
  this.gattClient.off('characteristicChange');
}

业务层你可以:

bleManager.subscribeNotify((res) => {
  if (res.characteristicUuid === NOTIFY_STATUS_UUID) {
    const view = new DataView(res.value);
    const on = view.getUint8(0) === 1;
    const brightness = view.getUint8(1);
    console.info(`灯状态: ${on ? '开' : '关'} 亮度: ${brightness}`);
    // 更新 UI 或全局状态
  }
});

非常实用的套路:

  • 把 Notify 当成设备 → App 的“事件总线”
  • 业务逻辑里统一根据 serviceUuid + characteristicUuid 做分发
  • 不要在 UI 组件里直接处理原始二进制数据,最好封装一层 DeviceProtocolParser

五、常见错误处理:出问题别上来就怀疑人生

下面这几个坑,你基本百分百会遇到一两个,提前知道心态会好很多。

5.1 扫描不到设备

可能原因:

  1. 权限不足(尤其是 LOCATION 没开)
  2. 蓝牙没打开 / 用户关闭了系统蓝牙开关
  3. 设备没处于广播状态(需要按实体按键进入配对)
  4. 过滤条件太严格(名称 / serviceUuid 填错)

解决思路:

  • 权限状态、蓝牙开关状态都打日志,必要时给用户 UI 提示。
  • 用第三方 BLE 调试 App 验证设备确实在广播。
  • 初期不要乱加过滤,先把所有设备扫出来确认再慢慢加条件。

5.2 连接失败、不稳定

可能原因:

  1. 设备一次只允许一个连接,上一个连接没断干净。
  2. 距离太远 / 信号干扰,导致连接超时或频繁断开。
  3. 短时间内频繁发起连接 / 断开请求,被设备或系统当成“攻击”了。

建议你做到:

  • 页面退出时记得 disconnect(deviceId)
  • 当连接失败时,可以做有限次重试(例如 2 次),重试间隔拉长一点。
  • UI 上给用户清晰提示“请靠近设备后重试”。

5.3 写入成功但设备没反应

检查这几项:

  1. UUID 是否对应正确的写入特征值(不是 Notify 的那个)。
  2. 该特征值属性是否真的支持写(Read/Write/Notify 是分开的)。
  3. 命令格式是否与硬件协议吻合(头、长度、校验位等等)。
  4. 写入类型是否对(有些需要 Write With Response,有些只接受 Without Response)。

非常推荐的一个习惯:

先用 BLE 调试工具(例如手机上的通用 BLE 工具)验证协议,确认这条命令确实能把设备点亮,再在代码里照着复刻。

不然你总是怀疑自己写错了代码,其实是硬件那边协议说明有坑。


5.4 Notify 没回调

常见原因:

  1. 没有效开启通知(Characteristic 权限不足,或者 Descriptor 没写成功)。
  2. 监听事件写错了(事件名、回调没绑、绑多次)。
  3. 设备压根没往这个特征值发过 Notify。
  4. UUID 一位写错,看起来差不多,实际上完全是另一个值。

排查顺序:

  1. 用调试工具确认:开启 Notify 后是否有数据推送
  2. 对照 UUID 完整字符串,别只看缩写前几位。
  3. 在 App 侧打满日志:Notify 开启是否成功、on('characteristicChange') 是否真的被执行过。

六、IoT 设备接入示例:以“智能灯(Smart Lamp)”为例串一遍全流程

最后,我们用一个完整的聪明小例子,把整条流程串起来——接入一个 BLE 智能灯设备

假设硬件同学给你了一份协议(非常常见的风格):

  • Service UUID: FFF0

  • 写入控制特征:FFF1

  • 状态 Notify 特征:FFF2

  • 命令格式(全部十六进制):

    • 开灯:A0 01 01
    • 关灯:A0 01 00
    • 设置亮度:A1 01 <00-64>(0–100,对应 0x00–0x64)
  • 状态上报格式:

    • Byte0:开关(0x00 关,0x01 开)
    • Byte1:亮度(0–100)

6.1 封装一个 SmartLampBleManager

const SERVICE_UUID = '0000FFF0-0000-1000-8000-00805F9B34FB';
const WRITE_CHAR_UUID = '0000FFF1-0000-1000-8000-00805F9B34FB';
const NOTIFY_CHAR_UUID = '0000FFF2-0000-1000-8000-00805F9B34FB';

class SmartLampBleManager extends BleManager {
  private currentDeviceId: string | null = null;
  onStatusChange?: (on: boolean, brightness: number) => void;

  async connectAndInit(deviceId: string) {
    await this.connect(deviceId);
    this.currentDeviceId = deviceId;

    const services = await this.discoverServices(deviceId);
    console.info('SmartLamp 服务列表:', JSON.stringify(services));

    await this.enableNotify(deviceId, SERVICE_UUID, NOTIFY_CHAR_UUID);

    this.subscribeNotify((res) => {
      if (res.deviceId !== deviceId) return;
      if (res.characteristicUuid !== NOTIFY_CHAR_UUID) return;

      const view = new DataView(res.value);
      const on = view.getUint8(0) === 1;
      const brightness = view.getUint8(1);
      console.info(`灯状态更新:on=${on}, brightness=${brightness}`);
      this.onStatusChange && this.onStatusChange(on, brightness);
    });
  }

  async turnOn() {
    if (!this.currentDeviceId) return;
    const cmd = new Uint8Array([0xA0, 0x01, 0x01]);
    await this.writeCharacteristic(this.currentDeviceId, SERVICE_UUID, WRITE_CHAR_UUID, cmd);
  }

  async turnOff() {
    if (!this.currentDeviceId) return;
    const cmd = new Uint8Array([0xA0, 0x01, 0x00]);
    await this.writeCharacteristic(this.currentDeviceId, SERVICE_UUID, WRITE_CHAR_UUID, cmd);
  }

  async setBrightness(val: number) {
    if (!this.currentDeviceId) return;
    const level = Math.max(0, Math.min(100, val));
    const cmd = new Uint8Array([0xA1, 0x01, level]);
    await this.writeCharacteristic(this.currentDeviceId, SERVICE_UUID, WRITE_CHAR_UUID, cmd);
  }

  dispose() {
    if (this.currentDeviceId) {
      this.disconnect(this.currentDeviceId);
      this.currentDeviceId = null;
    }
    this.unsubscribeNotify();
  }
}

6.2 ArkUI 页面:扫描 → 选择设备 → 控制灯

下面是一个简化但能跑通思路的页面结构:

@Entry
@Component
struct SmartLampPage {
  private manager: SmartLampBleManager = new SmartLampBleManager();
  @State devices: Array<{ id: string; name: string }> = [];
  @State connected: boolean = false;
  @State lampOn: boolean = false;
  @State brightness: number = 50;

  async aboutToAppear() {
    const ctx = getContext(this);
    const ok = await ensureBlePermissions(ctx);
    if (!ok) return;

    this.manager.onStatusChange = (on, bri) => {
      this.lampOn = on;
      this.brightness = bri;
    };

    this.manager.startScan((d) => {
      // 简单去重
      if (!this.devices.some(item => item.id === d.deviceId)) {
        this.devices.push({ id: d.deviceId, name: d.deviceName || '未知设备' });
      }
    });
  }

  aboutToDisappear() {
    this.manager.dispose();
  }

  async connectDevice(deviceId: string) {
    this.manager.stopScan();
    await this.manager.connectAndInit(deviceId);
    this.connected = true;
  }

  build() {
    Column() {
      if (!this.connected) {
        Text('请选择要连接的灯设备').fontSize(18).margin({ bottom: 12 });

        ForEach(this.devices, (d) => {
          Row() {
            Text(d.name).fontSize(16)
            Text(`(${d.id.slice(0, 6)}...)`).fontSize(12).margin({ left: 6 })
          }
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(8)
          .margin({ bottom: 8 })
          .onClick(() => this.connectDevice(d.id))
        }, d => d.id)
      } else {
        Text('已连接智能灯').fontSize(18).margin({ bottom: 12 });

        Row() {
          Button(this.lampOn ? '关灯' : '开灯')
            .onClick(async () => {
              if (this.lampOn) {
                await this.manager.turnOff();
              } else {
                await this.manager.turnOn();
              }
            })

          Text(`当前亮度:${this.brightness}`).margin({ left: 16 })
        }

        Slider({ value: this.brightness, min: 0, max: 100 })
          .margin({ top: 20 })
          .onChange((v) => {
            this.brightness = v;
          })
          .onChangeEnd(async (v) => {
            await this.manager.setBrightness(v);
          })
      }
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#F5F5F5')
  }
}

到这里,一整条链路就跑通了:

权限 ✅ → 初始化 BLE ✅ → 扫描设备 ✅ → 连接 ✅ → 发现服务 ✅
→ 写入控制命令 ✅ → 开启 Notify ✅ → 灯状态变更时 UI 实时更新 ✅

这已经是一个非常接近真实 IoT 场景的骨架了。

最后:BLE 不难,难的是“别让自己乱套”

如果你能把上面这几块在脑子里串起来:

  1. 权限:静态 + 动态 都要配齐,尤其是定位权限
  2. 扫描 / 连接:Scan → Connect → Discover Services 三连跳
  3. 特征值读写:Read 拿状态,Write 下指令,协议要跟硬件确认
  4. Notify:订阅 + 统一解析 → 当事件总线来用
  5. 常见错误:按“权限 → 蓝牙开关 → UUID → 协议 → 设备状态”顺序检查
  6. IoT 示例:封装一个具体设备的 Manager,UI 就只是调用它

那你基本已经从“靠运气调通 BLE”的阶段,走到了“心里有图、出问题也知道排查方向”的阶段。

如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐