HarmonyOS 7 空间音频导览连载03(共08篇):音频播放与状态管理【共创稿事节】

展馆导览从“能够找到展品”进入“能够讲解展品”后,最容易出现的问题不是播放器无法创建,而是页面状态与实际声音不同步:用户刚选中青铜器节点,按钮已变为暂停图标,音频资源却还在准备;播放自然结束后,进度条停在末端,按钮仍然显示“暂停”;短时间内重复点击播放,又可能让多个异步操作交错。解决这些问题,需要把上一章节的导览节点模型与媒体播放器状态机衔接起来,让播放器事件驱动界面,而非让界面自行推断媒体结果。

空间音频导览的播放会话与状态流转

一、播放状态应由媒体事件确认

上一章的 GuideNode 已经用稳定的 id 关联讲解文件 voiceAsset。这里继续沿用入口、青铜器展区、书画长廊和数字展厅四个节点。GuidePlanner 负责查找节点,SpatialStatusService 负责输出设备及系统空间音频开关。本篇新增 GuidePlaybackService,专门管理讲解音频的加载、播放、暂停、结束与释放。三者具有不同职责,避免某个页面同时处理路线、设备与音频生命周期。

媒体层选用 @kit.MediaKit 中的 AVPlayer。它能够通过 fdSrc 使用应用 resources/rawfile 里的媒体资源,并通过 stateChange 和 timeUpdate 通知当前播放情况。纯音频场景无需配置视频画面 Surface。播放器支持媒体解码与输出,但空间化听感仍由资源内容、音频输出设备、系统设置及具体渲染链路共同决定;状态服务中的“设备支持”不能直接代替“当前音轨具有三维位置信息”的判断。

播放器事件页面状态按钮处理
initialized已取得资源,准备解码显示“准备中”
prepared可以开始播放允许开始按钮
playing播放中显示暂停按钮
paused已暂停显示继续按钮
completed自然结束显示重新播放
error载入或播放失败给出错误提示

播放器的 idle、initialized、prepared 等状态有合法的迁移顺序。尤其不能在 createAVPlayer() 返回后就直接把界面设置为“播放中”。创建成功只表示获得播放器实例;资源赋值、准备成功、真正开始播放分别对应不同阶段。

二、确定文件位置和状态契约

本次将讲解资源放在 entry/src/main/resources/rawfile/ 下,例如 entrance.mp3、bronze.mp3、painting.mp3 和 digital.m4a。音频内容应与导览目录里的文件名对应。音频资源在构建时随应用封装,页面通过资源管理器获得文件描述符。相较于将原始路径写进 AVPlayer.url,getRawFd() 更符合应用内资源的访问方式。

在现有工程中新增 services/GuidePlaybackService.ets 和 pages/PlayerPage.ets。状态对象既有播放器事件对应的阶段,也保留节点标识、标题、进度、总时长和错误文本,供 ArkUI 一次性更新。

// entry/src/main/ets/services/GuidePlaybackService.ets
import { common } from '@kit.AbilityKit';
import { media } from '@kit.MediaKit';
import { GuideNode } from '../model/GuideNode';

export type GuidePlayPhase =
  'idle' | 'loading' | 'ready' | 'playing' | 'paused' | 'completed' | 'error';

export interface GuidePlaySnapshot {
  nodeId: string;
  title: string;
  phase: GuidePlayPhase;
  positionMs: number;
  durationMs: number;
  message: string;
}

export type PlayObserver = (value: GuidePlaySnapshot) => void;

export class GuidePlaybackService {
  private context: common.UIAbilityContext;
  private player: media.AVPlayer | undefined = undefined;
  private rawName: string = '';
  private observer: PlayObserver | undefined = undefined;
  private busy: boolean = false;
  private autoStart: boolean = false;
  public snapshot: GuidePlaySnapshot = {
    nodeId: '', title: '尚未选择展品', phase: 'idle',
    positionMs: 0, durationMs: 0, message: '选择节点开始讲解'
  };

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

  public observe(callback: PlayObserver): void {
    this.observer = callback;
    callback(this.snapshot);
  }

  public unobserve(): void { this.observer = undefined; }

  private publish(changes: Partial<GuidePlaySnapshot>): void {
    this.snapshot = { ...this.snapshot, ...changes };
    this.observer?.(this.snapshot);
  }

