HarmonyOS 7 空间音频导览连载04(共08篇):音源切换与故障回退【共创稿事节】

展馆中的讲解资源并非只有一种。普通语音面向所有参观者,部分展区还会提供多声道氛围声或带空间定位信息的声音作品。用户在入口听完简短介绍,转到数字展厅时,应用需要选择合适的音源;如果该资源无法取得、编码不被播放链路接受或用户切换了输出设备,又应平稳回到可访问的语音讲解。最容易出错的做法,是在点击列表项时立即修改 AVPlayer.fdSrc,不管旧播放器正处于 playing、prepared 还是 error。这一篇在连载03的 GuidePlaybackService 上建立切换协调层,用明确的候选顺序与异步队列收敛最终状态。

展馆音源选择与兼容资源回退策略

一、先区分业务音源等级与实际可播资源

连载02 的 GuideNode 包含 sourceTier 和 voiceAsset。前者表达内容制作类别,后者是具体文件名。SourceTier.OBJECT_AUDIO 只是业务标签,不应被当作系统自动识别的编解码格式。音频能否产生空间感,与文件实际轨道、播放框架、输出设备和系统开关均有关。资源策略负责“优先试哪一个文件”,而不是修改系统音效设置。

本系列采用“优先声效资源 → 常规讲解版本 → 公共讲解版本”的候选顺序。每个候选都必须是实际放入应用 resources/rawfile 的合法音频文件。数字展厅的优先资源沿用 digital.m4a,常规版本记为 digital_voice.mp3;其他展区根据对应 voiceAsset 选择。公共兜底文件 general_guide.mp3 可收录展馆方向与服务提示,不宜拿另一件展品的讲解替换本节点内容。

条件选择策略页面反馈
首选资源存在且可准备使用首选资源正常播放
设备空间音频开关不满足优先选择常规版本显示“普通讲解”
首选文件缺失或准备失败尝试同节点的替代版本显示“已切换讲解资源”
所有候选均无法使用保持错误状态提供重试入口
用户快速切换两个节点以最后一次明确选择为准避免旧音频回跳

这里的“开关满足”沿用第一篇的 SpatialSnapshot:supported && systemEnabled && available。它是用户体验策略的输入,不直接判断媒体文件是否兼容。即使设备支持空间音效,应用也应能够播放一般立体声讲解;反过来,设备支持并不保证任何文件都能直接按对象声轨渲染。

二、把候选资源与导览节点分开维护

GuideCatalog 保持展馆节点身份、位置和首选资源的基础数据。新增的 AudioSourcePolicy 只负责把节点和可用条件转换成有序候选列表。这样的分离避免为了兼容某种耳机,直接修改节点主数据。候选的 kind 是页面可理解的资源级别,不参与系统编码选择。

// entry/src/main/ets/services/AudioSourcePolicy.ets
import { GuideNode, SourceTier } from '../model/GuideNode';
import { SpatialSnapshot } from './SpatialStatusService';

export interface AudioCandidate {
  fileName: string;
  kind: 'enhanced' | 'voice' | 'general';
  label: string;
}

export class AudioSourcePolicy {
  public candidates(node: GuideNode, status: SpatialSnapshot): AudioCandidate[] {
    const preferred: boolean = status.available &&
      status.supported && status.systemEnabled;
    const result: AudioCandidate[] = [];
    if (node.id === 'digital') {
      if (preferred && node.sourceTier === SourceTier.OBJECT_AUDIO) {
        result.push({ fileName: node.voiceAsset, kind: 'enhanced',
          label: '空间声效资源' });
      }
      result.push({ fileName: 'digital_voice.mp3', kind: 'voice',
        label: '数字展厅普通讲解' });
    } else {
      result.push({ fileName: node.voiceAsset, kind: 'voice',
        label: '展区讲解' });
    }
    result.push({ fileName: 'general_guide.mp3', kind: 'general',
      label: '展馆公共讲解' });
    return result.filter((candidate: AudioCandidate, index: number): boolean =>
      result.findIndex((entry: AudioCandidate): boolean =>
        entry.fileName === candidate.fileName) === index);
  }
}

