HarmonyOS 7 空间音频与室内导览交互连载06(共08篇):音频路由与中断处理【共创稿事节】
HarmonyOS 7 空间音频导览连载06(共08篇):音频路由与中断处理【共创稿事节】
参观者在序厅戴着耳机收听讲解,走到青铜器展区时把耳机摘下,系统将音频输出转到扬声器;又或者播放过程中收到来电,讲解被暂时暂停。这时展品节点、导览路线和讲解文件都没有改变,变化的是系统输出路径与音频焦点。如果应用一收到设备连接事件就重建播放器,容易引发音频重复;如果在通话结束后无条件续播,又可能违背用户刚刚手动暂停的意图。可靠的处理应先监听并确认系统实际状态,再把必要的变化反馈到已有播放会话。

一、先区分设备连接、输出路由与媒体中断
本系列前五篇已经建立节点目录、空间音频状态、音源回退、播放状态机与方位页面。当前仍使用 GuidePlaybackService 托管 AVPlayer,由 SourceSwitchCoordinator 处理切换。第六篇增加两个独立的入口:AudioRoutingManager 负责告诉应用“媒体流优先输出设备变了”,AVPlayer 的 audioInterrupt 事件负责告诉应用“当前音频焦点需要怎样处理”。
设备连接变化与媒体输出变化是不同的事实。蓝牙耳机连上了,不一定马上成为媒体流的优先输出;耳机断开后优先设备通常需要重新查询,不能仅根据事件里消失的设备名称猜测接下来的路由。为此,服务应同时订阅 deviceChange 和 preferOutputDeviceChangeForRendererInfo,随后统一读取 getPreferredOutputDeviceForRendererInfoSync()。查询使用媒体流 STREAM_USAGE_MEDIA,与讲解播放用途保持一致。
| 事件类型 | 需要更新的状态 | 不应发生的动作 |
|---|---|---|
| 耳机连接或断开 | 可连接设备列表、当前优先输出 | 直接覆盖展品 ID |
| 媒体优先输出改变 | 输出名称、空间音频条件 | 自动重置播放进度 |
| 焦点要求暂停 | 播放提示、恢复入口 | 假定用户一定想续播 |
| 焦点允许恢复 | 可继续播放的交互状态 | 立即切换到另一讲解 |
| 用户主动暂停 | 用户意图标记 | 由系统恢复提示取消标记 |
由此可以把界面划为三块:当前展品、音频输出、播放提示。前者只随节点选择改变,后两者依靠系统事件与媒体事件更新。这样即使用户还在同一个展区,也能准确观察耳机切换与播放状态。
二、监听优先输出并发布快照
在 entry/src/main/ets/services 下增加 AudioRouteService.ets。它从 AudioManager 获取路由管理器,用固定的媒体用途查询优先输出。服务暴露一个观察者和成对的 start()、stop(),由播放会话所有者决定启动与关闭的时机。路由对象不直接持有 AVPlayer,只发布设备信息;音源策略可在下一次资源切换时读取最新空间音频条件。
// entry/src/main/ets/services/AudioRouteService.ets
import { audio } from '@kit.AudioKit';
export interface RouteSnapshot {
deviceName: string;
deviceType: audio.DeviceType | undefined;
spatialSupported: boolean;
message: string;
}
export class AudioRouteService {
private routing: audio.AudioRoutingManager =
audio.getAudioManager().getRoutingManager();
private rendererInfo: audio.AudioRendererInfo = {
usage: audio.StreamUsage.STREAM_USAGE_MEDIA,
rendererFlags: 0
};
private started: boolean = false;
private observer?: (value: RouteSnapshot) => void;
private deviceListener: (value: audio.DeviceChangeAction) => void =
(_value: audio.DeviceChangeAction): void => this.refresh();
private preferListener: (value: audio.AudioDeviceDescriptors) => void =
(_value: audio.AudioDeviceDescriptors): void => this.refresh();
public snapshot: RouteSnapshot = {
deviceName: '正在读取', deviceType: undefined,
spatialSupported: false, message: '等待输出设备信息'
};
public listen(observer: (value: RouteSnapshot) => void): void {
this.observer = observer;
this.observer(this.snapshot);
}
public refresh(): void {
try {
const preferred: audio.AudioDeviceDescriptors =
this.routing.getPreferredOutputDeviceForRendererInfoSync(
this.rendererInfo);
const first: audio.AudioDeviceDescriptor | undefined = preferred[0];
this.snapshot = {
deviceName: first ? first.name : '未找到媒体输出设备',
deviceType: first?.deviceType,
spatialSupported: first?.spatializationSupported ?? false,
message: first ? '媒体输出已更新' : '等待设备可用'
};
} catch (error) {
this.snapshot = {
...this.snapshot, message: '读取输出路由失败,请刷新'
};
console.error(`刷新输出路由失败:${JSON.stringify(error)}`);
}
this.observer?.(this.snapshot);
}
public start(): void {
if (this.started) { return; }
this.routing.on('deviceChange',
audio.DeviceFlag.OUTPUT_DEVICES_FLAG, this.deviceListener);
this.routing.on('preferOutputDeviceChangeForRendererInfo',
this.rendererInfo, this.preferListener);
this.started = true;
this.refresh();
}
public stop(): void {
if (!this.started) { return; }
this.routing.off('deviceChange', this.deviceListener);
this.routing.off('preferOutputDeviceChangeForRendererInfo',
this.preferListener);
this.started = false;
this.observer = undefined;
}
}
路由变化回调统一转到 refresh(),避免分别维护两套推断逻辑。首个描述符是当前媒体用途的优先设备,后续需要展示全部连接设备时,再使用 getDevices() 读取列表。设备名称只用于用户可理解的提示,不能作为设备身份的唯一键,因为同类耳机可能显示相似名称。空间音频支持情况也需要和第一篇的系统开关快照组合使用,不能由单一 spatialSupported 得出最终声效状态。
三、让 AVPlayer 的中断事件尊重用户意图
系统焦点事件由当前播放实例上报。对于已在播放的讲解,INTERRUPT_HINT_PAUSE 表示应进入暂停语义,INTERRUPT_HINT_STOP 表示停止当前播放活动,INTERRUPT_HINT_DUCK 表示减小声音,INTERRUPT_HINT_UNDUCK 表示恢复原音量策略。事件还携带 forceType:强制类型表示系统已执行相应处理,共享类型则需要应用按提示调整当前播放器。不能只根据电话应用是否进入前台推断中断状态。
在连载03的 GuidePlaybackService 增加一个轻量焦点策略,保留 userPaused 标记。页面点击暂停时设置该标记,系统中断只维护 focusHeld,不会清除用户暂停意图。媒体实例创建后注册一次中断监听,释放前对同一个回调取消订阅。
// GuidePlaybackService.ets 类内新增的字段与方法
private userPaused: boolean = false;
private focusHeld: boolean = false;
private focusHint: string = '';
private focusObserver?: (text: string) => void;
private interruptListener = (event: audio.InterruptEvent): void => {
switch (event.hintType) {
case audio.InterruptHint.INTERRUPT_HINT_PAUSE:
case audio.InterruptHint.INTERRUPT_HINT_STOP:
this.focusHeld = true;
this.focusHint = '讲解已因系统音频事件暂停';
if (event.forceType === audio.InterruptForceType.INTERRUPT_SHARE &&
this.player?.state === 'playing') {
this.player.pause().catch((err: Error): void => {
console.error(`暂停讲解失败:${err.message}`);
});
}
break;
case audio.InterruptHint.INTERRUPT_HINT_RESUME:
this.focusHeld = false;
this.focusHint = this.userPaused ? '已按用户操作保持暂停' :
'音频焦点已恢复,可继续讲解';
break;
case audio.InterruptHint.INTERRUPT_HINT_DUCK:
this.focusHint = '系统正在降低讲解音量';
break;
case audio.InterruptHint.INTERRUPT_HINT_UNDUCK:
this.focusHint = '讲解音量策略已恢复';
break;
default:
break;
}
this.focusObserver?.(this.focusHint);
};
public onFocusHint(observer: (text: string) => void): void {
this.focusObserver = observer;
}
public bindInterrupt(): void {
this.player?.on('audioInterrupt', this.interruptListener);
}
public unbindInterrupt(): void {
this.player?.off('audioInterrupt', this.interruptListener);
this.focusObserver = undefined;
}
public setUserPaused(paused: boolean): void {
this.userPaused = paused;
}
public canResume(): boolean {
return !this.focusHeld && !this.userPaused &&
this.player?.state === 'paused';
}
// GuidePlaybackService.ets 文件顶部需要的音频类型导入
import { audio } from '@kit.AudioKit';
bindInterrupt() 应放在 createAVPlayer() 成功且播放器事件注册完成之后;unbindInterrupt() 则在 release() 前调用。用户按下“继续”按钮后,页面先通过 setUserPaused(false) 记录明确意图,再检查 canResume(),最终仍由已有的播放服务调用媒体播放方法,并等待 stateChange('playing') 更新播放栏。上面的方法只表达焦点策略,不新建第二个播放器;播放器准备、暂停与资源释放仍归前两篇服务所有。
这里特别要区分 INTERRUPT_HINT_RESUME 与“用户点击播放”。前者表示系统焦点策略允许恢复,并不自动意味着用户希望重新收听;如果用户刚刚主动暂停,界面应继续显示暂停状态。遇到 STOP 后,还要结合 AVPlayer 当前状态决定是否重新准备文件,不能无视播放器状态直接发起 play()。
四、页面只显示设备和中断反馈

