HarmonyOS 7 空间音频导览连载08(共08篇):资源释放与集成优化【共创稿事节】

室内展馆导览已经具备设备能力查询、节点目录、音源回退、播放状态机、方位提示、媒体中断与进度恢复。功能组合以后,新的工程问题开始显现:播放页关闭时释放播放器,路由服务还在收设备事件;用户快速切换展品时,上一段加载任务可能在会话关闭后返回;应用重复进入播放页,旧回调与新回调同时修改状态。解决这些问题靠的不是继续添加页面判断,而是统一规定“谁拥有资源、谁注销监听、谁等待异步任务、谁负责关闭会话”。

空间音频导览资源释放与集成优化

一、整理八篇文章形成的资源所有权

整个工程以 GuideNode.id 贯穿导航与讲解,不把展示组件当作系统资源的实际拥有者。前七篇的服务可以分成三类:目录、方向计算一类属于轻量数据与纯计算;AVPlayer、音频路由观察和空间音频状态监听属于系统资源一类;进度持久化与音源切换队列则属于跨页面会话一类。区分它们以后,页面离开只移除界面回调,会话结束才释放媒体与系统监听。

资源建立位置页面隐藏会话结束
导览节点目录GuideCatalog保留可保留不可变数据
播放器 AVPlayerGuidePlaybackService根据媒体策略管理取消订阅并释放
原始音频文件句柄播放资源加载阶段由播放器会话持有在播放器释放后关闭
音频路由监听AudioRouteService可停止路由界面观察必须取消注册
方位页面观察者GuideDirectionState清除页面观察者清除业务观察者
进度写入队列GuideResumeStore按需补写等待最后一次写入

只要把 release() 放在每个页面 aboutToDisappear() 中,所有权就会在多个页面之间反复转移。一个页面离开可能是路由切换,不一定代表导览结束;一个 Ability 销毁才是更强的资源收尾信号。与此同时,应用可能被系统回收,因此重要进度必须在之前的后台回调中保存,不能等到销毁时才写入。

二、为整个导览会话建立统一关闭入口

工程在已有结构下新增 GuideSessionCoordinator.ets。它并非第二个播放器,只负责把第03~07篇的服务串起来。SourceSwitchCoordinator 的异步选择统一由协调器调度,避免资源已释放后,较早的 load() 操作又创建新的 AVPlayer。关闭应当是幂等动作,多次点击“结束导览”只执行同一轮清理流程。

entry/src/main/ets/
├── model/GuideNode.ets
├── model/GuideCatalog.ets
├── services/GuidePlaybackService.ets
├── services/SourceSwitchCoordinator.ets
├── services/AudioRouteService.ets
├── services/GuideDirectionState.ets
├── services/GuideResumeStore.ets
├── services/GuideProgressController.ets
├── services/GuideSessionCoordinator.ets
└── pages/PlayerPage.ets
// services/GuideSessionCoordinator.ets
import { GuidePlaybackService } from './GuidePlaybackService';
import { AudioRouteService } from './AudioRouteService';
import { GuideDirectionState } from './GuideDirectionState';
import { GuideProgressController } from './GuideProgressController';

export class GuideSessionCoordinator {
  private readonly playback: GuidePlaybackService;
  private readonly route: AudioRouteService;
  private readonly direction: GuideDirectionState;
  private readonly progress: GuideProgressController;
  private queue: Promise<void> = Promise.resolve();
  private closing: boolean = false;
  private closeTask?: Promise<void> = undefined;

  constructor(playback: GuidePlaybackService, route: AudioRouteService,
    direction: GuideDirectionState, progress: GuideProgressController) {
    this.playback = playback;
    this.route = route;
    this.direction = direction;
    this.progress = progress;
  }