  public async load(node: GuideNode,
    fileName: string = node.voiceAsset, autoStart: boolean = true): Promise<void> {
    if (this.busy) { return; }
    this.busy = true;
    this.autoStart = autoStart;
    try {
      await this.clearPlayer();
      this.publish({ nodeId: node.id, title: node.title, phase: 'loading',
        positionMs: 0, durationMs: 0, message: '正在准备讲解音频' });
      const next: media.AVPlayer = await media.createAVPlayer();
      this.player = next;
      next.on('stateChange', (state: string): void => {
        if (this.player !== next) { return; }
        switch (state) {
          case 'initialized':
            next.prepare().catch((error: Error) => this.report(error));
            break;
          case 'prepared':
            this.publish({ phase: 'ready', durationMs: next.duration,
              message: '讲解音频已准备完成' });
            if (this.autoStart) {
              this.autoStart = false;
              next.play().catch((error: Error) => this.report(error));
            }
            break;
          case 'playing':
            this.publish({ phase: 'playing', message: '正在播放讲解' });
            break;
          case 'paused':
            this.publish({ phase: 'paused', message: '已暂停' });
            break;
          case 'completed':
            this.publish({ phase: 'completed', positionMs: next.duration,
              message: '本节点讲解结束' });
            break;
        }
      });
      next.on('timeUpdate', (position: number): void => {
        if (this.player === next) {
          this.publish({ positionMs: position, durationMs: next.duration });
        }
      });
      next.on('error', (error: Error): void => { this.report(error); });
      const raw = await this.context.resourceManager.getRawFd(fileName);
      this.rawName = fileName;
      next.fdSrc = { fd: raw.fd, offset: raw.offset, length: raw.length };
    } catch (error) {
      this.report(error as Error);
      await this.clearPlayer();
    } finally {
      this.busy = false;
    }
  }

  public async toggle(): Promise<void> {
    const p = this.player;
    if (!p || this.busy) { return; }
    try {
      if (p.state === 'playing') {
        await p.pause();
      } else if (p.state === 'completed') {
        p.seek(0);
        await p.play();
      } else if (p.state === 'prepared' || p.state === 'paused') {
        await p.play();
      }
    } catch (error) { this.report(error as Error); }
  }

  public async release(): Promise<void> {
    this.autoStart = false;
    await this.clearPlayer();
    this.publish({ phase: 'idle', message: '播放会话已结束' });
    this.unobserve();
  }

  private async clearPlayer(): Promise<void> {
    const old = this.player;
    this.player = undefined;
    if (old) {
      old.off('stateChange');
      old.off('timeUpdate');
      old.off('error');
      await old.release();
    }
    if (this.rawName) {
      const name: string = this.rawName;
      this.rawName = '';
      await this.context.resourceManager.closeRawFd(name);
    }
  }

  private report(error: Error): void {
    console.error(`Guide playback: ${error.message}`);
    this.publish({ phase: 'error', message: '讲解资源暂时不可用' });
  }
}

rawName 与实际文件描述符生命周期绑定:切换或释放播放器以后关闭资源管理器打开的文件句柄,避免多个节点反复加载造成句柄滞留。load() 在准备阶段通过 busy 阻挡重复发起的加载操作;下一篇会把多次点击升级为顺序切换队列。stateChange 里的 prepared 才是自动播放的触发点;按钮状态以 playing 事件为准。

三、页面只响应播放快照

白色主题开发环境中的播放服务与页面预览

入口页面从导览目录中选中青铜器展区,构造播放服务并订阅快照。状态对象整体替换,ArkUI 就能更新按钮、进度和文字。按钮在 loading 与 error 时不发起播放操作,也不把“准备中”误判为“已暂停”。

// entry/src/main/ets/pages/PlayerPage.ets
import { common } from '@kit.AbilityKit';
import { GuidePlanner } from '../services/GuidePlanner';
import { GuidePlaybackService, GuidePlaySnapshot } from
  '../services/GuidePlaybackService';
import { GuideNode } from '../model/GuideNode';

@Entry
@Component
struct PlayerPage {
  private planner: GuidePlanner = new GuidePlanner();
  private service?: GuidePlaybackService = undefined;
  @State playback: GuidePlaySnapshot = {
    nodeId: '', title: '', phase: 'idle', positionMs: 0,
    durationMs: 0, message: '请选择展品'
  };

  aboutToAppear(): void {
    const context = getContext(this) as common.UIAbilityContext;
    this.service = new GuidePlaybackService(context);
    this.service.observe((state: GuidePlaySnapshot): void => {
      this.playback = state;
    });
    const node: GuideNode | undefined = this.planner.findById('bronze');
    if (node) { this.service.load(node).catch((e: Error) => console.error(e.message)); }
  }

  aboutToDisappear(): void {
    this.service?.release().catch((e: Error) => console.error(e.message));
  }

  build() {
    Scroll() {
      Column({ space: 18 }) {
        Text('室内导览讲解').fontSize(28).fontWeight(FontWeight.Bold)
        Text(this.playback.title).fontSize(22)
          .fontColor('#163455')
        Text(this.playback.message).fontSize(15).fontColor('#64748B')
        Progress({ value: this.playback.positionMs,
          total: Math.max(1, this.playback.durationMs) })
          .color('#2178ED').width('100%')
        Text(`${Math.floor(this.playback.positionMs / 1000)} 秒 / ` +
          `${Math.floor(this.playback.durationMs / 1000)} 秒`)
          .fontSize(13).fontColor('#64748B')
        Button(this.playback.phase === 'playing' ? '暂停讲解' : '播放讲解')
          .enabled(['ready', 'playing', 'paused', 'completed']
            .includes(this.playback.phase))
          .width('100%')
          .onClick((): void => { this.service?.toggle(); })
      }.width('100%').padding(24)
    }.backgroundColor('#F4F7FB')
  }
}

