HarmonyOS技术精讲-Connectivity Kit:蓝牙基础------经典蓝牙与BLE入门

HarmonyOS NEXT 在蓝牙模块上做了比较大的重构,把之前的 @ohos.bluetooth 拆成了两个独立的Manager:classicBluetoothManager 和 bleManager。这个改动本身是合理的------经典蓝牙和BLE的使用场景、API设计差别很大,拆开之后接口更加清晰。但很多初学者第一次接触时会困惑:到底该用哪个Manager?同一个设备能不能同时用两种模式?
这篇文章从实际开发角度出发,先把这两种模式讲清楚,然后分别给出BLE扫描连接和经典蓝牙文件发送的示例代码。代码可以直接跑,重点会放在生命周期管理和状态同步上,这是最容易出问题的地方。
蓝牙BR/EDR与BLE:先搞清楚用哪个
蓝牙本身有两种工作模式:BR/EDR(经典蓝牙)和BLE(低功耗蓝牙)。在HarmonyOS中,它们被封装为两个独立的模块,API风格完全不同。
| 特性 | BLE (bleManager) |
经典蓝牙 (classicBluetoothManager) |
|---|---|---|
| 功耗 | 极低,纽扣电池可运行数年 | 较高,适合持续数据传输 |
| 数据传输 | 速度慢(约1Mbps),适合小数据包 | 速度快(可达2-3Mbps),适合文件/音频 |
| 连接方式 | 广播-扫描-连接-服务发现 | 配对-连接-RFCOMM通道 |
| 典型场景 | 传感器、心率计、设备发现 | 耳机、音箱、文件传输 |
| 硬件要求 | 所有蓝牙4.0+设备都支持 | 必须支持BR/EDR(很多耳机/音箱仅支持此模式) |
| HarmonyOS API | @kit.ConnectivityKit 下 bleManager |
@kit.ConnectivityKit 下 classicBluetoothManager |
实际开发中,必须先确定业务场景,再选Manager。如果一个设备需要同时支持两种模式(比如既做数据采集,又做文件传输),需要分别初始化两个Manager,它们之间没有依赖关系。
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:Mate 40 Pro (HarmonyOS NEXT)
核心实现:BLE扫描与连接
Step 1:权限声明与动态申请
蓝牙操作需要两个关键权限:ohos.permission.BLUETOOTH(蓝牙基础操作)和 ohos.permission.DISCOVER_BLUETOOTH(扫描发现设备)。在HarmonyOS NEXT中,这两个权限都需要在 module.json5 中声明,并且在运行时动态申请。
json
// module.json5
"requestPermissions": [
{
"name": "ohos.permission.BLUETOOTH",
"reason": "$string:app_name"
},
{
"name": "ohos.permission.DISCOVER_BLUETOOTH",
"reason": "$string:app_name"
}
]
动态申请代码:
typescript
import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
async function requestBlePermissions(context: common.UIAbilityContext): Promise<void> {
const atManager = abilityAccessCtrl.createAtManager();
const permissions: Array<Permissions> = [
'ohos.permission.BLUETOOTH',
'ohos.permission.DISCOVER_BLUETOOTH'
];
try {
for (const perm of permissions) {
const result = await atManager.requestPermissionsFromUser(context, [perm]);
if (result.authResults[0] !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
console.error('权限被拒绝:', perm);
// 这里建议弹窗提示用户手动授权
}
}
} catch (err) {
console.error('权限请求失败:', err.message);
}
}
注意 :权限申请必须放在 aboutToAppear 或者页面初始化阶段,不要在 build() 里申请,否则会导致连续弹窗。另外,requestPermissionsFromUser 是异步的,调用 startBLEScan 之前必须确保权限已经获得。
Step 2:初始化BLE Manager并开始扫描
bleManager 是单例对象,直接通过 import 导入即可使用,不需要 getInstance 之类的方法。
typescript
import { ble } from '@kit.ConnectivityKit';
let scanResults: ble.ScanResult[] = [];
let scanning = false;
function startBleScan(): void {
if (scanning) {
console.warn('扫描正在进行中,无需重复启动');
return;
}
const filters: ble.ScanFilter[] = [];
// 可以根据需要设置过滤条件,比如只扫描特定服务UUID的设备
// filters.push({ serviceUuid: '0000180f-0000-1000-8000-00805f9b34fb' });
const options: ble.ScanOptions = {
interval: 500, // 扫描间隔,单位ms
dutyMode: ble.ScanDuty.SCAN_MODE_LOW_POWER, // 功耗模式
phyType: ble.PhyType.PHY_LE_1M // 物理层类型
};
try {
ble.startBLEScan(filters, options);
scanning = true;
console.info('BLE扫描已启动');
} catch (err) {
console.error('startBLEScan失败:', err.message);
}
}
// 注册扫描结果监听
ble.on('BLEDeviceFind', (data: Array<ble.ScanResult>) => {
data.forEach(device => {
const exist = scanResults.some(item => item.deviceId === device.deviceId);
if (!exist) {
scanResults.push(device);
console.info('发现新设备:', device.deviceName, device.deviceId);
}
});
});
// 停止扫描
function stopBleScan(): void {
if (!scanning) return;
try {
ble.stopBLEScan();
scanning = false;
console.info('BLE扫描已停止');
} catch (err) {
console.error('stopBLEScan失败:', err.message);
}
}
几个关键点:
startBLEScan的filters参数可以传空数组,表示扫描所有设备。如果加了过滤,发现速度会变慢,但能减少无效回调。on('BLEDeviceFind')的回调会在每次发现设备时触发,而且同一个设备可能被多次发现。上面代码里用deviceId去重,避免UI列表重复渲染。- 扫描间隔
interval默认是200ms,实测发现设为500ms以上能明显减少设备间的干扰,适合在信号较差的场景。
Step 3:连接设备并监听GATT连接状态
扫描到设备后,调用 connect 进行连接。注意:BLE连接是异步的,需要通过回调监听连接状态。
typescript
import { connectedAble, gatt } from '@kit.ConnectivityKit';
// 注意:bleManager也提供了连接方法,但推荐使用connectedAble,它暴露了更详细的连接状态
let deviceId: string; // 从扫描结果中获取
// 连接设备
function connectBleDevice(targetId: string): void {
deviceId = targetId;
// 先注册连接状态变化监听
ble.on('BLEConnectionStateChange', (state: ble.BLEConnectionChangeState) => {
console.info('连接状态变化: deviceId=', state.deviceId, 'state=', state.state);
// state.state: 0=已断开, 1=正在连接, 2=已连接
if (state.state === 2) {
console.info('设备已连接,开始发现服务');
discoverServices();
} else if (state.state === 0) {
console.warn('设备已断开');
// 这里建议触发重连逻辑,或者更新UI状态
}
});
try {
ble.connect(deviceId);
} catch (err) {
console.error('连接失败:', err.message);
}
}
// 发现服务
function discoverServices(): void {
try {
const services = ble.getServices(deviceId); // 返回GattService数组
services.forEach(service => {
console.info('发现服务:', service.serviceUuid);
// 可以在这里遍历characteristics,找到需要的特征
});
} catch (err) {
console.error('getServices失败:', err.message);
}
}
// 断开连接(页面销毁时一定要调用)
function disconnectBleDevice(): void {
try {
ble.disconnect(deviceId);
// 同时注销监听
ble.off('BLEConnectionStateChange');
} catch (err) {
console.error('断开连接失败:', err.message);
}
}
很多人会忽略 off 操作。如果页面销毁时不注销监听,下次进入页面会重复注册,导致回调被多次触发,出现状态错乱。
核心实现:经典蓝牙配对与文件发送
经典蓝牙的场景通常是设备配对后,通过RFCOMM通道传输文件。HarmonyOS的 classicBluetoothManager 提供了 createBond 进行配对,以及 listener('bondStateChange') 监听配对状态。
Step 1:初始化并打开蓝牙
typescript
import { classicBluetooth, baseProfile } from '@kit.ConnectivityKit';
// 获取经典蓝牙实例
const btManager = classicBluetooth.getClassicBluetoothManager();
// 检查并打开蓝牙
function enableClassicBt(): void {
const state = btManager.getState();
if (state === baseProfile.BluetoothState.STATE_OFF) {
btManager.enableBluetooth(); // 注意:这个是异步的,需要轮询状态确认
console.info('正在打开经典蓝牙...');
} else {
console.info('经典蓝牙已开启');
}
}
经典蓝牙的 enableBluetooth 不会立即生效,建议通过 on('stateChange') 监听状态变化:
typescript
btManager.on('stateChange', (data: baseProfile.StateChangeParam) => {
if (data.state === baseProfile.BluetoothState.STATE_ON) {
console.info('经典蓝牙已就绪');
// 可以开始扫描
startClassicScan();
}
});
Step 2:扫描经典蓝牙设备并配对
经典蓝牙的扫描方法和BLE不同,它通过 startBluetoothDiscovery 发现设备,然后通过 createBond 建立配对。
typescript
let classicDevices: classicBluetooth.BondDevice[] = [];
// 开始扫描
function startClassicScan(): void {
try {
btManager.startBluetoothDiscovery(); // 扫描附近的经典蓝牙设备
} catch (err) {
console.error('扫描启动失败:', err.message);
}
}
// 监听发现设备
btManager.on('deviceFind', (device: classicBluetooth.BondDevice) => {
const exist = classicDevices.some(d => d.deviceId === device.deviceId);
if (!exist) {
classicDevices.push(device);
console.info('发现经典蓝牙设备:', device.deviceName);
// 可以在这里自动发起配对,或等待用户点击
// bondWithDevice(device.deviceId);
}
});
// 发起配对
function bondWithDevice(deviceId: string): void {
try {
btManager.createBond(deviceId);
console.info('正在发起配对...');
} catch (err) {
console.error('createBond失败:', err.message);
}
}
// 监听配对状态
btManager.on('pinRequired', (deviceId: string, pinCode: number) => {
console.info('需要输入PIN码:', pinCode);
// 通常需要显示一个UI弹窗让用户输入,或者自动确认
btManager.setDevicePin(deviceId, pinCode.toString()); // 自动配对
});
btManager.on('bondStateChange', (data: baseProfile.BondStateParam) => {
if (data.state === baseProfile.BondState.BONDED) {
console.info('配对成功:', data.deviceId);
// 配对成功后可以建立RFCOMM连接
} else if (data.state === baseProfile.BondState.BONDING) {
console.info('正在配对中...');
} else if (data.state === baseProfile.BondState.UNBONDED) {
console.info('配对失败或已解除:', data.deviceId);
}
});
Step 3:RFCOMM数据传输示例
经典蓝牙的文件传输通常使用RFCOMM(串行端口协议)。下面是一个发送文本数据的示例:
typescript
import { socket } from '@kit.ConnectivityKit';
import { fileIo } from '@kit.CoreFileKit';
// 建立RFCOMM连接
async function connectRfcomm(deviceId: string, channel: number): Promise<void> {
const rfcommSocket = socket.constructRfcommSocketInstance();
// 监听连接状态
rfcommSocket.on('connect', () => {
console.info('RFCOMM连接成功');
sendFileViaRfcomm(rfcommSocket);
});
rfcommSocket.on('disconnect', (err) => {
console.warn('RFCOMM连接断开:', err.message);
});
try {
await rfcommSocket.connect({ deviceId, channel });
} catch (err) {
console.error('RFCOMM连接失败:', err.message);
}
}
// 发送文件
async function sendFileViaRfcomm(socket: socket.RfcommSocket): Promise<void> {
const filePath = '/data/storage/el2/base/haps/entry/files/test.txt'; // 模拟一个文件路径
try {
const file = await fileIo.open(filePath, fileIo.OpenMode.READ_ONLY);
const buf = new ArrayBuffer(1024);
let totalBytes = 0;
// 分批读取并发送
while (true) {
const readResult = await fileIo.read(file.fd, buf);
if (readResult.bytesRead === 0) break; // 读取完毕
const sent = socket.send(new Uint8Array(buf.slice(0, readResult.bytesRead)));
totalBytes += sent;
}
await fileIo.close(file.fd);
console.info('文件发送完成, 共发送字节:', totalBytes);
} catch (err) {
console.error('文件发送失败:', err.message);
}
}
注意 :RFCOMM的 channel 参数需要提前知道,或者通过SDP(服务发现协议)查询。实际项目中,通常需要双方约定一个固定的channel编号。
常见问题
问题1:权限声明后仍然报错 "Permission denied"
现象 :在 module.json5 中添加了权限,运行时 startBLEScan 依然抛异常。
原因:HarmonyOS NEXT 对敏感权限要求必须同时满足两个条件:
- 在
module.json5中声明 - 在运行时通过
abilityAccessCtrl动态申请
如果忘记动态申请,或者用户在权限弹窗中点击了"拒绝",后续接口会持续报错。
解决方案:
- 在
aboutToAppear中调用权限申请方法,并在回调中处理拒绝情况 - 如果用户拒绝,引导用户去系统设置中手动开启
问题2:BLE扫描发现设备后,立即连接失败
现象 :扫描日志显示找到了设备,但调用 ble.connect 后回调状态为 "断开"。
原因:BLE设备在广播时处于可连接状态,但如果设备已经与另一个主机建立了连接,或者设备本身处于非连接广播模式(如仅广播数据),则连接会失败。此外,扫描停止后立即连接的成功率更高,因为扫描会占用蓝牙资源。
解决方案:
- 在连接前先调用
ble.stopBLEScan,释放扫描资源 - 添加重连逻辑:第一次失败后,等待1-2秒再重试一次
问题3:经典蓝牙配对成功后,RFCOMM连接超时
现象 :createBond 回调了 BONDED,但调用 rfcommSocket.connect 时一直超时。
原因:配对成功只代表设备建立了信任关系,但不代表RFCOMM服务已经就绪。有些设备需要先建立连接(socket),配对才会正式完成。此外,channel号如果不正确,连接也会失败。
解决方案:
- 确保
channel参数使用的是对方设备支持的RFCOMM通道号 - 配对成功后,等待500ms-1s再发起RFCOMM连接,给设备足够的准备时间
最佳实践
-
不要在
build()中直接调用startBLEScan。ArkUI的build()方法会多次执行,每次都会启动新的扫描,导致回调混乱。应该把扫描逻辑放在aboutToAppear或者按钮点击事件里。 -
页面销毁时必须注销所有监听 。
on('BLEDeviceFind')、on('BLEConnectionStateChange')等监听如果不注销,当页面跳转后,回调节点依然存在,可能会操作已经被销毁的UI组件,导致undefined错误。 -
BLE扫描的生命周期管理 。如果应用进入后台,系统会暂停BLE扫描。建议在
onPageHide中停止扫描,在onPageShow中重新启动。这样可以节省电量,同时避免在后台无效扫描。
FAQ
Q:为什么真机正常,模拟器不生效?
A:HarmonyOS模拟器对蓝牙硬件的模拟不完整,特别是BLE的广播和扫描功能。建议所有蓝牙相关功能都在真机上测试。
Q:为什么页面返回后,再次进入发现设备列表清空了?
A:因为 scanResults 是组件内的局部变量,页面销毁后重新创建,数据自然丢失。建议把扫描结果存储在 AppStorage 或者全局单例中,实现跨页面持久化。
Q:第一次配对成功后,再次连接需要重新配对吗?
A:不需要。配对信息保存在系统蓝牙配置中,再次连接时直接调用 ble.connect 或 rfcommSocket.connect 即可,系统会自动使用已保存的配对凭证。但注意,如果用户在系统蓝牙设置中手动删除了配对,则需要重新 createBond。