HarmonyOS 7 空间音频导览连载07(共08篇):播放进度与前后台恢复【共创稿事节】

参观者在青铜器展区听讲解时,可能切去查看展馆信息,随后重新进入播放页面。如果当前讲解原本停在一分二十八秒,再次打开却从零开始,路线与声音的连续性就被打断了。更隐蔽的问题是页面隐藏之后,播放器仍有事件更新,但页面不再需要刷新;切回前台时又重新绑定了一组事件,使同一个进度回调重复执行。本篇沿用前六篇建立的 GuideNode、GuidePlaybackService、SourceSwitchCoordinator 和 AudioRouteService,把播放位置保存、前后台切换与界面订阅整理为一条明确的状态链路。

展馆讲解的播放进度与前后台恢复

一、先把进度归属于导览节点

播放位置不是播放器的永久属性。AVPlayer 被释放以后,其当前位置也随实例结束;展馆业务需要保存的是“哪一件展品、资源版本、停在什么位置、用户是否主动暂停”。节点主键必须使用此前建立的 id,而不是列表下标。即使策展人员把青铜器展区从第二站调整成第三站,记录仍能找到相同的内容。

恢复快照不直接保存“正在播放”作为无条件续播指令。播放器在后台可能受到系统音频中断,用户也可能已经主动点击暂停。比较稳妥的策略是:恢复节点与进度,界面显示“继续讲解”,只有用户确认或满足既定媒体策略时才恢复播放。空间音频支持状态仍由第一篇的设备能力服务重新取得,不能把旧设备的空间声效判断写进进度文件。

字段作用写入规则
nodeId对应导览节点切换目标后更新
positionMs毫秒级播放位置进度发生有效变化时节流保存
durationMs内容时长播放器取得时长后更新
savedAt最近一次保存时间用于排查过期记录
sourceName实际音源文件名音源切换后与进度一起更新

一个节点的增强音源与普通讲解通常时长不同,因此不能把前者的时间戳直接用于后者。连载04已有音源候选策略,当 sourceName 与当前选择的资源不一致时,可以清零恢复位置,避免拖到错误的段落。对完成播放的节点也要采取单独规则:进度到达尾部时,重新进入应呈现“重新播放”,而不是继续停在末端。

二、使用 Preferences 持久化轻量播放快照

工程沿用 entry/src/main/ets,在 services 下增加 GuideResumeStore.ets,在 pages 下扩展已有 PlayerPage.ets。这里不增加数据库层:恢复记录体积小、字段固定,Preferences 以键值形式保存更直接。官方的 getPreferences、put 和 flush 提供异步接口,适合在页面首次进入时读取,在关键切换点写入。