青铜器导览讲解播放界面

四、处理准备、暂停与自然结束的时序

timeUpdate 返回的是毫秒进度,页面用 Math.floor(positionMs / 1000) 转成秒。不要在界面上通过计时器自行累加进度:播放器可能因数据准备、用户暂停或系统中断而停止推进,计时器估值会逐渐偏离音轨真实位置。总时长也应在 prepared 后读取,未准备好时维持零值而不是预估时长。

若用户点击“播放”时播放器仍处于 loading,界面保持按钮不可用;准备完成后,由 autoStart 意图决定是否继续。暂停时等待播放器进入 paused,再改变控件文字。自然播放结束进入 completed,下次点击先 seek(0) 再开始。这样重新播放不会只让页面进度归零,却仍播放上一条音轨末尾内容。

观察到的问题优先检查处理方式
资源一直准备中文件是否在 rawfile,描述符是否有效检查文件名及资源封装
点击暂停后按钮仍旧不变是否收到 paused 事件以系统事件更新快照
进度卡住timeUpdate 是否继续上报核对当前播放器状态
切换节点后还播旧声音旧播放器是否释放将切换过程串行化
播放结束无法再次开始completed 后是否归零重播前执行 seek(0)

五、明确状态发布的边界与观察点

导览音频有两条容易被混淆的时间线:用户意图和媒体事实。用户点击节点属于意图,表示希望进入哪件展品;initialized、prepared、playing 等事件属于媒体事实。页面可以立即高亮被选中的展品,却应在收到媒体状态事件后再更新播放按钮。两类状态必须分别存储,即使它们在正常情况下几乎同时改变,也不要用一次点击直接覆盖播放器阶段。

进度刷新同样应遵循事件来源。播放器发出 timeUpdate 时,以媒体提供的位置覆盖当前进度;用户拖动滑块时,可以暂存预期跳转位置,等 seekDone 确认后再和播放进度收敛。这样可以降低拖动控件与自动进度回写相互争抢的概率。当前页面只显示进度,后续加入拖动功能时再补充 seekDone 监听和滑块交互锁。

资源释放应放在会话生命周期的明确位置。若应用采用页面级播放,离开页面时撤销事件监听、释放播放器、关闭资源描述符,顺序要固定。如果需要后台继续播放,则应把会话所有权移至更上层的服务,页面离开只取消 UI 观察者。无论哪一种方案,重复进入页面都应避免给同一播放器注册多份 stateChange 回调。

排查多次点击的竞态时,可以记录节点 ID、媒体事件名称与当前文件名,而不必记录用户个人信息。常见的异常序列是“选择甲节点—选择乙节点—甲节点准备完成”;看到这种时序后,优化方向应是统一请求队列并过滤过期事件,而不是在每个按钮上增加固定延时。下一篇将围绕该序列实现完整的资源替换与候选回退。

为播放时长建立纯计算工具

时间显示应与播放器状态分开。播放器只报告毫秒级当前位置,界面使用统一格式化方法,避免连续切换节点时遗留上一资源的时间字符串。

// model/PlaybackTime.ets
export function displayTime(millis: number): string {
  const seconds = Math.max(0, Math.floor(millis / 1000));
  const minutes = Math.floor(seconds / 60);
  const remain = seconds % 60;
  return `${minutes.toString().padStart(2, '0')}:${remain.toString().padStart(2, '0')}`;
}

六、为错误与音源切换保留清晰入口

出现错误时,页面不应继续显示“播放中”。report() 将异常汇总到错误阶段,同时保留日志中的具体错误消息。生产界面可以提供“重试当前节点”操作;重试应重新取得 rawfile 描述符并创建新的播放器,而不是直接在错误状态连续调用 play()。另一种常见问题是快速连续选择多个展品,旧播放器的准备回调晚于新节点请求,导致旧声音突然出现。本篇通过单个服务约束操作入口,下一篇则引入请求序号和切换队列,使最后一次有效选择成为最终播放目标。

当前 GuidePlaybackService 在页面出现时构造、离开时释放,适合音频随播放页结束的交互方式。未来若希望锁屏后继续讲解,应把播放会话的生命周期上移,不再由页面销毁决定是否停止。这一原则也解释了为何音频状态模型不应保存在某个按钮组件内。页面只是状态消费者,而会话才是播放器、事件回调和资源句柄的所有者。

本篇的工程重点是让“选择节点、准备资源、真正播放、暂停、完成”拥有明确时序:导览目录负责资源身份,AVPlayer 负责媒体状态,ArkUI 负责结果呈现。下一篇在这条链路上加入优先资源、兼容资源和安全回退,不必重写页面的基本播放逻辑。

相关资料:AVPlayer API 参考、音视频播放能力说明、HarmonyOS 开发者文档中心。

Logo

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

更多推荐