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 同样继承。
// EntryAbility.tsimport{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';exportdefaultclassEntryAbilityextendsUIAbility{onCreate(want:Want,launchParam:AbilityConstant.LaunchParam):void{// 在onCreate中请求权限letatManager=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')回调获取。
// deviceDiscovery.etsimport{bluetooth}from'@kit.ConnectivityKit';import{BusinessError}from'@kit.BasicServicesKit';exportclassDeviceManager{privatedeviceList: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>{returnnewPromise((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();}elseif(state===bluetooth.BondState.BOND_STATE_INVALID){bluetooth.off('bondStateChange');reject(newError('配对失败'));}}});try{bluetooth.createBond(device.deviceId);}catch(e){bluetooth.off('bondStateChange');reject(e);}});}}关键点:createBond调用后,系统会弹出配对确认框(或者自动配对),无论哪种情况,都需要通过on('bondStateChange')来感知配对结果。如果超时或失败,需要主动调用bluetooth.off清理监听器,否则会出现内存泄漏。
3. 连接并启动音频播放
配对成功后,我们需要连接 A2DP Profile。
// a2dpPlayback.etsimport{bluetooth}from'@kit.ConnectivityKit';exportclassA2dpManager{privatea2dpSrc:bluetooth.A2dpSourceProfile|undefined=undefined;// 初始化A2DP Source ProfileinitProfile():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启动“播放”}});}// 连接指定设备(必须已配对)asyncconnectDevice(device:bluetooth.BluetoothDevice):Promise<void>{if(!this.a2dpSrc){this.initProfile();}// 等待连接完成returnnewPromise((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){returnfalse;}conststate=this.a2dpSrc.getConnectionState(deviceId);returnstate===bluetooth.ProfileConnectionState.STATE_CONNECTED;}}常见的误区:很多开发者以为startAudioPlayback是本地设备调用的 API,用于启动播放。实际上,在 A2DP Sink 端(接收端),当 Profile 连接成功后,音频流会自动从 Source 端(手机)传输过来,Sink 端(车载设备或耳机)会自动开始播放。开发者不需要调用startAudioPlayback。这个 API 通常用在A2DP Source 端,用于将音频数据发送到远端。
核心实现:OPP 文件传输
文件传输使用 Object Push Profile (OPP)。这个 Profile 的交互逻辑比较特殊:发送方和接收方需要各自初始化 Profile。
1. 接收方配置
作为文件接收端,需要注册监听文件传输请求。
// oppReceiver.etsimport{bluetooth}from'@kit.ConnectivityKit';exportclassOppReceiver{privateoppProfile: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('文件传输完成');}elseif(state===bluetooth.TransferState.TRANSFER_STATE_FAILED){console.error('文件传输失败');}});}}2. 发送方实现
发送方调用sendFile,需要传入对端设备 ID 和文件 URI。
// oppSender.etsimport{bluetooth}from'@kit.ConnectivityKit';import{BusinessError}from'@kit.BasicServicesKit';import{fileIo}from'@kit.CoreFileKit';exportclassOppSender{privateoppProfile:bluetooth.OppProfile|undefined=undefined;init():void{this.oppProfile=bluetooth.OppProfile.createProfile();}asyncsendFile(deviceId:string,fileUri:string):Promise<void>{if(!this.oppProfile){Promise.reject(newError('OPP Profile未初始化'));return;}// 先确保设备已配对且可连接// 实际项目中,建议先调用 bluetooth.connectDevice 或使用系统的配对流程returnnewPromise((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目录(需要权限)。建议创建自己的接收目录:
// 在OppReceiver的初始化中,指定接收目录constreceiveDir=getContext(this).filesDir+'/bluetooth_received';// 创建目录fileIo.mkdirSync(receiveDir,true);// 在receiveFileRequest回调中,将文件移动到receiveDirfileIo.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组件来控制页面跳转。
// EntryAbility.ts// 整合了权限请求和全局单例初始化// 详细代码见上文 EntryAbility.ets// Index.ets (入口页面)@Entry@Componentstruct 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 协议没有内置断点续传功能。
如果你在实际开发中也遇到了蓝牙相关的其他问题,比如配对超时或连接状态跟踪混乱,建议重点排查生命周期管理和回调注册时机。官方文档对这部分描述比较简略,结合真机日志是比较可靠的调试手段。