entry/src/main/ets/
├── model/GuideNode.ets
├── model/GuideCatalog.ets
├── services/GuidePlaybackService.ets
├── services/SourceSwitchCoordinator.ets
├── services/AudioRouteService.ets
├── services/GuideResumeStore.ets
└── pages/PlayerPage.ets
// services/GuideResumeStore.ets
import { common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';

export interface GuideResumePoint {
  nodeId: string;
  sourceName: string;
  positionMs: number;
  durationMs: number;
  savedAt: number;
}

export class GuideResumeStore {
  private readonly context: common.UIAbilityContext;
  private handle?: preferences.Preferences = undefined;
  private pending: Promise<void> = Promise.resolve();

  constructor(context: common.UIAbilityContext) {
    this.context = context;
  }

  private async open(): Promise<preferences.Preferences> {
    if (!this.handle) {
      this.handle = await preferences.getPreferences(this.context,
        { name: 'guide_playback_progress' });
    }
    return this.handle;
  }

  public async read(): Promise<GuideResumePoint | undefined> {
    const store = await this.open();
    const data = await store.get('last_progress', '');
    if (typeof data !== 'string' || data.length === 0) {
      return undefined;
    }
    try {
      const item = JSON.parse(data) as GuideResumePoint;
      if (!item.nodeId || !item.sourceName ||
          !Number.isFinite(item.positionMs) || item.positionMs < 0 ||
          !Number.isFinite(item.durationMs) || item.durationMs < 0) {
        return undefined;
      }
      return item;
    } catch (_) {
      return undefined;
    }
  }

  public write(point: GuideResumePoint): Promise<void> {
    const task = this.pending.catch((): void => {}).then(async (): Promise<void> => {
      const store = await this.open();
      await store.put('last_progress', JSON.stringify(point));
      await store.flush();
    });
    this.pending = task;
    return task;
  }

  public async finishWrites(): Promise<void> {
    await this.pending;
  }
}

pending 使连续写入按照发起次序落盘,减少旧值覆盖新值的机会。业务层仍应限制写入频率,不能把 timeUpdate 的每一次回调都转化为磁盘写操作。Preferences 写入失败后应在界面保留当前状态,并在下一次关键节点尝试保存,而不是立即终止当前讲解。存储层只接受可序列化数据,不保存播放器实例、文件句柄或回调函数。

三、把播放器事件转成受控的进度保存

连载03的 GuidePlaybackService 已经在 stateChange 与 timeUpdate 里发布 GuidePlaySnapshot。这里增加一个协调器作为唯一长期订阅者,由协调器再向页面转发。这样页面销毁时只清除自己的回调,不影响播放器的进度追踪。若沿用第三篇“页面离开立即 release”的写法,恢复功能将无法形成完整会话;这次把播放服务的所有权上移到当前 UIAbility 的导览运行时。

// services/GuideProgressController.ets
import { GuidePlaybackService, GuidePlaySnapshot } from './GuidePlaybackService';
import { GuideResumeStore, GuideResumePoint } from './GuideResumeStore';

export class GuideProgressController {
  private readonly player: GuidePlaybackService;
  private readonly storage: GuideResumeStore;
  private sourceName: string = '';
  private lastSavedMs: number = -1;
  private view?: (snapshot: GuidePlaySnapshot) => void;

  constructor(player: GuidePlaybackService, storage: GuideResumeStore) {
    this.player = player;
    this.storage = storage;
    this.player.observe((value: GuidePlaySnapshot): void => {
      this.view?.(value);
      if (value.phase === 'completed') {
        this.save(true).catch((e: Error) => console.error(e.message));
      } else if (value.phase === 'playing' &&
                 Math.abs(value.positionMs - this.lastSavedMs) >= 5000) {
        this.save(false).catch((e: Error) => console.error(e.message));
      }
    });
  }

  public setSourceName(fileName: string): void {
    this.sourceName = fileName;
    this.lastSavedMs = -1;
  }

  public watch(callback: (snapshot: GuidePlaySnapshot) => void): void {
    this.view = callback;
    callback(this.player.snapshot);
  }

  public unwatch(): void { this.view = undefined; }

  public async save(finished: boolean = false): Promise<void> {
    const snap = this.player.snapshot;
    if (!snap.nodeId || !this.sourceName) { return; }
    const duration = Math.max(0, snap.durationMs);
    const position = finished ? duration : Math.max(0,
      Math.min(snap.positionMs, duration > 0 ? duration : snap.positionMs));
    const record: GuideResumePoint = {
      nodeId: snap.nodeId, sourceName: this.sourceName,
      positionMs: position, durationMs: duration, savedAt: Date.now()
    };
    await this.storage.write(record);
    this.lastSavedMs = position;
  }

  public async flush(): Promise<void> {
    await this.save();
    await this.storage.finishWrites();
  }
}

与上一节配合时,SourceSwitchCoordinator 在确定实际使用的候选文件后调用 setSourceName,而不是在用户点击节点的瞬间写入候选名。保存操作遵循真实播放结果,失败的增强音源不会污染成功回退到普通语音的记录。后台保存需捕获 flush() 的异常,避免把存储错误传递为媒体播放错误。对于写入密集的场景,可以采用更长的节流间隔,并在暂停、切换节点和切后台时补写一次。

DevEco Studio 中的进度恢复与日志观察

四、恢复前确认节点、音源与播放器状态

加载恢复记录时,需要先从 GuideCatalog 或 GuidePlanner 中查找节点,并确认节点仍可访问。播放服务接入的音源策略可能在设备切换后选择了另一份音频,因此先比较资源名,再比较长度;长度变化明显时应把恢复位置重置为零。AVPlayer.seek 使用毫秒数,只有在 prepared、playing、paused 或其他文档允许的状态下才执行。将刚创建但尚未准备完成的实例直接拖动到旧进度,容易遇到状态不匹配的异常。

在已有 GuidePlaybackService 内增加一个受控入口,页面不要直接持有 AVPlayer:

// 加入 GuidePlaybackService 类中
public restorePosition(positionMs: number): boolean {
  const player = this.player;
  if (!player || !Number.isFinite(positionMs)) { return false; }
  if (player.state !== 'prepared' && player.state !== 'paused' &&
      player.state !== 'playing' && player.state !== 'completed') {
    return false;
  }
  const duration = Math.max(0, player.duration);
  const safe = Math.max(0, Math.min(Math.floor(positionMs),
    duration > 1000 ? duration - 1000 : duration));
  player.seek(safe);
  return true;
}

调用方执行 load(node, matchedSource, false),等待连载04提供的 waitForReady() 返回,再调用 restorePosition();false 表示资源准备完毕后保持等待状态,由用户点击“继续讲解”触发播放。seekDone 可以作为进度定位完成的反馈,不应假设 seek() 调用后声音已经开始。若恢复记录指向的文件已被移除,可以清除旧进度并按连载04的候选策略载入普通讲解。

五、页面生命周期与 Ability 生命周期职责不同

aboutToAppear 和 aboutToDisappear 属于 ArkUI 组件生命周期;onForeground 和 onBackground 属于 UIAbility 生命周期。用户切换页面、关闭播放详情页或应用进入后台,这几件事不应统一做“释放全部资源”。在本系列中,页面只订阅 GuideProgressController,并通过 @State 显示最新播放快照:

// PlayerPage.ets 核心连接段,runtime 为当前 UIAbility 持有的实例
@State currentTitle: string = '尚未选择展品';
@State playPosition: number = 0;
@State playDuration: number = 0;
@State phaseText: string = '等待播放';

aboutToAppear(): void {
  guideRuntime.progress.watch((snapshot: GuidePlaySnapshot): void => {
    this.currentTitle = snapshot.title;
    this.playPosition = snapshot.positionMs;
    this.playDuration = snapshot.durationMs;
    this.phaseText = snapshot.message;
  });
}

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

这里的 guideRuntime 是业务运行时单例,创建时持有 GuidePlaybackService、GuideResumeStore 和 GuideProgressController,并在 Ability 创建时完成初始化。与上一节的 watch() 相比,onForeground 的工作是重新读取必要的环境状态、让可见页面立即取得当前快照;onBackground 负责尽早保存位置,并根据产品策略暂停导览讲解。若希望在后台持续提供媒体播放,需要再按媒体会话和后台任务相关指南规划服务生命周期,而不是单凭页面保持对象引用。

播放进度恢复与继续讲解界面

六、沿实际用户操作顺序定位问题

从序厅选择青铜器展区,播放一段讲解后主动暂停,然后切到别的应用。此时应以用户暂停为准保存进度;返回导览后,页面显示同一节点与保存位置,播放按钮仍等待用户操作。另一条路径是播放中进入后台,随后收到系统媒体中断,恢复前台时不能绕过连载06的焦点决策而直接续播。还有一条路径是从青铜器改听书画长廊:要先等资源切换最终结果确定,再更新 sourceName,否则旧时长会写到新节点上。

调试时关注四个时间点:播放器 timeUpdate 接收时间、持久化排队时间、落盘完成时间、页面重新绑定时间。持久化队列可能短暂滞后,故意将“进度已经排队”与“已经完成写入”区分开。HiLog 中记录节点标识和当前位置即可,避免把整个媒体对象或过多数据写入日志。若定位失败,检查是否在 prepared 之前调用 seek、是否替换了唯一观察者、是否误把手动暂停解释成系统临时暂停。

系统将页面放入后台,并不意味着播放实例必然销毁;系统也可能在资源紧张时回收应用进程,因此关键位置不能只保存在内存。Preferences 的数据属于应用本地持久化信息,适合简短恢复记录;资源原文件仍应由包内 resources/rawfile 提供。对状态恢复而言,“记录属于哪个节点”与“当前能否安全继续”是两道不同的判断,必须分开处理。

七、形成可以继续维护的进度协议

从连载03的媒体事件,到连载04的音源回退,再到连载06的音频中断,本篇增加的持久化只承担记录职责,不更改播放器状态机本身。进度栏统一使用毫秒数据源,界面显示前转换成分秒;页面退出时解绑,Ability 进入后台时补写,恢复前先查节点和音源,最后在用户确认后继续播放。这样即使展区顺序调整、耳机更换或页面反复进入,导览仍能维持稳定的语义。

官方资料可参考 用户首选项 Preferences、UIAbility 生命周期 与 Media Kit 音视频播放器接口。下一篇在这套运行时基础上整理资源持有、监听解绑与会话收尾,让系列形成完整闭环。

Logo

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

更多推荐