空间声场已经能保存三个固定声源的位置,也有了头部方位插值和会话控制器。真正麻烦的收尾往往发生在用户没有“正常结束”的时候:页面被返回操作销毁、播放初始化仍在等待、耳机输出发生变化,或者刚点击恢复又立刻切到其他页面。如果将清理全部寄托在一个页面退出回调上,异步操作仍可能在资源释放之后继续写入旧会话。

这一篇延续SpatialScene,不另起一个声音播放器。钢琴S1坐标(-2,0,3)、雨声S2坐标(2,0,4)、提示音S3坐标(0,1,2)保持不变;头部朝向15°时的相对方位仍为-48.69°、11.57°、-15°。要新增的是一次性关闭、播放暂停后的恢复条件、异常场景的回退,以及资源属于哪个生命周期。声位数字可以跨页面编辑状态保留,音频渲染对象则应按播放会话的真实生存期管理。

空间声场关闭序列与资源持有关系

一、停止播放与释放资源要分清层级

停止通常意味着当前流停止输出,但播放器实例仍可以根据具体接口契约保留;释放意味着其底层音频资源与回调持有关系结束。二者合成一条同步按钮事件,容易发生重复释放,也不容易处理暂停过程中页面消失的情况。第三篇的应用状态中已有CLOSING与CLOSED,这里把它们用于关闭协议:进入CLOSING之后拒绝新的播放请求,等待正在进行的操作落定,再释放适配层持有的实例。

声场快照并不跟着播放器一起删除。三组世界坐标、用户最后的头部角度和声源选择属于应用数据;音频播放位置、AudioRenderer对象和事件监听属于会话资源。若用户只是短暂暂停,应当保留后者供恢复使用;若页面所属的会话关闭,保留业务快照但主动结束播放器资源。这一点是避免“返回页面后声源全部归零”的关键。

关闭路径还需要有明确优先级。由业务按钮发起的暂停可以继续保持编辑;系统事件提示播放不可继续时,状态先切入暂停或错误待恢复;用户彻底离开页面后,关闭标记优先于任何尚未完成的恢复操作。开发者可以通过这套顺序建立断言,而不依赖恰好发生的回调先后顺序。

二、以单飞关闭阻止双重释放

要解决的问题是两个入口几乎同时调用退出:页面离开与工具栏的结束按钮都会触发close()。如果每个入口各自执行stop和release,就可能在第二次调用时碰到无效音频句柄。将关闭任务缓存成同一个Promise,即使多处发起,也只会关闭一次。以下控制器保持独立,调用方可用第三篇的PlaybackPort连接具体AudioRenderer。

type CloseState = 'IDLE' | 'READY' | 'RUNNING' |
  'PAUSED' | 'CLOSING' | 'CLOSED';

export interface AudioResourcePort {
  stopIfRunning(): Promise<void>;
  release(): Promise<void>;
}

export class CloseOnceGate {
  state: CloseState = 'IDLE';
  private sequence: number = 0;
  private pending: Promise<void> | undefined;
  constructor(private resource: AudioResourcePort) {}

  currentGeneration(): number { return this.sequence; }
  accepts(generation: number): boolean {
    return generation === this.sequence &&
      this.state !== 'CLOSING' && this.state !== 'CLOSED';
  }

  close(): Promise<void> {
    if (this.pending) return this.pending;
    if (this.state === 'CLOSED') return Promise.resolve();
    const previous = this.state;
    this.state = 'CLOSING';
    this.sequence += 1;
    this.pending = (async () => {
      try {
        if (previous === 'RUNNING' || previous === 'PAUSED') {
          await this.resource.stopIfRunning();
        }
      } finally {
        try { await this.resource.release(); }
        finally { this.state = 'CLOSED'; }
      }
    })();
    return this.pending;
  }
}

控制器中的sequence不是文章编号,而是当前会话世代。每次关闭递增世代,先前携带旧值的异步回调即使完成,也无法改变新页面的状态。异常处理采用finally确保释放动作获得执行机会;释放本身可能失败,所以实际适配层还应记录错误并清空引用,后续重新进入场景时必须重新创建资源。不要在CLOSED状态下恢复旧播放器。

三、让AudioRenderer适配层承担底层生命周期

应用状态机不能代替真正的音频接口调用。Audio Kit的AudioRenderer具有开始、暂停、停止、释放等操作,也能使用stateChange订阅播放状态。适配层应当保存唯一的renderer引用,操作期间检查当前句柄是否存在;释放时先断开本会话的事件映射,再按状态停流并销毁音频对象。以下代码展示一个可与上一节AudioResourcePort配合的简化持有者,流写入与内容来源仍由播放管线自己管理。

import { audio } from '@kit.AudioKit';

export class RendererOwner {
  private renderer: audio.AudioRenderer | undefined;
  private running: boolean = false;
  private disposed: boolean = false;

  attach(renderer: audio.AudioRenderer): void {
    if (this.disposed || this.renderer) throw new Error('renderer ownership');
    this.renderer = renderer;
  }

  async start(): Promise<void> {
    if (this.disposed || !this.renderer) return;
    await this.renderer.start();
    this.running = true;
  }

