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

一、蓝牙权限:不先搞清这个,你后面全白写

先说个残酷的事实:绝大部分 “怎么蓝牙没反应” 的问题,都死在权限这一步。
鸿蒙对蓝牙做了比较严格的权限控制,不仅有普通蓝牙权限,还有位置 / 扫描相关的权限,这一点跟 Android 有点类似:扫 BLE 必须有定位权限,否则你根本扫不到设备。

1.1 常见蓝牙相关权限

通常 BLE 场景至少会碰到这些权限(名字可能按版本略有调整,但核心意思类似):

  • ohos.permission.BLUETOOTH —— 基础蓝牙使用
  • ohos.permission.BLUETOOTH_ADMIN / MANAGE_BLUETOOTH —— 扫描、配对、管理
  • ohos.permission.LOCATION —— 扫描 BLE 时获取附近设备
  • 某些版本上还有数据相关权限,如记录设备信息等

经验结论:
你只声明蓝牙权限而不声明定位权限,很可能扫不到任何 BLE 设备,看日志还以为设备不在线。

1.2 在 module.json5 中声明静态权限

先在 module.json5 里面把该写的“话”跟系统说清楚:

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

这一步只是静态声明,告诉系统“我这个应用未来可能要用这些权限”,还不等于用户已经授权。

1.3 动态申请权限(否则很多 API 直接给你拒绝)

鸿蒙里典型的动态权限申请流程还是走 abilityAccessCtrl

import abilityAccessCtrl from '@ohos.abilityAccessCtrl';