  public enqueue(action: () => Promise<void>): Promise<void> {
    if (this.closing) { return Promise.reject(new Error('导览会话已经关闭')); }
    const task = this.queue.catch((): void => {}).then(async (): Promise<void> => {
      if (!this.closing) { await action(); }
    });
    this.queue = task.catch((error: Error): void => {
      console.error(`导览音源处理异常:${error.message}`);
    });
    return task;
  }

  public close(): Promise<void> {
    if (!this.closeTask) {
      this.closing = true;
      this.closeTask = this.closeInternal();
    }
    return this.closeTask;
  }

  private async closeInternal(): Promise<void> {
    await this.queue.catch((): void => {});
    this.progress.unwatch();
    this.direction.unlisten();
    this.route.stop();
    try {
      await this.progress.flush();
    } finally {
      await this.playback.release();
    }
  }
}

外部切换入口应写成 session.enqueue(() => coordinator.select(node)),而不是绕过队列直接调用切换器。close() 首次调用就设置 closing,后续切换请求将被拒绝;已经进入队列的操作完成后,再执行释放。若业务中存在下载、解码或定位等额外异步任务,也要接入各自的取消信号,并在关闭前等待它们完成。调用方可以用 await session.close() 得到清理结束的语义,而不需要靠固定延迟猜测资源状态。

这里也暴露出一个必要的优化:SourceSwitchCoordinator 的内部串行队列可以继续保留,但所有入口必须共用一个外层会话调度器,避免多个页面各自实例化相同协调器。若播放器实例创建晚于关闭信号,enqueue 内的再次检查会阻止后续开始;实际关闭仍需等待已经开始的任务结束,然后释放其持有资源。

三、释放顺序比释放次数更重要

连载03的 GuidePlaybackService.clearPlayer() 已经先 off('stateChange')、off('timeUpdate')、off('error'),之后 await player.release(),最后关闭 getRawFd() 取得的原始文件句柄。这条顺序要保留。资源句柄在播放器仍使用它时提前关闭,可能造成读流异常;先将播放器指针设为空,并由回调比较当前实例,也能减少旧媒体事件对新页面的影响。

可为资源关闭增加错误隔离,确保播放器释放异常时仍尝试关闭文件句柄:

// GuidePlaybackService 类中替换清理方法的核心逻辑
private async clearPlayer(): Promise<void> {
  const old = this.player;
  const name = this.rawName;
  this.player = undefined;
  this.rawName = '';
  try {
    if (old) {
      old.off('stateChange');
      old.off('timeUpdate');
      old.off('error');
      old.off('audioInterrupt');
      await old.release();
    }
  } finally {
    if (name) {
      await this.context.resourceManager.closeRawFd(name);
    }
  }
}

audioInterrupt 是连载06添加的媒体中断观察,注册了才需要在这里取消;如果业务集中维护独立的回调引用,也可以在注销时传入相同回调。AudioRouteService.stop() 内必须取消它自己登记的设备与首选输出监听,不能依赖媒体播放器的释放顺带清理。监听解除之后仍可能收到此前排队的异步回调,因此发布状态时还要判断会话是否已经关闭。

DevEco Studio 中的会话清理服务与界面状态

四、应用生命周期中的清理不能一刀切

界面关闭、用户结束导览、Ability 销毁是三个不同的动作。播放详情页离开只应调用 progress.unwatch();用户点击“结束导览”可调用 session.close();UIAbility.onDestroy() 则作为最后一道会话清理入口。与此同时,onBackground() 已在连载07负责进度落盘,它不必每次都立即释放播放器,否则用户只是临时查看消息也会失去当前会话。是否继续后台播放应遵守音频中断与后台任务策略,不能仅以页面可见性决定。

// pages/PlayerPage.ets 中的收尾按钮和页面生命周期
private session: GuideSessionCoordinator = guideRuntime.session;

aboutToDisappear(): void {
  guideRuntime.progress.unwatch();
}

private finishTour(): void {
  this.session.close().then((): void => {
    this.finishMessage = '导览已结束,讲解进度已保存';
  }).catch((error: Error): void => {
    this.finishMessage = '导览结束时出现资源清理异常';
    console.error(error.message);
  });
}

