《HarmonyOS技术精讲-蓝牙》传统蓝牙实战:音频播放与文件传输

HarmonyOS技术精讲-蓝牙:传统蓝牙实战,音频播放与文件传输

开篇:为什么传统蓝牙在 HarmonyOS 里反而成了"老大难"

很多人一提到蓝牙开发,第一反应就是低功耗蓝牙(BLE)。但在实际的 HarmonyOS 项目里,传统蓝牙(BR/EDR)的需求一点也不少。比如车载场景的音频播放、设备间无网络环境下的文件传输,这些场景都必须走传统蓝牙。

问题在于,官方文档对传统蓝牙的"音频播放"和"文件传输"这两块,示例代码写得比较简略。很多开发者照着文档跑通了一次配对连接,结果发现:音频流就是不走 A2DP Sink 输出,或者文件传输明明显示成功,却不知道文件存哪里了。

这篇文章不扯别的,直接解决两个具体痛点:

  1. 如何让 HarmonyOS 设备作为 A2DP Sink(音频接收端)播放远端蓝牙音频
  2. 如何通过 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_BLUETOOTHohos.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);
      }
    });
  }
}

关键点sendFilefileUri 必须是 应用沙箱内的文件路径 ,或者是通过 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.movefileIo.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,除非你能确定没有其他页面在使用该监听。

最佳实践:稳定传输的三点建议

  1. runStart 阶段初始化 Profile :不要在 build() 或组件的 aboutToAppear 里初始化蓝牙 Profile。因为 Profile 创建涉及底层初始化,可能会卡住 UI 线程。推荐在 EntryAbilityonCreate 或 App 启动时初始化为一个全局单例。

  2. 异步回调内慎用全局数据 :蓝牙的回调可能发生在后台线程。如果在回调内部直接修改页面的 @State 变量,需要确保页面还活着。推荐使用 this.state = newValue 的方式更新,并配合 @Monitor@Watch 来触发 UI 刷新。

  3. 显式处理超时createBondconnect 都没有内置超时机制。如果对端设备没有响应,你的 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 协议没有内置断点续传功能。


如果你在实际开发中也遇到了蓝牙相关的其他问题,比如配对超时或连接状态跟踪混乱,建议重点排查生命周期管理和回调注册时机。官方文档对这部分描述比较简略,结合真机日志是比较可靠的调试手段。

相关推荐
山内桜良12 小时前
HarmonyOS中,html 与 ets 桥接沟通
华为·html·harmonyos
言乐62 小时前
Python视频相对亮度检测
数据库·python·计算机视觉·小程序·音视频
心中有国也有家3 小时前
AtomGit Flutter 鸿蒙客户端:初始化流水线
学习·flutter·华为·harmonyos
在书中成长3 小时前
HarmonyOS 小游戏《对战五子棋》开发第30篇 - TwoPlayerPage双人对战(二):状态栏与玩家指示器
harmonyos
爱吃大芒果3 小时前
ArkUI V2 状态管理极简教程,手写待办清单 Demo
华为·harmonyos
汤姆yu3 小时前
面向具身智能的物理视频模拟器:蚂蚁灵波LingBot-Video开源模型全解析
java·大数据·人工智能·gpt·开源·大模型·音视频
USB_ABC3 小时前
视频矩阵核心技术对比:模块化vs固定机箱、硬件帧同步vs软件同步怎么选
矩阵·音视频
水龙吟啸4 小时前
华为2026.6.17机考选择题+编程题【速刷敲黑板】
人工智能·深度学习·算法·华为