都能用手机连冰箱了,你的鸿蒙 BLE 还连不上灯泡?
—HarmonyOS 蓝牙(BLE)开发从权限到 IoT 全流程实战概念不难、细节够你崩溃。扫描列表里一个设备都没有,以为硬件坏了,结果是——权限少配了一个。连接老是失败,有时候能连、有时候不能连,像掷骰子。写入特征值控制设备时,明明代码执行成功,但设备就是不鸟你。开了 Notify 却收不到任何回调,最后发现 UUID 写错一位。所以这篇咱们就别玩虚的,按你给的大纲,一条线把鸿蒙 BLE 开发从
大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~
本文目录:
前言
——HarmonyOS 蓝牙(BLE)开发从权限到 IoT 全流程实战
说句心里话,BLE 这种东西真是典型的:概念不难、细节够你崩溃。
你可能已经经历过这些场景:
- 扫描列表里一个设备都没有,以为硬件坏了,结果是——权限少配了一个。
- 连接老是失败,有时候能连、有时候不能连,像掷骰子。
- 写入特征值控制设备时,明明代码执行成功,但设备就是不鸟你。
- 开了 Notify 却收不到任何回调,最后发现 UUID 写错一位。
所以这篇咱们就别玩虚的,按你给的大纲,一条线把鸿蒙 BLE 开发从 权限 → 扫描 / 连接 → 读写特征值 → Notify 通信 → 常见坑 → IoT 接入示例 串起来。
代码以 ArkTS + HarmonyOS Stage 模型 为基础,API 名字有版本差异的地方我都会提示“以实际 SDK 为准”,你照着思路落地就行。
一、蓝牙权限:80% 的“啥都扫不到”问题都死在这
先把最容易忽略但最致命的说清楚:权限。
1.1 BLE 相关常用权限
典型 BLE 应用(扫描 + 连接 + 收发数据)通常会用到这几类权限:
-
基础蓝牙能力
ohos.permission.BLUETOOTHohos.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 客户端
建议封装一个 BleClient 或 BleManager 类,把所有扫描 / 连接逻辑集中管理,不要到处散落。
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();
}
}
}
你可以在:
MainAbility的onCreate/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)
一般流程是:
- 扫描列表展示给用户
- 用户点选某个设备
- 用
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(订阅特征值通知)
一般需要两步:
- 调用 API 告诉系统“我想订阅这个特征值的 Notify”
- 有些协议还要求对 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 扫描不到设备
可能原因:
- 权限不足(尤其是
LOCATION没开) - 蓝牙没打开 / 用户关闭了系统蓝牙开关
- 设备没处于广播状态(需要按实体按键进入配对)
- 过滤条件太严格(名称 / serviceUuid 填错)
解决思路:
- 权限状态、蓝牙开关状态都打日志,必要时给用户 UI 提示。
- 用第三方 BLE 调试 App 验证设备确实在广播。
- 初期不要乱加过滤,先把所有设备扫出来确认再慢慢加条件。
5.2 连接失败、不稳定
可能原因:
- 设备一次只允许一个连接,上一个连接没断干净。
- 距离太远 / 信号干扰,导致连接超时或频繁断开。
- 短时间内频繁发起连接 / 断开请求,被设备或系统当成“攻击”了。
建议你做到:
- 页面退出时记得
disconnect(deviceId)。 - 当连接失败时,可以做有限次重试(例如 2 次),重试间隔拉长一点。
- UI 上给用户清晰提示“请靠近设备后重试”。
5.3 写入成功但设备没反应
检查这几项:
- UUID 是否对应正确的写入特征值(不是 Notify 的那个)。
- 该特征值属性是否真的支持写(Read/Write/Notify 是分开的)。
- 命令格式是否与硬件协议吻合(头、长度、校验位等等)。
- 写入类型是否对(有些需要 Write With Response,有些只接受 Without Response)。
非常推荐的一个习惯:
先用 BLE 调试工具(例如手机上的通用 BLE 工具)验证协议,确认这条命令确实能把设备点亮,再在代码里照着复刻。
不然你总是怀疑自己写错了代码,其实是硬件那边协议说明有坑。
5.4 Notify 没回调
常见原因:
- 没有效开启通知(Characteristic 权限不足,或者 Descriptor 没写成功)。
- 监听事件写错了(事件名、回调没绑、绑多次)。
- 设备压根没往这个特征值发过 Notify。
- UUID 一位写错,看起来差不多,实际上完全是另一个值。
排查顺序:
- 用调试工具确认:开启 Notify 后是否有数据推送。
- 对照 UUID 完整字符串,别只看缩写前几位。
- 在 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 不难,难的是“别让自己乱套”
如果你能把上面这几块在脑子里串起来:
- 权限:静态 + 动态 都要配齐,尤其是定位权限
- 扫描 / 连接:Scan → Connect → Discover Services 三连跳
- 特征值读写:Read 拿状态,Write 下指令,协议要跟硬件确认
- Notify:订阅 + 统一解析 → 当事件总线来用
- 常见错误:按“权限 → 蓝牙开关 → UUID → 协议 → 设备状态”顺序检查
- IoT 示例:封装一个具体设备的 Manager,UI 就只是调用它
那你基本已经从“靠运气调通 BLE”的阶段,走到了“心里有图、出问题也知道排查方向”的阶段。
如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~
更多推荐




所有评论(0)