HarmonyOS 7 空间音频与室内导览交互连载03(共08篇):音频播放与状态管理【共创稿事节】
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 负责结果呈现。下一篇在这条链路上加入优先资源、兼容资源和安全回退,不必重写页面的基本播放逻辑。
更多推荐
所有评论(0)