《HarmonyOS技术精讲-蓝牙》传统蓝牙实战:音频播放与文件传输 HarmonyOS技术精讲-蓝牙传统蓝牙实战音频播放与文件传输开篇为什么传统蓝牙在 HarmonyOS 里反而成了“老大难”很多人一提到蓝牙开发第一反应就是低功耗蓝牙BLE。但在实际的 HarmonyOS 项目里传统蓝牙BR/EDR的需求一点也不少。比如车载场景的音频播放、设备间无网络环境下的文件传输这些场景都必须走传统蓝牙。问题在于官方文档对传统蓝牙的“音频播放”和“文件传输”这两块示例代码写得比较简略。很多开发者照着文档跑通了一次配对连接结果发现音频流就是不走 A2DP Sink 输出或者文件传输明明显示成功却不知道文件存哪里了。这篇文章不扯别的直接解决两个具体痛点如何让 HarmonyOS 设备作为 A2DP Sink音频接收端播放远端蓝牙音频如何通过 OPP Profile 实现可靠的蓝牙文件传输它解决什么问题传统蓝牙BR/EDR的核心优势在于高带宽和稳定的点对点连接。它跟 BLE 的主要区别如下特性传统蓝牙 (BR/EDR)低功耗蓝牙 (BLE)数据传输速率1-3 Mbps实际应用通常低于1Mbps125 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}fromkit.AbilityKit;import{window}fromkit.ArkUI;import{common}fromkit.AbilityKit;import{bundleManager}fromkit.AbilityKit;import{BusinessError}fromkit.BasicServicesKit;exportdefaultclassEntryAbilityextendsUIAbility{onCreate(want:Want,launchParam:AbilityConstant.LaunchParam):void{// 在onCreate中请求权限letatManagerabilityAccessCtrl.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}fromkit.ConnectivityKit;import{BusinessError}fromkit.BasicServicesKit;exportclassDeviceManager{privatedeviceList:bluetooth.BluetoothDevice[][];// 注册发现结果回调startDiscovery(callback:(device:bluetooth.BluetoothDevice)void):void{// 先清空上一次的监听器避免重复注册导致多次回调try{bluetooth.off(bluetoothDeviceFind);}catch(e){// 忽略第一次未注册的错误}bluetooth.on(bluetoothDeviceFind,(devices:Arraybluetooth.BluetoothDevice){devices.forEach(device{// 只处理传统蓝牙设备非BLEif(device.deviceTypebluetooth.BluetoothDeviceType.BREDR){callback(device);this.deviceList.push(device);}});});bluetooth.startBluetoothDiscovery();}// 发起配对createBond(device:bluetooth.BluetoothDevice):Promisevoid{returnnewPromise((resolve,reject){// 注册配对状态变化监听bluetooth.on(bondStateChange,(state:bluetooth.BondState,peerDevice:bluetooth.BluetoothDevice){if(peerDevice.deviceIddevice.deviceId){if(statebluetooth.BondState.BOND_STATE_BONDED){bluetooth.off(bondStateChange);// 移除监听避免内存泄漏resolve();}elseif(statebluetooth.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}fromkit.ConnectivityKit;exportclassA2dpManager{privatea2dpSrc:bluetooth.A2dpSourceProfile|undefinedundefined;// 初始化A2DP Source ProfileinitProfile():void{this.a2dpSrcbluetooth.A2dpSourceProfile.createProfile();if(!this.a2dpSrc){console.error(A2DP Source Profile创建失败);return;}// 注册连接状态变化监听this.a2dpSrc.on(connectionStateChange,(state:bluetooth.ProfileConnectionState,device:bluetooth.BluetoothDevice){if(statebluetooth.ProfileConnectionState.STATE_CONNECTED){console.log(A2DP连接成功开始播放);// 重要连接成功后远端设备开始传输音频流本地设备会自动作为Sink播放// 这里不需要额外API启动“播放”}});}// 连接指定设备必须已配对asyncconnectDevice(device:bluetooth.BluetoothDevice):Promisevoid{if(!this.a2dpSrc){this.initProfile();}// 等待连接完成returnnewPromise((resolve,reject){this.a2dpSrc!.connect(device.deviceId,(err:BusinessError){if(err){reject(err);}else{resolve();}});});}// 启动音频播放实际上是在A2DP连接成功后通知远端设备开始传输// 这个APIbluetooth.startAudioPlayback(deviceId) 通常在远端调用// 本地端不需要手动调用连接成功即开始// 但是为了示例完整性这里展示如何获取当前连接状态isA2dpConnected(deviceId:string):boolean{if(!this.a2dpSrc){returnfalse;}conststatethis.a2dpSrc.getConnectionState(deviceId);returnstatebluetooth.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}fromkit.ConnectivityKit;exportclassOppReceiver{privateoppProfile:bluetooth.OppProfile|undefinedundefined;init():void{this.oppProfilebluetooth.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(statebluetooth.TransferState.TRANSFER_STATE_COMPLETED){console.log(文件传输完成);}elseif(statebluetooth.TransferState.TRANSFER_STATE_FAILED){console.error(文件传输失败);}});}}2. 发送方实现发送方调用sendFile需要传入对端设备 ID 和文件 URI。// oppSender.etsimport{bluetooth}fromkit.ConnectivityKit;import{BusinessError}fromkit.BasicServicesKit;import{fileIo}fromkit.CoreFileKit;exportclassOppSender{privateoppProfile:bluetooth.OppProfile|undefinedundefined;init():void{this.oppProfilebluetooth.OppProfile.createProfile();}asyncsendFile(deviceId:string,fileUri:string):Promisevoid{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(statebluetooth.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 去连接手机需要手机主动推流。确保连接的方向正确。坑2OPP 接收文件后找不到文件现象显示传输成功但在系统文件管理器里找不到接收到的文件。原因OPP 接收的文件默认保存在应用专属沙箱目录下而不是系统 Downloads 文件夹。具体路径取决于 Profile 的配置。HarmonyOS 的 OPP Profile 没有提供配置保存路径的 API。解法在receiveFileRequest回调中filePath参数就是文件在接收方沙箱中的路径。你需要主动调用fileIo.move或fileIo.copy将文件移动到应用的downloadsAgent目录下或者存到Documents目录需要权限。建议创建自己的接收目录// 在OppReceiver的初始化中指定接收目录constreceiveDirgetContext(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 (入口页面)EntryComponentstruct 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是否在发现设备后的有效时间内调用超过发现窗口期会导致配对失败。QA2DP 播放没有声音但日志显示连接成功A检查你的设备是否支持 A2DP Sink。手机作为 Sink 的实现通常是有限的。建议使用专门的蓝牙音箱或车机进行测试。另外连接的远端设备手机需要开始播放音乐本地才会收到音频流。QOPP 协议是否能传输大文件A理论上可以但实际表现与设备蓝牙芯片性能有关。超过 100MB 的文件建议使用其他方式如 WiFi 直连或断点续传机制。OPP 协议没有内置断点续传功能。如果你在实际开发中也遇到了蓝牙相关的其他问题比如配对超时或连接状态跟踪混乱建议重点排查生命周期管理和回调注册时机。官方文档对这部分描述比较简略结合真机日志是比较可靠的调试手段。