新的 AudioRouteService 可以被导览页面订阅,播放页面同时订阅焦点文字。以 @State 保存输出名称、空间音频支持状态及提示词,用户离开页面时撤销 UI 观察者;若播放会话仍需保持,则路由监听应由会话管理层继续持有,避免组件销毁同时撤销整个应用的音频事件。
// 播放页面中用于呈现路由与中断信息的核心逻辑
@State outputName: string = '正在读取';
@State spatialSupportText: string = '待确认';
@State focusText: string = '可以开始讲解';
private routeService: AudioRouteService = new AudioRouteService();
aboutToAppear(): void {
this.routeService.listen((route: RouteSnapshot): void => {
this.outputName = route.deviceName;
this.spatialSupportText = route.spatialSupported ?
'设备支持空间音频' : '采用常规音频输出';
});
this.routeService.start();
this.playbackService.onFocusHint((hint: string): void => {
this.focusText = hint;
});
}
aboutToDisappear(): void {
this.routeService.stop();
}
// build() 里的状态卡片
Column({ space: 8 }) {
Text('当前音频输出').fontSize(14).fontColor('#68788C')
Text(this.outputName).fontSize(21).fontWeight(FontWeight.Bold)
Text(this.spatialSupportText).fontSize(13)
Text(this.focusText).fontSize(14).fontColor('#146CF3')
}.padding(16).borderRadius(16).backgroundColor('#F0F6FF')
此段展示的是接入已有播放页的增量代码,playbackService 为连载03已经创建的会话字段;页面完整 build() 仍保留原来的展品标题、播放器状态与导览方向卡片。以增量方式连接可以避免把播放器事件与路由服务各创建一份,造成相同事件被多次接收。