  async pause(): Promise<void> {
    if (this.disposed || !this.renderer || !this.running) return;
    await this.renderer.pause();
    this.running = false;
  }

  async stopIfRunning(): Promise<void> {
    const instance = this.renderer;
    if (!instance || !this.running) return;
    await instance.stop();
    this.running = false;
  }

  async release(): Promise<void> {
    if (this.disposed) return;
    this.disposed = true;
    const instance = this.renderer;
    this.renderer = undefined;
    this.running = false;
    if (instance) await instance.release();
  }
}

对于因外部中断而改变的流状态,running只是应用操作意图,不能替代AudioRenderer实际的state。生产工程可以通过stateChange回调修正该标志,在销毁前撤销订阅,并限制该事件只更新它所归属的会话。若stop()失败,退出路径仍应争取release();若release报错,已标记为失效的引用也不应重新暴露给页面使用。图中关闭后的按钮禁用,就是这种所有权约束在交互层的直接体现。

四、用世代标记处理迟到的声位和播放事件

第三篇的SceneSnapshot有修订号,第二篇的头部插值器有时间戳。这里再加一层会话世代,三种数字分别解决不同问题:时间戳决定头部轨迹的新旧、修订号决定声位配置的新旧、世代决定当前回调有没有权利更新页面。它们不能合并为一个计数器,否则页面重新进入时很难解释旧渲染回调的处理结果。

export interface ViewSnapshot {
  selectedId: string;
  headYaw: number;
  sceneRevision: number;
}

export class SpatialViewBinding {
  private mounted: boolean = false;
  private generation: number = 0;
  private snapshot: ViewSnapshot = {
    selectedId: 's1', headYaw: 15, sceneRevision: 2
  };

  mount(): number {
    this.mounted = true;
    this.generation += 1;
    return this.generation;
  }

  applyAsyncResult(fromGeneration: number, patch: ViewSnapshot): boolean {
    if (!this.mounted || fromGeneration !== this.generation) return false;
    if (patch.sceneRevision < this.snapshot.sceneRevision) return false;
    this.snapshot = { ...patch };
    return true;
  }

  unmount(): ViewSnapshot {
    this.mounted = false;
    this.generation += 1;
    return { ...this.snapshot };
  }
}

页面退出时可以先调用unmount()让旧异步结果失去写入资格,再将ViewSnapshot保存在应用数据层,随后执行会话关闭。这样,用户下次进入仍能看到钢琴选中、头部15°和原有的声源配置。新的renderer需要重新创建,不应把旧的音频对象从持久化数据中取回。特别是头部序列在重新进入时可能从较小时间戳重新开始,姿态轨迹控制器也应该按新会话重置接受窗口,而不是误判新数据为旧数据。

开发环境中的会话资源持有与过期回调拦截

五、恢复与回退:保持可操作,但不要忽略失败状态

恢复流程只有在应用会话处于PAUSED且资源仍有效时才继续同一播放器;从CLOSED进入必须重新初始化。遇到设备路由改变或播放异常时,应先读取音频状态并决定是否恢复音频流,不能简单把界面按钮颜色改成运行中。需要时可以将声位视图保持在可编辑状态,把播放操作置为待重试,避免声源位置因音频异常被覆盖。对于空间相关效果,若当前音频链路暂时无法使用空间处理节点,应用可以在界面中保持世界坐标数据,再按确定的音频路径播放普通媒体内容;不能把应用层计算的角度值误当成已作用于每路声音的系统效果。

为了让定位更具体,建议用一张事件表组织复现条件:正常暂停后恢复、暂停期间退出、初始化期间退出、两次并发关闭、旧姿态晚到、释放失败、页面重新进入。检查时逐项确认状态机是否进入预期目标,release()调用次数是否至多一次,旧世代结果有没有被拒绝,以及新的页面是否重新获得可用资源。这里不需要靠猜测界面截图判断播放器是否已关闭,而是根据接口状态变化和适配层资源计数来解释生命周期。

暂停与释放后的声场配置保留

六、把三篇积累的约束收拢到一个稳定工程

回头看整个空间声场系列,数据层的职责从未改变:第一篇定义世界坐标与方位,第二篇处理头部角度时间线,第三篇用有修订号的快照衔接播放会话,这一篇完成关闭与恢复控制。音频接口的配置和资源归属则集中在适配层,不让三组声源组件直接创建各自的播放器。这样可以更清楚地区分“声源位置保持正确”与“播放资源已经准备就绪”,也能避免页面切换时最棘手的重入问题。

最终保留下来的不是一个永久驻留的音频句柄,而是可独立保存和恢复的声场快照。控制器关闭后,仍能读取钢琴、雨声、提示音的坐标;再次进入则获得新的会话世代,并按当前播放条件重新申请音频资源。这种数据与资源分离的做法,也适合其他涉及异步硬件能力和复杂页面生命周期的HarmonyOS应用。

参考资料:AudioManager与空间音频管理器、音频播放流状态管理、Audio Kit媒体播放用途配置。

Logo

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

更多推荐