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()。

四、页面只显示设备和中断反馈

DevEco Studio 中的路由监听与音频事件界面

新的 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 参考、音频输出设备切换技术说明。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