async function ensurePermission(context, permission: string) {
  const atManager = abilityAccessCtrl.createAtManager();
  const status = await atManager.checkAccessToken(
    context.tokenId,
    permission
  );

  if (status === 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) {
  const perms = [
    'ohos.permission.BLUETOOTH',
    'ohos.permission.MANAGE_BLUETOOTH',
    'ohos.permission.LOCATION'
  ];

  for (const p of perms) {
    const ok = await ensurePermission(context, p);
    if (!ok) {
      console.error(`权限 [${p}] 被拒绝`);
      return false;
    }
  }
  return true;
}

建议做法:

  • 不要应用一启动就弹一堆权限
  • 在用户点击“连接设备”、“搜索设备”等入口时再触发权限申请
  • 用户拒绝时,配一个简单的 UI 提示:为什么需要蓝牙 / 定位权限

这样用户体验会好很多,审核也不会揪你。

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

权限搞定之后,下一步才轮到 扫描 & 连接
这部分大多数人是这么经历的:

“扫描没问题,列表里能看到设备,点击连接就开始无限失败。”

通常要么是 过滤条件写错,要么是 连接时机不对,要么是 蓝牙状态没处理

下面按正常流程来过一遍。

2.1 初始化 BLE 客户端

鸿蒙 BLE 一般会通过蓝牙子系统提供的 client API 来做 GATT 连接(不同版本模块名字略有差异,这里写成通用伪代码风格,重点放在流程和模式上):

import ble from '@ohos.bluetooth'; // 示例名称,具体以实际版本为准

let bleClient: any = null;

function initBleClient() {
  if (!bleClient) {
    bleClient = ble.createGattClient();
  }
}

初始化一般放在:

  • App 启动时
  • 或点击“进入蓝牙设备页面”时

总之不要每次扫描都 new 一遍,避免资源浪费。

2.2 启动扫描(Scan)

典型扫描流程:

function startScan(onDeviceFound: (device) => void) {
  // 监听扫描回调
  bleClient.on('scanResult', (result) => {
    console.info(`发现设备:${result.deviceName}, ${result.deviceId}`);
    onDeviceFound(result);
  });

  // 开始扫描,可配置过滤条件
  bleClient.startScan({
    // 可选:serviceUuids: ['your-service-uuid'],
    // 可选:deviceName: 'MyDevice'
    interval: 1000  // 扫描间隔等配置
  });
}

function stopScan() {
  bleClient.stopScan();
}

实战建议:

  • 加过滤:按设备名 / Service UUID 过滤,否则你会看到一堆乱七八糟的设备
  • 扫描时间控制在一定范围(比如 10–20 秒),不要一直扫描到手机发热

2.3 用户点击某个设备 → 发起连接(Connect)

通常你会有一个设备列表,用户点击某一项时去连接:

function connect(deviceId: string) {
  return new Promise((resolve, reject) => {
    bleClient.connect(deviceId, (err, data) => {
      if (err) {
        console.error('连接失败:', JSON.stringify(err));
        reject(err);
        return;
      }
      console.info('连接成功:', JSON.stringify(data));
      resolve(data);
    });
  });
}

连接成功后,通常要做几件重要的事情:

  1. 保存 deviceId / 连接句柄,后面读写要用
  2. 发现服务(discoverServices)
  3. 找到你关心的 Service / Characteristic UUID

2.4 发现服务(Discover Services)

这一块是后面能不能读写的关键。

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

你拿到 services 后,需要:

  • 根据 Service UUID 找到对应 service
  • 再从该 service 的 characteristics 中找到你要的 Characteristic UUID

大部分 IoT 设备都会在文档里给你这样一段东西:

  • Service UUID: 0xFFF0
  • Write Characteristic: 0xFFF1
  • Notify Characteristic: 0xFFF2

一定要跟产品 / 硬件那一侧确认清楚!
不要自己拍脑袋猜。

三、读取 & 写入特征值:BLE 真正干活的地方

蓝牙 BLE 的真正业务数据交互,是通过 Characteristic(特征值) 完成的。

一旦你拿到:

  • deviceId
  • serviceUuid
  • characteristicUuid

你才算“挽起袖子真的开始工作”。

3.1 读取特征值(Read Characteristic)

示例:

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

记得解析数据:

如果设备返回的是字节流,你要把它和协议对上。比如:

function parseTemperature(buffer: ArrayBuffer): number {
  const view = new DataView(buffer);
  // 假设第一个字节是温度
  return view.getInt8(0);
}

3.2 写入特征值(Write Characteristic)

写入是 IoT 里最常用的动作,比如:

  • 开灯 / 关灯
  • 设置亮度
  • 修改设备模式
function writeCharacteristic(deviceId: string, serviceUuid: string, charUuid: string, payload: Uint8Array) {
  return new Promise((resolve, reject) => {
    bleClient.writeCharacteristicValue(
      deviceId,
      serviceUuid,
      charUuid,
      payload.buffer,
      (err, data) => {
        if (err) {
          console.error('写入特征值失败:', JSON.stringify(err));
          reject(err);
          return;
        }
        console.info('写入特征值成功:', JSON.stringify(data));
        resolve(data);
      }
    );
  });
}

构造 payload:

// 比如要给设备发送一个简单协议:0xA0 0x01 0x01
const payload = new Uint8Array([0xA0, 0x01, 0x01]);
await writeCharacteristic(deviceId, serviceUuid, writeCharUuid, payload);

请务必跟硬件侧确认通讯协议!
1 个字节写错,设备就会当你在说外星话。

四、BLE Notify 通信:从“我问你答”到“你主动给我推消息”

轮询太土了。BLE 的精华在于 Notify / Indicate ——设备主动推数据。

比如:

  • 设备状态改变时及时上报
  • IoT 传感器定时发数据(温度 / 湿度 / 电量等)

在鸿蒙 BLE 中,你需要先:

  1. 开启某个特征值的 Notify
  2. 监听对应回调

4.1 开启 Notify

function enableNotify(deviceId: string, serviceUuid: string, notifyCharUuid: string) {
  return new Promise((resolve, reject) => {
    bleClient.setCharacteristicNotification(
      deviceId,
      serviceUuid,
      notifyCharUuid,
      true,
      (err) => {
        if (err) {
          console.error('开启 Notify 失败:', JSON.stringify(err));
          reject(err);
          return;
        }
        console.info('开启 Notify 成功');
        resolve(true);
      }
    );
  });
}

有些协议还要求你向 Descriptor(描述符) 里写特定值(比如 0x01 0x00)才算真正开启通知,这种就得按设备协议来。

4.2 监听 Notify 数据

function subscribeNotify(onData: (data) => void) {
  bleClient.on('characteristicChanged', (res) => {
    console.info('收到 Notify :', JSON.stringify(res));
    // res 里面通常包含 deviceId、serviceUuid、characteristicUuid、value
    onData(res);
  });
}

解析方式与前面读特征值类似,关键是要根据协议对每个字节做解析。

4.3 前端 UI 上的常见 Notify 玩法

比如做一个 IoT 温湿度设备的实时数据页面:

  • 上方:当前温度 / 湿度
  • 中间:折线实时刷新
  • 下方:设备状态(在线 / 离线)

你可以:

  1. 在进入页面时自动 enableNotify
  2. onPageHide / aboutToDisappear 时取消通知(或者断开连接)

避免一直占用蓝牙资源和电量。

五、常见错误处理:别动不动就“重启手机”

说点现实的:BLE 的失败情况,非常多。
如果你没做错误处理,你只能看到“失败”两个字,然后一脸懵。

下面列几个最常见、也最坑的错误场景

5.1 扫描不到设备

可能原因:

  1. 权限不全(尤其是定位权限)
  2. 蓝牙没打开(有的机型需要手动打开系统蓝牙)
  3. 过滤条件过于严格(比如写死了错误的 Service UUID)
  4. 设备根本没广播(很多 IoT 设备要按某个物理按钮才进入广播模式)

实战建议:

  • 日志里打印:权限状态 / 蓝牙开关状态
  • 提示用户检查设备是否进入配对模式
  • 测试时可以用手机 / BLE 调试工具确认设备是否在广播

5.2 连接失败 / 频繁断开

可能原因:

  1. 设备一次只允许一个连接,你前一个连接没断干净
  2. 连接过程超时,设备自动断开
  3. 信号太差(物理环境问题)
  4. 设备端有连接数 / 会话数限制

建议做法:

  • 在页面退出时,记得 disconnect(deviceId)
  • 出错时提示用户:靠近设备、确认设备电量
  • 日志中把错误码打印完整,方便和硬件同学对

5.3 写入失败 / 没反应

可能原因:

  1. 写错 Service / Characteristic UUID
  2. 写入数据格式与协议不符
  3. 写入特征值不支持写(属性 flag 不包含 write)
  4. 写间隔太短导致设备处理不过来

排查建议:

  • 用第 3 方调试工具(nRF Connect 等)先验证协议
  • 尽量不要在毫秒级疯狂写入(除非协议明确支持)
  • 根据硬件文档确认特征值属性

5.4 Notify 没回调

经典问题,通常出现在:

  1. 只调用了 setCharacteristicNotification,却没写客户端配置描述符(CCCD)
  2. 设备侧根本没有向该特征值发送 Notify
  3. Notify 的 UUID 搞错(你以为是这个,实际上是另一个)

最佳实践:
先在 BLE 调试工具里把整套流程走通,再在代码里复刻。
不要只盯着代码怀疑人生,有时候是设备这边没发。

六、IoT 设备接入示例:以“智能灯”场景串一遍全流程

前面说了这么多,最后我们用一个简单但很典型的例子,把整个 BLE 流程串起来:
接入一款 BLE 智能灯设备。

假设硬件给你的协议是这样的(很常见的风格):

  • Service UUID: 0xFFF0

  • 写入控制特征:0xFFF1

  • 状态 Notify 特征:0xFFF2

  • 写入命令格式:

    • 开灯:0xA0 0x01 0x01
    • 关灯:0xA0 0x01 0x00
    • 调亮度:0xA1 0x01 <0-100>

6.1 总体流程图(文字版)

  1. 检查 / 申请蓝牙 + 定位权限
  2. 初始化 BLE 客户端
  3. 扫描设备,过滤名称为 “SmartLamp”
  4. 用户点击某台灯设备 → 连接
  5. 发现服务,找到 FFF0
  6. 记录写入特征 FFF1,Notify 特征 FFF2
  7. 开启 Notify,监听灯状态变化
  8. 用户在 UI 上操作:开关、调光
  9. 将对应命令写入到 FFF1
  10. 灯反馈状态 → FFF2 通知 → 页面 UI 更新

6.2 简化代码示意(流程版,便于理解)

说明:这里不是逐 API 精确代码,而是把流程、结构、调用关系写清楚,便于你在项目里套用。

const SERVICE_UUID = 'FFF0';
const WRITE_CHAR_UUID = 'FFF1';
const NOTIFY_CHAR_UUID = 'FFF2';

class SmartLampBleManager {
  private deviceId: string | null = null;

  async init(context) {
    const ok = await ensureBlePermissions(context);
    if (!ok) throw new Error('BLE 权限申请失败');
    initBleClient();
  }

  startScan(onDeviceFound) {
    startScan((result) => {
      if (result.deviceName === 'SmartLamp') {
        onDeviceFound(result);
      }
    });
  }

  async connectToDevice(deviceId: string) {
    await connect(deviceId);
    this.deviceId = deviceId;
    const services: any = await discoverServices(deviceId);
    // 此处可遍历 services 找到 uuid = FFF0 的 service
    await enableNotify(deviceId, SERVICE_UUID, NOTIFY_CHAR_UUID);
    subscribeNotify((res) => {
      if (res.characteristicUuid === NOTIFY_CHAR_UUID) {
        this.handleLampStatus(res.value);
      }
    });
  }

  private handleLampStatus(buffer: ArrayBuffer) {
    // 根据协议解析,比如第 1 字节代表开关,第 2 字节代表亮度
    const v = new DataView(buffer);
    const on = v.getUint8(0) === 1;
    const brightness = v.getUint8(1); // 0-100

    console.info(`灯状态:${on ? '开' : '关'},亮度=${brightness}`);
    // 你可以通过状态管理 / 回调通知 UI 更新
  }

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

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

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

  disconnect() {
    if (this.deviceId) {
      bleClient.disconnect(this.deviceId);
      this.deviceId = null;
    }
  }
}

再配合一个简单的 ArkUI 页面:

@Entry
@Component
struct LampPage {
  private manager: SmartLampBleManager = new SmartLampBleManager();
  @State isOn: boolean = false;
  @State brightness: number = 50;

  async aboutToAppear() {
    await this.manager.init(getContext(this));
    this.manager.startScan((device) => {
      // 这里你可以展示设备列表,这里直接连接第一个
      this.manager.connectToDevice(device.deviceId);
    });
  }

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

      Slider({
        value: this.brightness,
        min: 0,
        max: 100
      }).onChange(async (value) => {
        this.brightness = value;
        await this.manager.setBrightness(value);
      })
    }
  }
}

这样,一个完整的 鸿蒙 BLE + IoT 智能灯控制 Demo 流程 就跑通了:
权限 → 扫描 → 连接 → 服务发现 → 写入控制 → Notify 状态回传 → UI 反馈 一条龙。

最后来一句真心话 🌈

BLE 在任何平台上都不算“好脾气”的模块:协议要对、UUID 要对、权限要对、时机要对,设备还不能抽风。鸿蒙上 BLE 的整体 API 其实并不算复杂,但是对流程的要求非常严格:权限先到位、状态先判断、再做扫描 / 连接 / 读写 / Notify。

只要你心里有那条清晰的“时间线”:

权限 → 初始化 → 扫描 → 连接 → 发现服务 → 建立读写 / Notify 通道 → 稳定维护连接

再加上一点点和硬件同学“对协议”的耐心,你会发现其实 BLE 开发也没有那么可怕,甚至——还挺有成就感的。

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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