实际资源清单应随发布包更新。比如数字展厅没有提供 digital_voice.mp3,就应在策展资源准备阶段补充讲解文件,而不是依赖应用运行时猜测文件名。这里采用固定本地资源,是为了让首选和回退顺序可读、可排查。后续若改为网络资源,策略类仍然可以输出候选项,但资源获取要转由下载与缓存服务处理,并增加网络重试、完整性检查和版本管理。

三、把“切换请求”变成串行任务

连续点击是最容易触发音频竞争的场景。用户先选青铜器展区,随后立即选书画长廊;第一次的文件读取可能尚未完成。若两个 load() 并行执行,旧请求的 prepared 回调可能在后到达,使界面和声音回到已放弃的节点。

这里加入 SourceSwitchCoordinator。每次选择先更新递增序号,新的操作接在现有 Promise 队列之后执行。轮到某次任务时,检查自己是否仍是最新请求;若不是,直接跳过。串行化不等于强行取消底层文件读取,但能够避免新旧播放器同时操作同一播放会话。候选失败时依次尝试,直到播放器完成准备。

// entry/src/main/ets/services/SourceSwitchCoordinator.ets
import { GuideNode } from '../model/GuideNode';
import { GuidePlaybackService } from './GuidePlaybackService';
import { SpatialSnapshot } from './SpatialStatusService';
import { AudioSourcePolicy, AudioCandidate } from './AudioSourcePolicy';

export interface SwitchState {
  selectedId: string;
  sourceLabel: string;
  message: string;
  switching: boolean;
}

export class SourceSwitchCoordinator {
  private policy: AudioSourcePolicy = new AudioSourcePolicy();
  private serial: Promise<void> = Promise.resolve();
  private revision: number = 0;
  private session: GuidePlaybackService;
  private onChange: (s: SwitchState) => void;
  private status: () => SpatialSnapshot;

  constructor(session: GuidePlaybackService,
    status: () => SpatialSnapshot, onChange: (s: SwitchState) => void) {
    this.session = session;
    this.status = status;
    this.onChange = onChange;
  }

  public select(node: GuideNode): Promise<void> {
    const request: number = ++this.revision;
    this.onChange({ selectedId: node.id, sourceLabel: '',
      message: '正在切换讲解资源', switching: true });
    this.serial = this.serial.catch((): void => {}).then(async (): Promise<void> => {
      if (request !== this.revision) { return; }
      const options: AudioCandidate[] =
        this.policy.candidates(node, this.status());
      for (const option of options) {
        if (request !== this.revision) { return; }
        await this.session.load(node, option.fileName, false);
        if (await this.session.waitForReady()) {
          if (request !== this.revision) { return; }
          this.onChange({ selectedId: node.id, sourceLabel: option.label,
            message: option.kind === 'enhanced' ? '已选择声效资源' :
              '已选择兼容讲解资源', switching: false });
          await this.session.toggle();
          return;
        }
      }
      if (request === this.revision) {
        this.onChange({ selectedId: node.id, sourceLabel: '',
          message: '讲解资源不可用,请重新选择或稍后重试', switching: false });
      }
    });
    return this.serial;
  }

  public cancelPending(): void { ++this.revision; }
}

这里还需要处理 AVPlayer.prepare() 的异步确认。连载03中 load() 在 fdSrc 设置后返回,而 prepared 可能稍晚才到达。要让上面的回退循环真正可靠,应为播放服务补充等待函数,用事件驱动的 Promise 表达“准备成功或失败”,而不是读取设置数据源那一瞬间的 snapshot.phase。

// 加入 GuidePlaybackService 类中
public waitForReady(timeoutMs: number = 8000): Promise<boolean> {
  if (this.snapshot.phase === 'ready') { return Promise.resolve(true); }
  if (this.snapshot.phase === 'error') { return Promise.resolve(false); }
  return new Promise<boolean>((resolve) => {
    const previous: PlayObserver | undefined = this.observer;
    let settled: boolean = false;
    const timer = setTimeout((): void => {
      finish(false);
    }, timeoutMs);
    const finish = (ok: boolean): void => {
      if (settled) { return; }
      settled = true;
      clearTimeout(timer);
      this.observer = previous;
      resolve(ok);
    };
    this.observer = (value: GuidePlaySnapshot): void => {
      previous?.(value);
      if (value.phase === 'ready') { finish(true); }
      if (value.phase === 'error') { finish(false); }
    };
  });
}

