HarmonyOS开发音频路由:设备管理、输出切换与蓝牙音频策略全解
HarmonyOS开发中的音频路由:设备管理、输出切换与蓝牙音频策略全解
📌 核心要点:掌握 HarmonyOS 音频路由机制——从音频设备发现与监听,到输出设备动态切换,再到蓝牙音频路由策略,实现多设备间的无缝音频流转
一、背景与动机
想象这样一个场景:你正在用手机外放听歌,突然戴上蓝牙耳机,音乐自动从耳机里流出来;摘下耳机,音乐又自动切回扬声器。再比如,你正在用蓝牙音箱放音乐,来了个电话,通话音频自动切到手机听筒。这些"音频自动流转"的体验,背后就是音频路由在起作用。
音频路由,简单来说就是决定"声音从哪里出来"。手机有扬声器、听筒、有线耳机、蓝牙耳机、蓝牙音箱……多个输出设备并存时,系统需要一套规则来决定音频走哪条路。
为什么音频路由如此重要?
- 用户体验:插上耳机声音就自动从耳机出来,这是最基本的期望
- 蓝牙场景:蓝牙设备的连接、断开、切换,都需要正确的路由策略
- 多设备协同:HarmonyOS 的分布式特性,音频可能在多个设备间流转
- 通话场景:来电时音频路由需要快速、准确地切换到听筒或蓝牙
- 应用适配:应用需要感知当前输出设备,做出相应的 UI 和功能调整
二、核心原理
2.1 音频路由架构
HarmonyOS 的音频路由由 AudioRoutingManager 统一管理,它负责设备发现、状态监听和路由决策:
flowchart TB
classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
classDef error fill:#F44336,stroke:#D32F2F,color:#fff
classDef info fill:#2196F3,stroke:#1976D2,color:#fff
classDef purple fill:#9C27B0,stroke:#7B1FA2,color:#fff
A[应用层] --> B[AudioRoutingManager]
B --> C[设备发现]
B --> D[设备监听]
B --> E[路由决策]
B --> F[设备切换]
C --> C1[getDevices]
C --> C2[设备类型识别]
D --> D1[on'deviceChange']
D --> D2[连接/断开事件]
E --> E1[音频流类型]
E --> E2[设备优先级]
E --> E3[系统策略]
F --> F1[selectOutputDevice]
F --> F2[蓝牙A2DP切换]
F --> F3[分布式流转]
G[输出设备] --> H[扬声器 SPEAKER]
G --> I[有线耳机 WIRED_HEADSET]
G --> J[蓝牙A2DP BLUETOOTH_A2DP]
G --> K[蓝牙SCO BLUETOOTH_SCO]
G --> L[听筒 EARPIECE]
G --> M[分布式设备 DISTRIBUTED]
class A primary
class B info
class C primary
class D warning
class E purple
class F error
class G primary
2.2 音频设备类型与优先级
HarmonyOS 定义了多种音频设备类型,系统有一套默认的优先级规则:
| 优先级 | 设备类型 | 枚举值 | 说明 |
|---|---|---|---|
| 1(最高) | 蓝牙 SCO | BLUETOOTH_SCO | 通话专用蓝牙通道 |
| 2 | 有线耳机 | WIRED_HEADSET | 3.5mm 或 USB-C 耳机 |
| 3 | 蓝牙 A2DP | BLUETOOTH_A2DP | 蓝牙耳机/音箱(媒体音频) |
| 4 | 听筒 | EARPIECE | 通话听筒 |
| 5(最低) | 扬声器 | SPEAKER | 外放扬声器 |
默认路由策略:系统会自动选择当前已连接的优先级最高的设备。比如同时插着有线耳机和连着蓝牙耳机,声音会从有线耳机出来。
2.3 蓝牙音频路由
蓝牙音频路由是最复杂的场景,因为蓝牙有两种音频协议:
flowchart LR
classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
classDef error fill:#F44336,stroke:#D32F2F,color:#fff
classDef info fill:#2196F3,stroke:#1976D2,color:#fff
classDef purple fill:#9C27B0,stroke:#7B1FA2,color:#fff
A[蓝牙设备] --> B{协议类型}
B -->|A2DP| C[高级音频分发协议]
B -->|SCO| D[同步面向连接协议]
C --> C1[音乐播放]
C --> C2[视频音频]
C --> C3[游戏音效]
C --> C4[高质量立体声]
D --> D1[语音通话]
D --> D2[语音识别]
D --> D3[低延迟单声道]
class A primary
class B warning
class C info
class D error
A2DP vs SCO:
| 特性 | A2DP | SCO |
|---|---|---|
| 用途 | 媒体音频(音乐、视频) | 语音通话 |
| 音质 | 高(AAC/SBC 编码) | 低(CVSD/msBC 编码) |
| 延迟 | 较高 | 低 |
| 声道 | 立体声 | 单声道 |
| 方向 | 单向(播放) | 双向(播放+录音) |
三、代码实战
3.1 音频设备发现与监听
实现一个完整的音频设备管理面板,实时显示已连接设备,监听设备变化:
import { audio } from '@kit.AudioKit';
import { BusinessError } from '@kit.BasicServicesKit';
// 音频设备信息模型
interface AudioDeviceInfo {
id: number;
deviceType: audio.DeviceType;
deviceName: string;
address: string;
isConnected: boolean;
icon: string;
}
@Entry
@Component
struct AudioDeviceManager {
// 路由管理器
private routingManager: audio.AudioRoutingManager | null = null;
// 音频管理器
private audioManager: audio.AudioManager | null = null;
// 设备列表
@State outputDevices: AudioDeviceInfo[] = [];
@State activeDevice: AudioDeviceInfo | null = null;
@State statusText: string = '未初始化';
aboutToAppear(): void {
this.initAudioManager();
}
aboutToDisappear(): void {
this.removeDeviceListener();
}
/**
* 初始化音频管理器
*/
async initAudioManager(): Promise<void> {
try {
// 获取音频管理器
this.audioManager = audio.getAudioManager();
// 获取路由管理器
this.routingManager = this.audioManager.getRoutingManager();
// 加载当前设备列表
await this.loadDevices();
// 注册设备变化监听
this.registerDeviceListener();
this.statusText = '设备监听已启动';
console.info('[路由] 初始化成功');
} catch (err) {
const error = err as BusinessError;
console.error(`[路由] 初始化失败: ${error.message}`);
this.statusText = '初始化失败';
}
}
/**
* 加载当前已连接的输出设备
*/
async loadDevices(): Promise<void> {
if (!this.routingManager) return;
try {
// 获取所有输出设备
const devices = this.routingManager.getDevices(audio.DeviceFlag.OUTPUT_DEVICES_FLAG);
const deviceList: AudioDeviceInfo[] = [];
for (const device of devices) {
deviceList.push({
id: device.id,
deviceType: device.deviceType,
deviceName: this.getDeviceTypeName(device.deviceType),
address: device.address || '',
isConnected: true, // getDevices 返回的都是已连接的
icon: this.getDeviceIcon(device.deviceType)
});
}
this.outputDevices = deviceList;
// 获取当前活跃设备
const activeDevices = this.routingManager.getDevices(audio.DeviceFlag.OUTPUT_DEVICES_FLAG);
if (activeDevices.length > 0) {
this.activeDevice = {
id: activeDevices[0].id,
deviceType: activeDevices[0].deviceType,
deviceName: this.getDeviceTypeName(activeDevices[0].deviceType),
address: activeDevices[0].address || '',
isConnected: true,
icon: this.getDeviceIcon(activeDevices[0].deviceType)
};
}
} catch (err) {
const error = err as BusinessError;
console.error(`[路由] 加载设备失败: ${error.message}`);
}
}
/**
* 注册设备变化监听
*/
registerDeviceListener(): void {
if (!this.routingManager) return;
this.routingManager.on('deviceChange', audio.DeviceFlag.OUTPUT_DEVICES_FLAG, (deviceChanged: audio.DeviceChangeAction) => {
const type = deviceChanged.type; // 0=连接, 1=断开
const device = deviceChanged.device;
if (type === audio.DeviceChangeType.CONNECT) {
console.info(`[路由] 设备连接: ${this.getDeviceTypeName(device.deviceType)}`);
promptAction.showToast({
message: `${this.getDeviceIcon(device.deviceType)} ${this.getDeviceTypeName(device.deviceType)} 已连接`
});
} else if (type === audio.DeviceChangeType.DISCONNECT) {
console.info(`[路由] 设备断开: ${this.getDeviceTypeName(device.deviceType)}`);
promptAction.showToast({
message: `${this.getDeviceIcon(device.deviceType)} ${this.getDeviceTypeName(device.deviceType)} 已断开`
});
}
// 刷新设备列表
this.loadDevices();
});
}
/**
* 移除设备监听
*/
removeDeviceListener(): void {
if (!this.routingManager) return;
this.routingManager.off('deviceChange', audio.DeviceFlag.OUTPUT_DEVICES_FLAG);
}
/**
* 获取设备类型名称
*/
getDeviceTypeName(type: audio.DeviceType): string {
const names: Record<number, string> = {
[audio.DeviceType.SPEAKER]: '扬声器',
[audio.DeviceType.WIRED_HEADSET]: '有线耳机',
[audio.DeviceType.WIRED_HEADPHONES]: '有线耳机(无麦克风)',
[audio.DeviceType.BLUETOOTH_SCO]: '蓝牙通话',
[audio.DeviceType.BLUETOOTH_A2DP]: '蓝牙音频',
[audio.DeviceType.EARPIECE]: '听筒',
[audio.DeviceType.USB_HEADSET]: 'USB 耳机',
[audio.DeviceType.DISTRIBUTED]: '分布式设备',
};
return names[type] || `未知设备(${type})`;
}
/**
* 获取设备图标
*/
getDeviceIcon(type: audio.DeviceType): string {
const icons: Record<number, string> = {
[audio.DeviceType.SPEAKER]: '🔊',
[audio.DeviceType.WIRED_HEADSET]: '🎧',
[audio.DeviceType.WIRED_HEADPHONES]: '🎧',
[audio.DeviceType.BLUETOOTH_SCO]: '📞',
[audio.DeviceType.BLUETOOTH_A2DP]: '📶',
[audio.DeviceType.EARPIECE]: '📱',
[audio.DeviceType.USB_HEADSET]: '🔌',
[audio.DeviceType.DISTRIBUTED]: '🌐',
};
return icons[type] || '❓';
}
build() {
Column({ space: 16 }) {
// 标题
Text('🔌 音频设备管理')
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text(this.statusText)
.fontSize(14)
.fontColor('#888')
// 当前活跃设备
if (this.activeDevice) {
Column({ space: 8 }) {
Text('当前输出')
.fontSize(14)
.fontColor('#888')
.width('100%')
Row({ space: 12 }) {
Text(this.activeDevice.icon)
.fontSize(32)
Column({ space: 4 }) {
Text(this.activeDevice.deviceName)
.fontSize(18)
.fontWeight(FontWeight.Bold)
if (this.activeDevice.address) {
Text(this.activeDevice.address)
.fontSize(12)
.fontColor('#888')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.borderRadius(12)
.backgroundColor('#252540')
}
.width('90%')
}
// 所有已连接设备
Text('已连接设备')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.width('90%')
if (this.outputDevices.length === 0) {
Text('暂无已连接设备')
.fontSize(14)
.fontColor('#888')
} else {
List({ space: 8 }) {
ForEach(this.outputDevices, (device: AudioDeviceInfo) => {
ListItem() {
Row({ space: 12 }) {
Text(device.icon)
.fontSize(28)
Column({ space: 4 }) {
Text(device.deviceName)
.fontSize(16)
.fontWeight(
this.activeDevice?.id === device.id ? FontWeight.Bold : FontWeight.Normal
)
.fontColor(
this.activeDevice?.id === device.id ? '#4CAF50' : '#fff'
)
Text(`ID: ${device.id}`)
.fontSize(12)
.fontColor('#888')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 当前活跃标记
if (this.activeDevice?.id === device.id) {
Text('✓ 活跃')
.fontSize(12)
.fontColor('#4CAF50')
}
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor(
this.activeDevice?.id === device.id ? 'rgba(76,175,80,0.1)' : '#252540'
)
}
}, (device: AudioDeviceInfo) => `${device.id}`)
}
.width('90%')
.height(300)
}
// 刷新按钮
Button('🔄 刷新设备列表')
.width(200)
.backgroundColor('#2196F3')
.onClick(() => this.loadDevices())
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.padding(20)
.backgroundColor('#1a1a2e')
}
}
3.2 输出设备动态切换
实现音频输出设备的手动切换功能,支持在扬声器、蓝牙、有线耳机之间自由切换:
import { audio } from '@kit.AudioKit';
import { BusinessError } from '@kit.BasicServicesKit';
@Entry
@Component
struct OutputDeviceSwitcher {
// 路由管理器
private routingManager: audio.AudioRoutingManager | null = null;
// 音频渲染器
private audioRenderer: audio.AudioRenderer | null = null;
// 可用输出设备
@State availableDevices: audio.AudioDeviceDescriptors = [];
// 当前选中设备
@State selectedDeviceId: number = -1;
// 切换状态
@State isSwitching: boolean = false;
@State switchResult: string = '';
aboutToAppear(): void {
this.init();
}
aboutToDisappear(): void {
this.release();
}
/**
* 初始化
*/
async init(): Promise<void> {
try {
const audioManager = audio.getAudioManager();
this.routingManager = audioManager.getRoutingManager();
// 创建音频渲染器(用于测试切换效果)
const rendererOptions: audio.AudioRendererOptions = {
streamInfo: {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100,
channels: audio.AudioChannel.CHANNEL_2,
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
},
rendererInfo: {
usage: audio.StreamUsage.STREAM_USAGE_MUSIC,
rendererFlags: 0
}
};
this.audioRenderer = await audio.createAudioRenderer(rendererOptions);
await this.audioRenderer.start();
// 加载设备列表
this.refreshDevices();
// 监听设备变化
this.routingManager.on('deviceChange', audio.DeviceFlag.OUTPUT_DEVICES_FLAG, () => {
this.refreshDevices();
});
} catch (err) {
const error = err as BusinessError;
console.error(`[切换] 初始化失败: ${error.message}`);
}
}
/**
* 刷新可用设备列表
*/
refreshDevices(): void {
if (!this.routingManager) return;
try {
this.availableDevices = this.routingManager.getDevices(audio.DeviceFlag.OUTPUT_DEVICES_FLAG);
// 默认选中第一个设备
if (this.selectedDeviceId === -1 && this.availableDevices.length > 0) {
this.selectedDeviceId = this.availableDevices[0].id;
}
} catch (err) {
console.error('[切换] 刷新设备失败');
}
}
/**
* 切换输出设备
*/
async switchOutputDevice(deviceId: number): Promise<void> {
if (!this.routingManager || !this.audioRenderer) return;
this.isSwitching = true;
this.switchResult = '';
try {
// 找到目标设备
const targetDevice = this.availableDevices.find(d => d.id === deviceId);
if (!targetDevice) {
this.switchResult = '❌ 设备未找到';
return;
}
// 方式一:通过路由管理器全局切换
await this.routingManager.selectOutputDevice(targetDevice);
this.selectedDeviceId = deviceId;
this.switchResult = `✅ 已切换到 ${this.getDeviceTypeName(targetDevice.deviceType)}`;
console.info(`[切换] 输出设备: ${this.getDeviceTypeName(targetDevice.deviceType)}`);
} catch (err) {
const error = err as BusinessError;
this.switchResult = `❌ 切换失败: ${error.message}`;
console.error(`[切换] 失败: ${error.message}`);
} finally {
this.isSwitching = false;
}
}
/**
* 通过渲染器切换(仅影响当前渲染器的输出)
*/
async switchRendererOutputDevice(deviceId: number): Promise<void> {
if (!this.audioRenderer) return;
this.isSwitching = true;
try {
const targetDevice = this.availableDevices.find(d => d.id === deviceId);
if (!targetDevice) return;
// 方式二:通过渲染器切换(只影响当前流)
await this.audioRenderer.selectOutputDevice([targetDevice]);
this.selectedDeviceId = deviceId;
this.switchResult = `✅ 渲染器输出已切换`;
console.info('[切换] 渲染器输出设备已切换');
} catch (err) {
const error = err as BusinessError;
this.switchResult = `❌ 切换失败: ${error.message}`;
} finally {
this.isSwitching = false;
}
}
/**
* 获取设备类型名称
*/
getDeviceTypeName(type: audio.DeviceType): string {
const names: Record<number, string> = {
[audio.DeviceType.SPEAKER]: '扬声器',
[audio.DeviceType.WIRED_HEADSET]: '有线耳机',
[audio.DeviceType.BLUETOOTH_A2DP]: '蓝牙音频',
[audio.DeviceType.BLUETOOTH_SCO]: '蓝牙通话',
[audio.DeviceType.EARPIECE]: '听筒',
};
return names[type] || '未知';
}
/**
* 获取设备图标
*/
getDeviceIcon(type: audio.DeviceType): string {
const icons: Record<number, string> = {
[audio.DeviceType.SPEAKER]: '🔊',
[audio.DeviceType.WIRED_HEADSET]: '🎧',
[audio.DeviceType.BLUETOOTH_A2DP]: '📶',
[audio.DeviceType.BLUETOOTH_SCO]: '📞',
[audio.DeviceType.EARPIECE]: '📱',
};
return icons[type] || '❓';
}
/**
* 释放资源
*/
async release(): Promise<void> {
try {
this.routingManager?.off('deviceChange', audio.DeviceFlag.OUTPUT_DEVICES_FLAG);
await this.audioRenderer?.release();
} catch (err) {
console.error('[切换] 释放失败');
}
}
build() {
Column({ space: 16 }) {
Text('🔀 输出设备切换')
.fontSize(24)
.fontWeight(FontWeight.Bold)
// 切换结果
if (this.switchResult) {
Text(this.switchResult)
.fontSize(14)
.fontColor(this.switchResult.startsWith('✅') ? '#4CAF50' : '#F44336')
.padding(8)
.borderRadius(8)
.backgroundColor('#252540')
}
// 设备列表
Text('选择输出设备')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.width('90%')
List({ space: 8 }) {
ForEach(this.availableDevices, (device: audio.AudioDeviceDescriptor) => {
ListItem() {
Row({ space: 12 }) {
Text(this.getDeviceIcon(device.deviceType))
.fontSize(28)
Column({ space: 4 }) {
Text(this.getDeviceTypeName(device.deviceType))
.fontSize(16)
.fontWeight(
this.selectedDeviceId === device.id ? FontWeight.Bold : FontWeight.Normal
)
.fontColor(
this.selectedDeviceId === device.id ? '#4CAF50' : '#fff'
)
Text(`ID: ${device.id} | 地址: ${device.address || '内置'}`)
.fontSize(12)
.fontColor('#888')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 选中标记
Radio({ value: `${device.id}`, group: 'outputDevice' })
.checked(this.selectedDeviceId === device.id)
.onChange((isChecked: boolean) => {
if (isChecked) {
this.switchOutputDevice(device.id);
}
})
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor(
this.selectedDeviceId === device.id ? 'rgba(76,175,80,0.1)' : '#252540'
)
}
}, (device: audio.AudioDeviceDescriptor) => `${device.id}`)
}
.width('90%')
.layoutWeight(1)
// 刷新按钮
Button('🔄 刷新')
.width(120)
.onClick(() => this.refreshDevices())
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.padding(20)
.backgroundColor('#1a1a2e')
}
}
3.3 蓝牙音频路由策略管理
针对蓝牙场景的复杂路由需求,实现一个策略化的蓝牙音频路由管理器:
import { audio } from '@kit.AudioKit';
import { BusinessError } from '@kit.BasicServicesKit';
// 蓝牙音频路由策略
enum BluetoothRouteStrategy {
AUTO = 'auto', // 自动(系统默认)
A2DP_ONLY = 'a2dp', // 仅 A2DP(强制媒体音频走蓝牙)
SCO_ONLY = 'sco', // 仅 SCO(强制通话音频走蓝牙)
SPEAKER_FALLBACK = 'spk' // 蓝牙不可用时回退到扬声器
}
@Entry
@Component
struct BluetoothRouteManager {
// 路由管理器
private routingManager: audio.AudioRoutingManager | null = null;
// 蓝牙设备列表
@State bluetoothDevices: audio.AudioDeviceDescriptors = [];
// 当前策略
@State currentStrategy: BluetoothRouteStrategy = BluetoothRouteStrategy.AUTO;
// 蓝牙连接状态
@State isBluetoothConnected: boolean = false;
@State connectedDeviceName: string = '';
// 当前活跃的蓝牙设备类型
@State activeBtType: string = '无';
// 状态日志
@State routeLogs: string[] = [];
aboutToAppear(): void {
this.initRouteManager();
}
aboutToDisappear(): void {
this.cleanup();
}
/**
* 初始化路由管理器
*/
async initRouteManager(): Promise<void> {
try {
const audioManager = audio.getAudioManager();
this.routingManager = audioManager.getRoutingManager();
// 初始加载蓝牙设备
this.refreshBluetoothDevices();
// 监听设备变化
this.routingManager.on('deviceChange', audio.DeviceFlag.OUTPUT_DEVICES_FLAG, (action: audio.DeviceChangeAction) => {
const device = action.device;
const isBt = device.deviceType === audio.DeviceType.BLUETOOTH_A2DP ||
device.deviceType === audio.DeviceType.BLUETOOTH_SCO;
if (isBt) {
if (action.type === audio.DeviceChangeType.CONNECT) {
this.addLog(`🔵 蓝牙设备已连接: ${this.getDeviceTypeName(device.deviceType)}`);
this.isBluetoothConnected = true;
this.applyStrategy(this.currentStrategy);
} else {
this.addLog(`⚪ 蓝牙设备已断开: ${this.getDeviceTypeName(device.deviceType)}`);
this.isBluetoothConnected = false;
this.handleBluetoothDisconnect();
}
this.refreshBluetoothDevices();
}
});
this.addLog('✅ 蓝牙路由管理器已启动');
} catch (err) {
const error = err as BusinessError;
console.error(`[蓝牙路由] 初始化失败: ${error.message}`);
}
}
/**
* 刷新蓝牙设备列表
*/
refreshBluetoothDevices(): void {
if (!this.routingManager) return;
const allDevices = this.routingManager.getDevices(audio.DeviceFlag.OUTPUT_DEVICES_FLAG);
this.bluetoothDevices = allDevices.filter(d =>
d.deviceType === audio.DeviceType.BLUETOOTH_A2DP ||
d.deviceType === audio.DeviceType.BLUETOOTH_SCO
);
this.isBluetoothConnected = this.bluetoothDevices.length > 0;
if (this.bluetoothDevices.length > 0) {
this.connectedDeviceName = this.getDeviceTypeName(this.bluetoothDevices[0].deviceType);
this.activeBtType = this.bluetoothDevices[0].deviceType === audio.DeviceType.BLUETOOTH_A2DP ? 'A2DP' : 'SCO';
} else {
this.connectedDeviceName = '';
this.activeBtType = '无';
}
}
/**
* 应用蓝牙路由策略
*/
async applyStrategy(strategy: BluetoothRouteStrategy): Promise<void> {
if (!this.routingManager) return;
this.currentStrategy = strategy;
try {
switch (strategy) {
case BluetoothRouteStrategy.AUTO:
// 自动模式:让系统决定路由
this.addLog('📋 策略: 自动(系统默认)');
// 不做任何干预,系统自动选择最优设备
break;
case BluetoothRouteStrategy.A2DP_ONLY:
// 强制 A2DP:找到 A2DP 设备并切换
if (this.bluetoothDevices.length > 0) {
const a2dpDevice = this.bluetoothDevices.find(d =>
d.deviceType === audio.DeviceType.BLUETOOTH_A2DP
);
if (a2dpDevice) {
await this.routingManager.selectOutputDevice(a2dpDevice);
this.addLog('📋 策略: 强制A2DP,已切换到蓝牙音频');
} else {
this.addLog('⚠️ 无A2DP设备可用');
}
}
break;
case BluetoothRouteStrategy.SCO_ONLY:
// 强制 SCO:找到 SCO 设备并切换
if (this.bluetoothDevices.length > 0) {
const scoDevice = this.bluetoothDevices.find(d =>
d.deviceType === audio.DeviceType.BLUETOOTH_SCO
);
if (scoDevice) {
await this.routingManager.selectOutputDevice(scoDevice);
this.addLog('📋 策略: 强制SCO,已切换到蓝牙通话');
} else {
this.addLog('⚠️ 无SCO设备可用');
}
}
break;
case BluetoothRouteStrategy.SPEAKER_FALLBACK:
// 蓝牙不可用时回退到扬声器
if (!this.isBluetoothConnected) {
const speaker = this.routingManager.getDevices(audio.DeviceFlag.OUTPUT_DEVICES_FLAG)
.find(d => d.deviceType === audio.DeviceType.SPEAKER);
if (speaker) {
await this.routingManager.selectOutputDevice(speaker);
this.addLog('📋 策略: 回退到扬声器');
}
}
break;
}
} catch (err) {
const error = err as BusinessError;
this.addLog(`❌ 策略应用失败: ${error.message}`);
}
}
/**
* 处理蓝牙断开
*/
async handleBluetoothDisconnect(): Promise<void> {
if (this.currentStrategy === BluetoothRouteStrategy.SPEAKER_FALLBACK) {
await this.applyStrategy(BluetoothRouteStrategy.SPEAKER_FALLBACK);
} else {
this.addLog('ℹ️ 蓝牙已断开,系统将自动选择其他输出设备');
}
}
/**
* 获取设备类型名称
*/
getDeviceTypeName(type: audio.DeviceType): string {
const names: Record<number, string> = {
[audio.DeviceType.BLUETOOTH_A2DP]: '蓝牙音频(A2DP)',
[audio.DeviceType.BLUETOOTH_SCO]: '蓝牙通话(SCO)',
[audio.DeviceType.SPEAKER]: '扬声器',
[audio.DeviceType.WIRED_HEADSET]: '有线耳机',
};
return names[type] || '未知';
}
/**
* 添加日志
*/
addLog(message: string): void {
const time = new Date().toLocaleTimeString();
this.routeLogs.unshift(`[${time}] ${message}`);
// 只保留最近 20 条
if (this.routeLogs.length > 20) {
this.routeLogs = this.routeLogs.slice(0, 20);
}
}
/**
* 清理
*/
cleanup(): void {
this.routingManager?.off('deviceChange', audio.DeviceFlag.OUTPUT_DEVICES_FLAG);
}
build() {
Column({ space: 16 }) {
// 标题
Text('📶 蓝牙音频路由管理')
.fontSize(22)
.fontWeight(FontWeight.Bold)
// 蓝牙连接状态
Row({ space: 12 }) {
Text(this.isBluetoothConnected ? '🔵' : '⚪')
.fontSize(24)
Column({ space: 4 }) {
Text(this.isBluetoothConnected ? '蓝牙已连接' : '蓝牙未连接')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(this.isBluetoothConnected ? '#4CAF50' : '#F44336')
if (this.isBluetoothConnected) {
Text(`${this.connectedDeviceName} | 类型: ${this.activeBtType}`)
.fontSize(12)
.fontColor('#888')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('90%')
.padding(16)
.borderRadius(12)
.backgroundColor('#252540')
// 路由策略选择
Column({ space: 12 }) {
Text('路由策略')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.width('100%')
ForEach([
{ strategy: BluetoothRouteStrategy.AUTO, name: '🔄 自动', desc: '系统自动选择最优设备' },
{ strategy: BluetoothRouteStrategy.A2DP_ONLY, name: '🎵 仅A2DP', desc: '强制媒体音频走蓝牙' },
{ strategy: BluetoothRouteStrategy.SCO_ONLY, name: '📞 仅SCO', desc: '强制通话音频走蓝牙' },
{ strategy: BluetoothRouteStrategy.SPEAKER_FALLBACK, name: '🔊 回退扬声器', desc: '蓝牙断开时自动切回扬声器' },
], (item: { strategy: BluetoothRouteStrategy; name: string; desc: string }) => {
Row({ space: 12 }) {
Radio({ value: item.strategy, group: 'btStrategy' })
.checked(this.currentStrategy === item.strategy)
.onChange((isChecked: boolean) => {
if (isChecked) {
this.applyStrategy(item.strategy);
}
})
Column({ space: 2 }) {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Medium)
Text(item.desc)
.fontSize(12)
.fontColor('#888')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(8)
.borderRadius(8)
.backgroundColor(this.currentStrategy === item.strategy ? 'rgba(76,175,80,0.1)' : 'transparent')
})
}
.width('90%')
.padding(16)
.borderRadius(12)
.backgroundColor('#252540')
// 路由日志
Column({ space: 8 }) {
Text('📋 路由日志')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.width('100%')
List({ space: 4 }) {
ForEach(this.routeLogs, (log: string, index: number) => {
ListItem() {
Text(log)
.fontSize(11)
.fontColor('#aaa')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}, (log: string, index: number) => `${index}`)
}
.width('100%')
.height(150)
}
.width('90%')
.padding(16)
.borderRadius(12)
.backgroundColor('#252540')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.padding(20)
.backgroundColor('#1a1a2e')
}
}
四、踩坑与注意事项
4.1 selectOutputDevice 权限问题
问题:调用 selectOutputDevice() 时抛出权限异常。
原因:切换输出设备需要 ohos.permission.MODIFY_AUDIO_SETTINGS 权限。
解决方案:在 module.json5 中声明权限:
"requestPermissions": [
{
"name": "ohos.permission.MODIFY_AUDIO_SETTINGS",
"reason": "$string:audio_settings_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
4.2 蓝牙 SCO 和 A2DP 不能同时使用
问题:通话时想同时用 A2DP 播放背景音,结果通话音频也从 A2DP 出来了,音质很差。
原因:蓝牙同一时间只能使用一种协议。SCO 激活时,A2DP 会被暂停。
解决方案:通话场景必须使用 SCO,通话结束后再切回 A2DP。不要试图在通话中同时使用两种协议。
// 通话开始:切换到 SCO
this.routingManager.selectOutputDevice(scoDevice);
// 通话结束:切换回 A2DP
this.routingManager.selectOutputDevice(a2dpDevice);
4.3 设备变化回调的时序问题
问题:收到蓝牙连接回调后立即调用 selectOutputDevice(),但设备还没完全就绪,导致切换失败。
解决方案:收到连接回调后延迟一小段时间再切换,或检查设备状态后再操作。
this.routingManager.on('deviceChange', audio.DeviceFlag.OUTPUT_DEVICES_FLAG, async (action) => {
if (action.type === audio.DeviceChangeType.CONNECT) {
// 延迟 500ms 等待设备完全就绪
setTimeout(async () => {
try {
await this.routingManager?.selectOutputDevice(action.device);
} catch (err) {
console.warn('[路由] 设备切换延迟后仍失败,可能设备未就绪');
}
}, 500);
}
});
4.4 渲染器级切换 vs 全局切换
问题:使用 audioRenderer.selectOutputDevice() 只切换了当前渲染器的输出,其他应用的音频没变。
原因:AudioRenderer.selectOutputDevice() 只影响当前渲染器流,而 AudioRoutingManager.selectOutputDevice() 是全局切换。
选择建议:
- 音乐播放器等独立应用:使用渲染器级切换,不影响其他应用
- 系统设置类应用:使用全局切换
4.5 分布式音频路由
问题:HarmonyOS 的分布式场景下,音频可能需要流转到其他设备,但路由切换不生效。
解决方案:分布式音频路由需要额外的权限和配置,确保设备在同一分布式网络中,且应用声明了分布式权限。
五、HarmonyOS 6 适配
5.1 API 变更
| 变更项 | HarmonyOS 5 | HarmonyOS 6 |
|---|---|---|
| 设备切换 | selectOutputDevice(device) |
新增 selectOutputDeviceByStrategy(strategy) |
| 蓝牙协议 | A2DP / SCO | 新增 LE Audio(低功耗音频)支持 |
| 设备监听 | on('deviceChange') |
新增 on('preferredDeviceChange') 首选设备变化 |
| 分布式路由 | 手动切换 | 新增 selectDistributedDevice() 自动发现分布式设备 |
| 音频策略 | 无 | 新增 AudioPolicyManager 统一策略管理 |
5.2 迁移指南
// HarmonyOS 5 写法
await this.routingManager.selectOutputDevice(targetDevice);
// HarmonyOS 6 写法(支持策略化切换)
await this.routingManager.selectOutputDeviceByStrategy({
strategy: audio.OutputStrategy.PREFER_BLUETOOTH,
fallbackDevice: audio.DeviceType.SPEAKER
});
5.3 新特性
- LE Audio:新一代蓝牙音频协议,更低延迟、更高音质、更低功耗
- 策略化路由:通过策略描述路由偏好,系统自动选择最优设备
- 分布式音频流转:一键将音频流转到同一网络下的其他 HarmonyOS 设备
- 多流路由:不同音频流可以同时输出到不同设备(如音乐走蓝牙,通知走扬声器)
六、总结
mindmap
root((音频路由))
设备管理
设备发现
getDevices
设备类型识别
设备监听
on deviceChange
连接/断开事件
设备信息
id / deviceType
address / name
输出切换
全局切换
RoutingManager.selectOutputDevice
影响所有音频流
渲染器切换
AudioRenderer.selectOutputDevice
仅影响当前流
切换权限
MODIFY_AUDIO_SETTINGS
蓝牙路由
A2DP协议
高质量媒体音频
立体声输出
SCO协议
通话语音
低延迟双向
策略管理
自动/强制A2DP
强制SCO/回退扬声器
设备优先级
蓝牙SCO 最高
有线耳机
蓝牙A2DP
听筒
扬声器 最低
注意事项
SCO/A2DP互斥
设备就绪延迟
权限声明
分布式场景
核心要点回顾:
- AudioRoutingManager 是核心:所有设备发现、监听、切换都通过它完成
- 设备优先级有规则:蓝牙 SCO > 有线耳机 > 蓝牙 A2DP > 听筒 > 扬声器,系统默认选最高优先级的已连接设备
- 蓝牙双协议互斥:A2DP 和 SCO 不能同时使用,通话必须用 SCO,媒体用 A2DP
- 两种切换粒度:全局切换影响所有流,渲染器切换只影响当前流,按需选择
- 设备变化有延迟:收到连接回调后设备可能还没完全就绪,切换前适当等待
- 点赞
- 收藏
- 关注作者
评论(0)