// build() 中的操作区域
Button('结束本次导览')
  .onClick((): void => this.finishTour())

上面的 finishMessage 是页面已有的 @State 文本字段,guideRuntime 是在连载07中由当前 Ability 持有的统一业务实例。关闭后的运行时不应被继续当作可用播放器;再次开始一次导览,应重新创建全新的会话协调器、播放器和进度订阅关系。这比把 closing 标记简单改回 false 更可靠,因为已释放的 AVPlayer 对象不能直接重新投入使用。

五、把错误处理从页面回调收拢到服务层

音源准备失败、空间音频当前不可用、文件句柄关闭异常以及本地写入失败,属于不同的故障域。页面只负责展示面向用户的状态文字,例如“讲解资源暂时不可用”或“已保存上次进度”。服务层记录节点标识、音源名称、媒体状态和错误码,供排查时复现业务条件。要避免错误路径里无限重试:候选音源由连载04限制数量,关闭流程只执行一次,恢复进度只在已准备的播放器上操作。

如果 progress.flush() 抛错而 playback.release() 成功,协调器依然应该向上反馈持久化失败,这也是 try/finally 的意义。反过来,播放器释放失败并不允许继续用旧实例播放。为了进一步提高健壮性,可以分别捕获路由停止、播放释放、文件句柄关闭的异常并汇总到日志;不过每个步骤仍必须有清楚的错误处理与清理顺序,不能用空的 catch 把所有故障吞掉。

页面快速进入又退出时,最典型的失序不是某个函数没写,而是资源操作的先后次序失控。比如 load() 正等待文件句柄,用户同时结束会话;如果关闭流程没有等待加载完成,新的播放器可能随后出现。把所有播放器相关动作放到同一队列,配合状态标志阻止新动作,是比分散的布尔变量更稳定的实现方式。

六、在整套工程中做功能级检查

把入口、青铜器展区、书画长廊和数字展厅依次加入路线,选择一个节点后通过 SourceSwitchCoordinator 获取可用音源,播放服务发出状态快照,方向服务负责位置与目标提示。接着切换设备,检查系统焦点处理是否尊重用户暂停;切到后台后重进页面,确认恢复记录仍指向正确的节点;最后点击结束导览,再次进入时新建会话,确认旧服务不再推送事件。

关注的观察点包括:同一会话的播放器数量、路由监听订阅次数、已关闭句柄数量、重复的状态回调、后台写入延迟和恢复位置误差。它们属于可用于调试的指标,不需要预先编造性能数字。为了减少界面频繁刷新,可在 timeUpdate 中保留毫秒精度,但只在可见页面把位置格式化为秒级文本;地图节点不变时,避免每次播放进度更新都重新计算整张路线。

结束导览后的会话状态和路线记录

七、从单项能力走向稳定的导览系统

这一系列的主要收获在于明确职责。空间音频状态告诉页面当前音频能力是否满足要求;节点目录给每段声音一个稳定身份;播放状态机负责确认实际播放;音源回退避免增强内容失败时整段讲解中断;方向服务只处理展馆平面方位;中断服务尊重系统媒体策略;进度存储为重新进入提供上下文;会话协调器让这一切有统一的清理入口。

一个稳定的导览应用既要在用户眼前给出正确反馈,也要在页面不可见、音源切换和会话结束时维持清晰的资源所有权。工程优化的重点不是堆叠监听器,而是让生命周期、异步任务与播放状态在相同的业务边界内收敛。后续扩展新的展馆或更多声音节点时,可以继续复用这一套服务关系,而不必重写播放器与页面之间的连接方式。

相关官方资料包括 Media Kit 中 AVPlayer 接口、窗口生命周期、UIAbility 组件生命周期 和 用户首选项 Preferences。

Logo

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

更多推荐