
HarmonyOS技术精讲-蓝牙:传统蓝牙实战,音频播放与文件传输
开篇:为什么传统蓝牙在 HarmonyOS 里反而成了"老大难"
很多人一提到蓝牙开发,第一反应就是低功耗蓝牙(BLE)。但在实际的 HarmonyOS 项目里,传统蓝牙(BR/EDR)的需求一点也不少。比如车载场景的音频播放、设备间无网络环境下的文件传输,这些场景都必须走传统蓝牙。
问题在于,官方文档对传统蓝牙的"音频播放"和"文件传输"这两块,示例代码写得比较简略。很多开发者照着文档跑通了一次配对连接,结果发现:音频流就是不走 A2DP Sink 输出,或者文件传输明明显示成功,却不知道文件存哪里了。
这篇文章不扯别的,直接解决两个具体痛点:
- 如何让 HarmonyOS 设备作为 A2DP Sink(音频接收端)播放远端蓝牙音频
- 如何通过 OPP Profile 实现可靠的蓝牙文件传输
它解决什么问题?
传统蓝牙(BR/EDR)的核心优势在于高带宽和稳定的点对点连接。它跟 BLE 的主要区别如下:
| 特性 | 传统蓝牙 (BR/EDR) | 低功耗蓝牙 (BLE) |
|---|---|---|
| 数据传输速率 | 1-3 Mbps(实际应用通常低于1Mbps) | 125 Kbps - 2 Mbps |
| 典型应用场景 | 音频流(通话/音乐)、文件传输、打印机 | 心率计、温湿度计、电子标签 |
| Profile 协议栈 | 复杂,包含 A2DP, HFP, OPP, SPP 等 | 简单,以 GATT 为中心 |
| 配对与连接 | 流程较长,需要手动 or 自动配对 | 广播-扫描-连接,流程更轻量 |
如果你的需求是连续高吞吐量的数据传输,比如音频流或者几十兆的文件,传统蓝牙是唯一的选择。
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:支持蓝牙的传统手机或平板(需开启蓝牙和位置权限)
核心实现:A2DP 音频播放
音频播放的核心是让当前设备作为 A2DP Sink(接收端),接收来自其他设备(如另一台手机或笔记本)的音频流。这里的关键 API 是 bluetooth.startAudioPlayback。
1. 权限与配置
第一步是申请权限。传统蓝牙需要获取 ohos.permission.USE_BLUETOOTH 和 ohos.permission.ACCESS_BLUETOOTH。并且,搜索设备需要开启位置服务,这是 Android 遗留的规范,HarmonyOS 同样继承。
typescript
// EntryAbility.ts
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { bundleManager } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 在onCreate中请求权限
let atManager = abilityAccessCtrl.createAtManager();
try {
atManager.requestPermissionsFromUser(this.context, [
'ohos.permission.USE_BLUETOOTH',
'ohos.permission.ACCESS_BLUETOOTH',
'ohos.permission.ACCESS_FINE_LOCATION' // 搜索设备需要
]).then((data) => {
console.log('权限请求结果:', JSON.stringify(data));
}).catch((err: BusinessError) => {
console.error('权限请求失败:', JSON.stringify(err));
});
} catch (e) {
console.error('权限请求异常:', JSON.stringify(e));
}
}
// ... 其余生命周期
}
2. 蓝牙设备配对与连接
配对是连接的前置步骤。官方提供了 bluetooth.createBond 方法,但很多人发现调用后没有回调。问题在于,这个 API 需要在发现设备后才能调用成功,并且配对结果通过 bluetooth.on('bondStateChange') 回调获取。
typescript
// deviceDiscovery.ets
import { bluetooth } from '@kit.ConnectivityKit';
import { BusinessError } from '@kit.BasicServicesKit';
export class DeviceManager {
private deviceList: bluetooth.BluetoothDevice[] = [];
// 注册发现结果回调
startDiscovery(callback: (device: bluetooth.BluetoothDevice) => void): void {
// 先清空上一次的监听器,避免重复注册导致多次回调
try {
bluetooth.off('bluetoothDeviceFind');
} catch (e) {
// 忽略第一次未注册的错误
}
bluetooth.on('bluetoothDeviceFind', (devices: Array<bluetooth.BluetoothDevice>) => {
devices.forEach(device => {
// 只处理传统蓝牙设备(非BLE)
if (device.deviceType === bluetooth.BluetoothDeviceType.BREDR) {
callback(device);
this.deviceList.push(device);
}
});
});
bluetooth.startBluetoothDiscovery();
}
// 发起配对
createBond(device: bluetooth.BluetoothDevice): Promise<void> {
return new Promise((resolve, reject) => {
// 注册配对状态变化监听
bluetooth.on('bondStateChange', (state: bluetooth.BondState, peerDevice: bluetooth.BluetoothDevice) => {
if (peerDevice.deviceId === device.deviceId) {
if (state === bluetooth.BondState.BOND_STATE_BONDED) {
bluetooth.off('bondStateChange'); // 移除监听,避免内存泄漏
resolve();
} else if (state === bluetooth.BondState.BOND_STATE_INVALID) {
bluetooth.off('bondStateChange');
reject(new Error('配对失败'));
}
}
});
try {
bluetooth.createBond(device.deviceId);
} catch (e) {
bluetooth.off('bondStateChange');
reject(e);
}
});
}
}
关键点 :createBond 调用后,系统会弹出配对确认框(或者自动配对),无论哪种情况,都需要通过 on('bondStateChange') 来感知配对结果。如果超时或失败,需要主动调用 bluetooth.off 清理监听器,否则会出现内存泄漏。
3. 连接并启动音频播放
配对成功后,我们需要连接 A2DP Profile。
typescript
// a2dpPlayback.ets
import { bluetooth } from '@kit.ConnectivityKit';
export class A2dpManager {
private a2dpSrc: bluetooth.A2dpSourceProfile | undefined = undefined;
// 初始化A2DP Source Profile
initProfile(): void {
this.a2dpSrc = bluetooth.A2dpSourceProfile.createProfile();
if (!this.a2dpSrc) {
console.error('A2DP Source Profile创建失败');
return;
}
// 注册连接状态变化监听
this.a2dpSrc.on('connectionStateChange', (state: bluetooth.ProfileConnectionState, device: bluetooth.BluetoothDevice) => {
if (state === bluetooth.ProfileConnectionState.STATE_CONNECTED) {
console.log('A2DP连接成功,开始播放');
// 重要:连接成功后,远端设备开始传输音频流,本地设备会自动作为Sink播放
// 这里不需要额外API启动"播放"
}
});
}
// 连接指定设备(必须已配对)
async connectDevice(device: bluetooth.BluetoothDevice): Promise<void> {
if (!this.a2dpSrc) {
this.initProfile();
}
// 等待连接完成
return new Promise((resolve, reject) => {
this.a2dpSrc!.connect(device.deviceId, (err: BusinessError) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
// 启动音频播放(实际上是在A2DP连接成功后,通知远端设备开始传输)
// 这个API:bluetooth.startAudioPlayback(deviceId) 通常在远端调用
// 本地端不需要手动调用,连接成功即开始
// 但是为了示例完整性,这里展示如何获取当前连接状态
isA2dpConnected(deviceId: string): boolean {
if (!this.a2dpSrc) {
return false;
}
const state = this.a2dpSrc.getConnectionState(deviceId);
return state === bluetooth.ProfileConnectionState.STATE_CONNECTED;
}
}
常见的误区 :很多开发者以为 startAudioPlayback 是本地设备调用的 API,用于启动播放。实际上,在 A2DP Sink 端(接收端),当 Profile 连接成功后,音频流会自动从 Source 端(手机)传输过来 ,Sink 端(车载设备或耳机)会自动开始播放。开发者不需要调用 startAudioPlayback。这个 API 通常用在 A2DP Source 端,用于将音频数据发送到远端。
核心实现:OPP 文件传输
文件传输使用 Object Push Profile (OPP)。这个 Profile 的交互逻辑比较特殊:发送方和接收方需要各自初始化 Profile。
1. 接收方配置
作为文件接收端,需要注册监听文件传输请求。
typescript
// oppReceiver.ets
import { bluetooth } from '@kit.ConnectivityKit';
export class OppReceiver {
private oppProfile: bluetooth.OppProfile | undefined = undefined;
init(): void {
this.oppProfile = bluetooth.OppProfile.createProfile();
if (!this.oppProfile) {
console.error('OPP Profile创建失败');
return;
}
// 注册接收文件请求
this.oppProfile.on('receiveFileRequest', (device: bluetooth.BluetoothDevice, filePath: string) => {
console.log('收到文件传输请求,来自设备:', device.deviceName, '文件:', filePath);
// 这里可以显示通知,让用户确认接收。为了演示,直接自动接收
this.oppProfile!.acceptFileTransfer(device.deviceId, (err: BusinessError) => {
if (err) {
console.error('接受文件失败:', JSON.stringify(err));
} else {
console.log('文件接收请求已接受');
}
});
});
// 注册传输状态监听
this.oppProfile.on('transferStateChange', (state: bluetooth.TransferState, device: bluetooth.BluetoothDevice) => {
if (state === bluetooth.TransferState.TRANSFER_STATE_COMPLETED) {
console.log('文件传输完成');
} else if (state === bluetooth.TransferState.TRANSFER_STATE_FAILED) {
console.error('文件传输失败');
}
});
}
}
2. 发送方实现
发送方调用 sendFile,需要传入对端设备 ID 和文件 URI。
typescript
// oppSender.ets
import { bluetooth } from '@kit.ConnectivityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo } from '@kit.CoreFileKit';
export class OppSender {
private oppProfile: bluetooth.OppProfile | undefined = undefined;
init(): void {
this.oppProfile = bluetooth.OppProfile.createProfile();
}
async sendFile(deviceId: string, fileUri: string): Promise<void> {
if (!this.oppProfile) {
Promise.reject(new Error('OPP Profile未初始化'));
return;
}
// 先确保设备已配对且可连接
// 实际项目中,建议先调用 bluetooth.connectDevice 或使用系统的配对流程
return new Promise((resolve, reject) => {
this.oppProfile!.sendFile(deviceId, fileUri, (err: BusinessError) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
// 监听发送结果
registerTransferStateListener(): void {
this.oppProfile!.on('transferStateChange', (state: bluetooth.TransferState, device: bluetooth.BluetoothDevice) => {
if (state === bluetooth.TransferState.TRANSFER_STATE_COMPLETED) {
console.log('文件已成功发送到:', device.deviceName);
}
});
}
}
关键点 :sendFile 的 fileUri 必须是 应用沙箱内的文件路径 ,或者是通过 fileIo 打开的 Content URI。直接传入系统相册路径可能无法访问。建议先将文件拷贝到应用缓存目录下。
踩坑章节:三个让你怀疑人生的"小问题"
坑1:配对成功后,A2DP 连接失败
现象 :调用 a2dpSrc.connect 后,回调一直不返回,或者返回 STATE_DISCONNECTED。
原因:A2DP Profile 连接和配对是两个独立的过程。配对只是建立了安全通道,而 Profile 连接需要双方设备都支持 A2DP Source/Sink 角色。大部分手机默认只支持 A2DP Source(发送方),作为 Sink 需要设备声明支持。
解法 :在调用 connect 前,先通过 bluetooth.getRemoteDeviceClass(deviceId) 获取远端设备类型,确认它是否支持 A2DP Source。如果远端设备是手机,它一定支持 Source。但如果是耳机,它只支持 Sink。此时本地设备如果作为 Source 去连接耳机,耳机会接受;但如果作为 Sink 去连接手机,需要手机主动推流。确保连接的方向正确。
坑2:OPP 接收文件后,找不到文件
现象:显示传输成功,但在系统文件管理器里找不到接收到的文件。
原因:OPP 接收的文件默认保存在应用专属沙箱目录下,而不是系统 Downloads 文件夹。具体路径取决于 Profile 的配置。HarmonyOS 的 OPP Profile 没有提供配置保存路径的 API。
解法 :在 receiveFileRequest 回调中,filePath 参数就是文件在接收方沙箱中的路径。你需要主动调用 fileIo.move 或 fileIo.copy,将文件移动到应用的 downloadsAgent 目录下,或者存到 Documents 目录(需要权限)。建议创建自己的接收目录:
typescript
// 在OppReceiver的初始化中,指定接收目录
const receiveDir = getContext(this).filesDir + '/bluetooth_received';
// 创建目录
fileIo.mkdirSync(receiveDir, true);
// 在receiveFileRequest回调中,将文件移动到receiveDir
fileIo.copyFile(filePath, receiveDir + '/' + fileName);
坑3:多次调用 bluetooth.on 导致回调重复
现象:第一次传输正常,第二次传输回调被调用了两次。
原因 :on 方法是全局注册的。如果在每个页面的 aboutToAppear 中重复注册,业务逻辑会收到多份回调,导致状态混乱。
解法 :将蓝牙相关的生命周期监听放到 Ability 或者单例 Service 中管理,确保只注册一次。在页面销毁时,不要轻易调用 off,除非你能确定没有其他页面在使用该监听。
最佳实践:稳定传输的三点建议
-
在
runStart阶段初始化 Profile :不要在build()或组件的aboutToAppear里初始化蓝牙 Profile。因为 Profile 创建涉及底层初始化,可能会卡住 UI 线程。推荐在EntryAbility的onCreate或 App 启动时初始化为一个全局单例。 -
异步回调内慎用全局数据 :蓝牙的回调可能发生在后台线程。如果在回调内部直接修改页面的
@State变量,需要确保页面还活着。推荐使用this.state = newValue的方式更新,并配合@Monitor或@Watch来触发 UI 刷新。 -
显式处理超时 :
createBond和connect都没有内置超时机制。如果对端设备没有响应,你的 Promise 会一直 pending。建议手动设置定时器,超过一定时间(如 15 秒)主动 reject,并清理监听器,避免资源泄漏。
Demo 入口文件
这里给出一个整合了上述所有功能的 App 入口文件。它使用了 Navigation 组件来控制页面跳转。
typescript
// EntryAbility.ts
// 整合了权限请求和全局单例初始化
// 详细代码见上文 EntryAbility.ets
// Index.ets (入口页面)
@Entry
@Component
struct Index {
build() {
Row() {
Column() {
Button('扫描设备并配对')
.onClick(() => {
router.pushUrl({ url: 'pages/DeviceList' });
})
.margin({ bottom: 20 })
Button('文件传输接收')
.onClick(() => {
router.pushUrl({ url: 'pages/FileReceiver' });
})
}
.width('100%')
}
.height('100%')
}
}
FAQ:开发者常问的三个问题
Q:为什么真机上配对弹窗不显示?
A:确保应用已获取位置权限,并且手机蓝牙开关已开启。同时检查 createBond 是否在发现设备后的有效时间内调用,超过发现窗口期会导致配对失败。
Q:A2DP 播放没有声音,但日志显示连接成功?
A:检查你的设备是否支持 A2DP Sink。手机作为 Sink 的实现通常是有限的。建议使用专门的蓝牙音箱或车机进行测试。另外,连接的远端设备(手机)需要开始播放音乐,本地才会收到音频流。
Q:OPP 协议是否能传输大文件?
A:理论上可以,但实际表现与设备蓝牙芯片性能有关。超过 100MB 的文件,建议使用其他方式(如 WiFi 直连)或断点续传机制。OPP 协议没有内置断点续传功能。
如果你在实际开发中也遇到了蓝牙相关的其他问题,比如配对超时或连接状态跟踪混乱,建议重点排查生命周期管理和回调注册时机。官方文档对这部分描述比较简略,结合真机日志是比较可靠的调试手段。