五、按真实的事件序列定位问题
音频路由排查建议观察“连接变化—媒体优先路由变化—空间音频状态刷新—播放状态变化”四条线。假如耳机离线后,页面仍显示它作为当前输出,应先确认是否收到 deviceChange,再看 preferOutputDeviceChangeForRendererInfo,随后主动调用 refresh() 检查媒体用途是否一致。即使系统把声音转到扬声器,播放器也可能继续保持 playing,因此不要凭设备断连直接重写播放阶段。
中断处理要关注时序:系统发出暂停事件,随后播放器进入 paused;焦点恢复事件到来时,只改变“可继续”的提示;用户明确点击后,再由播放器事件决定是否改成“播放中”。如果在焦点恢复时程序自行恢复,又遇到用户此前的暂停操作,就会产生抢夺状态的问题。把 userPaused 与 focusHeld 分开,便能清楚地表达这两种来源。
还要注意连续事件和页面生命周期。耳机短时间反复连接可能带来多次刷新,可以在会话层对最终输出快照做相等比较,避免每次都触发大型组件重建。重复打开播放页时,start() 的幂等标记阻止重复注册路由监听;而播放器对象替换时必须取消旧对象的 audioInterrupt 回调,随后在新播放器上重新绑定。资源切换队列仍由连载04管理,设备事件只提供策略判断的最新依据。
六、让播放决策留在会话层
至此,导览业务已经可以按展馆节点选择内容、根据平面坐标展示方向、按媒体事件更新播放状态,再根据输出路由和音频焦点改变提示。关键原则是:系统路由事件不等于用户切换节点,焦点恢复事件也不等于用户同意播放。将这些信号分层处理,就能减少重复播放、播放栏误判和界面跳转。
下一篇继续解决进度保存及前后台恢复:页面可以重建,但播放会话所拥有的节点、资源版本、时间位置和用户暂停意图应具有清楚的恢复规则。相关资料:AudioManager API 参考、AVPlayer API 参考、音频输出设备切换技术说明。
更多推荐


所有评论(0)