上面的协调器已经在加载后使用 await this.session.waitForReady() 等待准备完成。由于回调与超时可能接近同时触发,settled 用于确保 Promise 只完成一次。这里的等待只针对媒体准备阶段;真正开始播放以后,界面仍需通过 playing 事件确认播放成功。若应用同时存在多个状态消费者,适宜将单一 observer 扩展为观察者集合,避免临时等待者覆盖其他订阅;可在后续播放会话整合时统一改造。

四、切换过程中保留清楚的用户反馈

白色主题开发环境中的资源策略与切换页面

页面继续使用连载03的 GuidePlaySnapshot 展示进度与播放阶段,增加 SwitchState 记录选择意图和资源类别。用户点击节点时,立即呈现“正在切换讲解资源”;等待候选准备完成后,再显示“空间声效资源”或“兼容讲解资源”。这可以避免界面展示新展品标题,耳中却仍在播放旧展品的问题。

// PlayerPage.ets 中与切换相关的字段和调用
@State switchState: SwitchState = {
  selectedId: '', sourceLabel: '', message: '', switching: false
};
private coordinator?: SourceSwitchCoordinator = undefined;

private initSwitch(): void {
  if (!this.service) { return; }
  this.coordinator = new SourceSwitchCoordinator(
    this.service,
    (): SpatialSnapshot => spatialStatusService.current,
    (state: SwitchState): void => { this.switchState = state; }
  );
}

private chooseNode(nodeId: string): void {
  const node: GuideNode | undefined = this.planner.findById(nodeId);
  if (!node || !node.accessible) { return; }
  this.coordinator?.select(node).catch((error: Error): void => {
    console.error(`Source switch failed: ${error.message}`);
  });
}

将 initSwitch() 放在页面创建 GuidePlaybackService 并订阅快照之后。首次进入可调用 chooseNode('entrance');其它按钮分别传入 'bronze'、'calligraphy'、'digital'。页面离开时先调用 coordinator.cancelPending(),随后释放播放服务,防止迟到的选择继续接入新的资源。

数字展厅音源切换与兼容讲解提示界面

五、避免失败回退变成无限循环

资源回退必须有上界。策略输出的候选列表固定、去重,最多尝试首选、同节点普通讲解、公共讲解三级。失败不应无条件立即重试首选,否则坏文件会反复进入加载阶段,用户始终看不到明确的错误结果。将失败原因归类为“文件不存在、解码失败、准备超时、播放阶段错误”,并在日志中保留节点 ID 与候选文件名,能够迅速定位内容打包或格式兼容问题。

另一种边界情况是系统开关在切换过程中发生变化。例如音频准备期间用户关闭空间音效,第一篇的状态快照可能已更新。对于本地资源,正在准备的候选可以完成当前流程;下一次节点切换重新通过 AudioSourcePolicy 读取最新快照。若业务要求立即切到普通讲解,应在 SpatialStatusService 的状态通知中提交新的 select() 请求,而不是直接在系统事件回调里修改播放器数据源。

需要特别注意失败后的资源释放。上一播放器必须完成 release(),对应 getRawFd() 资源再被 closeRawFd() 关闭,才能把新文件描述符交给下一播放器。使用一条串行队列可以令这个顺序更直观,也方便未来增加播放打断、前后台恢复和路由变更处理。

六、让切换逻辑成为长期可维护的服务

这一阶段形成了一条相对稳定的播放链路:GuideCatalog 确定讲解节点,SpatialStatusService 提供当前输出设备条件,AudioSourcePolicy 生成音源候选,SourceSwitchCoordinator 串行处理用户选择,GuidePlaybackService 完成媒体准备和播放状态上报。以后更换音频版本时,只需调整候选资源表;以后更改页面布局时,不必让按钮直接操作文件描述符。

资源切换和播放状态管理的核心不是尽可能快地调用 play(),而是保证每次最终呈现的节点、资源和播放器状态都对应同一个有效请求。下一篇会在此基础上将方位提示、节点位置与界面联动起来,使声音导览不仅能够可靠播放,也能够反映参观者选择的展区与方向。

相关资料:AVPlayer API 参考、资源管理器 API、空间音频能力介绍、HarmonyOS 开发者文档中心。

Logo

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

更多推荐