HarmonyOS 7 空间音频与室内导览交互连载04(共08篇):音源切换与故障回退【共创稿事节】
HarmonyOS 7 空间音频导览连载04(共08篇):音源切换与故障回退【共创稿事节】
展馆中的讲解资源并非只有一种。普通语音面向所有参观者,部分展区还会提供多声道氛围声或带空间定位信息的声音作品。用户在入口听完简短介绍,转到数字展厅时,应用需要选择合适的音源;如果该资源无法取得、编码不被播放链路接受或用户切换了输出设备,又应平稳回到可访问的语音讲解。最容易出错的做法,是在点击列表项时立即修改 AVPlayer.fdSrc,不管旧播放器正处于 playing、prepared 还是 error。这一篇在连载03的 GuidePlaybackService 上建立切换协调层,用明确的候选顺序与异步队列收敛最终状态。

一、先区分业务音源等级与实际可播资源
连载02 的 GuideNode 包含 sourceTier 和 voiceAsset。前者表达内容制作类别,后者是具体文件名。SourceTier.OBJECT_AUDIO 只是业务标签,不应被当作系统自动识别的编解码格式。音频能否产生空间感,与文件实际轨道、播放框架、输出设备和系统开关均有关。资源策略负责“优先试哪一个文件”,而不是修改系统音效设置。
本系列采用“优先声效资源 → 常规讲解版本 → 公共讲解版本”的候选顺序。每个候选都必须是实际放入应用 resources/rawfile 的合法音频文件。数字展厅的优先资源沿用 digital.m4a,常规版本记为 digital_voice.mp3;其他展区根据对应 voiceAsset 选择。公共兜底文件 general_guide.mp3 可收录展馆方向与服务提示,不宜拿另一件展品的讲解替换本节点内容。
| 条件 | 选择策略 | 页面反馈 |
|---|---|---|
| 首选资源存在且可准备 | 使用首选资源 | 正常播放 |
| 设备空间音频开关不满足 | 优先选择常规版本 | 显示“普通讲解” |
| 首选文件缺失或准备失败 | 尝试同节点的替代版本 | 显示“已切换讲解资源” |
| 所有候选均无法使用 | 保持错误状态 | 提供重试入口 |
| 用户快速切换两个节点 | 以最后一次明确选择为准 | 避免旧音频回跳 |
这里的“开关满足”沿用第一篇的 SpatialSnapshot:supported && systemEnabled && available。它是用户体验策略的输入,不直接判断媒体文件是否兼容。即使设备支持空间音效,应用也应能够播放一般立体声讲解;反过来,设备支持并不保证任何文件都能直接按对象声轨渲染。
二、把候选资源与导览节点分开维护
GuideCatalog 保持展馆节点身份、位置和首选资源的基础数据。新增的 AudioSourcePolicy 只负责把节点和可用条件转换成有序候选列表。这样的分离避免为了兼容某种耳机,直接修改节点主数据。候选的 kind 是页面可理解的资源级别,不参与系统编码选择。
// entry/src/main/ets/services/AudioSourcePolicy.ets
import { GuideNode, SourceTier } from '../model/GuideNode';
import { SpatialSnapshot } from './SpatialStatusService';
export interface AudioCandidate {
fileName: string;
kind: 'enhanced' | 'voice' | 'general';
label: string;
}
export class AudioSourcePolicy {
public candidates(node: GuideNode, status: SpatialSnapshot): AudioCandidate[] {
const preferred: boolean = status.available &&
status.supported && status.systemEnabled;
const result: AudioCandidate[] = [];
if (node.id === 'digital') {
if (preferred && node.sourceTier === SourceTier.OBJECT_AUDIO) {
result.push({ fileName: node.voiceAsset, kind: 'enhanced',
label: '空间声效资源' });
}
result.push({ fileName: 'digital_voice.mp3', kind: 'voice',
label: '数字展厅普通讲解' });
} else {
result.push({ fileName: node.voiceAsset, kind: 'voice',
label: '展区讲解' });
}
result.push({ fileName: 'general_guide.mp3', kind: 'general',
label: '展馆公共讲解' });
return result.filter((candidate: AudioCandidate, index: number): boolean =>
result.findIndex((entry: AudioCandidate): boolean =>
entry.fileName === candidate.fileName) === index);
}
}
实际资源清单应随发布包更新。比如数字展厅没有提供 digital_voice.mp3,就应在策展资源准备阶段补充讲解文件,而不是依赖应用运行时猜测文件名。这里采用固定本地资源,是为了让首选和回退顺序可读、可排查。后续若改为网络资源,策略类仍然可以输出候选项,但资源获取要转由下载与缓存服务处理,并增加网络重试、完整性检查和版本管理。
三、把“切换请求”变成串行任务
连续点击是最容易触发音频竞争的场景。用户先选青铜器展区,随后立即选书画长廊;第一次的文件读取可能尚未完成。若两个 load() 并行执行,旧请求的 prepared 回调可能在后到达,使界面和声音回到已放弃的节点。
这里加入 SourceSwitchCoordinator。每次选择先更新递增序号,新的操作接在现有 Promise 队列之后执行。轮到某次任务时,检查自己是否仍是最新请求;若不是,直接跳过。串行化不等于强行取消底层文件读取,但能够避免新旧播放器同时操作同一播放会话。候选失败时依次尝试,直到播放器完成准备。
// entry/src/main/ets/services/SourceSwitchCoordinator.ets
import { GuideNode } from '../model/GuideNode';
import { GuidePlaybackService } from './GuidePlaybackService';
import { SpatialSnapshot } from './SpatialStatusService';
import { AudioSourcePolicy, AudioCandidate } from './AudioSourcePolicy';
export interface SwitchState {
selectedId: string;
sourceLabel: string;
message: string;
switching: boolean;
}
export class SourceSwitchCoordinator {
private policy: AudioSourcePolicy = new AudioSourcePolicy();
private serial: Promise<void> = Promise.resolve();
private revision: number = 0;
private session: GuidePlaybackService;
private onChange: (s: SwitchState) => void;
private status: () => SpatialSnapshot;
constructor(session: GuidePlaybackService,
status: () => SpatialSnapshot, onChange: (s: SwitchState) => void) {
this.session = session;
this.status = status;
this.onChange = onChange;
}
public select(node: GuideNode): Promise<void> {
const request: number = ++this.revision;
this.onChange({ selectedId: node.id, sourceLabel: '',
message: '正在切换讲解资源', switching: true });
this.serial = this.serial.catch((): void => {}).then(async (): Promise<void> => {
if (request !== this.revision) { return; }
const options: AudioCandidate[] =
this.policy.candidates(node, this.status());
for (const option of options) {
if (request !== this.revision) { return; }
await this.session.load(node, option.fileName, false);
if (await this.session.waitForReady()) {
if (request !== this.revision) { return; }
this.onChange({ selectedId: node.id, sourceLabel: option.label,
message: option.kind === 'enhanced' ? '已选择声效资源' :
'已选择兼容讲解资源', switching: false });
await this.session.toggle();
return;
}
}
if (request === this.revision) {
this.onChange({ selectedId: node.id, sourceLabel: '',
message: '讲解资源不可用,请重新选择或稍后重试', switching: false });
}
});
return this.serial;
}
public cancelPending(): void { ++this.revision; }
}
这里还需要处理 AVPlayer.prepare() 的异步确认。连载03中 load() 在 fdSrc 设置后返回,而 prepared 可能稍晚才到达。要让上面的回退循环真正可靠,应为播放服务补充等待函数,用事件驱动的 Promise 表达“准备成功或失败”,而不是读取设置数据源那一瞬间的 snapshot.phase。
// 加入 GuidePlaybackService 类中
public waitForReady(timeoutMs: number = 8000): Promise<boolean> {
if (this.snapshot.phase === 'ready') { return Promise.resolve(true); }
if (this.snapshot.phase === 'error') { return Promise.resolve(false); }
return new Promise<boolean>((resolve) => {
const previous: PlayObserver | undefined = this.observer;
let settled: boolean = false;
const timer = setTimeout((): void => {
finish(false);
}, timeoutMs);
const finish = (ok: boolean): void => {
if (settled) { return; }
settled = true;
clearTimeout(timer);
this.observer = previous;
resolve(ok);
};
this.observer = (value: GuidePlaySnapshot): void => {
previous?.(value);
if (value.phase === 'ready') { finish(true); }
if (value.phase === 'error') { finish(false); }
};
});
}
上面的协调器已经在加载后使用 await this.session.waitForReady() 等待准备完成。由于回调与超时可能接近同时触发,settled 用于确保 Promise 只完成一次。这里的等待只针对媒体准备阶段;真正开始播放以后,界面仍需通过 playing 事件确认播放成功。若应用同时存在多个状态消费者,适宜将单一 observer 扩展为观察者集合,避免临时等待者覆盖其他订阅;可在后续播放会话整合时统一改造。
四、切换过程中保留清楚的用户反馈

页面继续使用连载03的 GuidePlaySnapshot 展示进度与播放阶段,增加 SwitchState 记录选择意图和资源类别。用户点击节点时,立即呈现“正在切换讲解资源”;等待候选准备完成后,再显示“空间声效资源”或“兼容讲解资源”。这可以避免界面展示新展品标题,耳中却仍在播放旧展品的问题。
// PlayerPage.ets 中与切换相关的字段和调用
@State switchState: SwitchState = {
selectedId: '', sourceLabel: '', message: '', switching: false
};
private coordinator?: SourceSwitchCoordinator = undefined;
private initSwitch(): void {
if (!this.service) { return; }
this.coordinator = new SourceSwitchCoordinator(
this.service,
(): SpatialSnapshot => spatialStatusService.current,
(state: SwitchState): void => { this.switchState = state; }
);
}
private chooseNode(nodeId: string): void {
const node: GuideNode | undefined = this.planner.findById(nodeId);
if (!node || !node.accessible) { return; }
this.coordinator?.select(node).catch((error: Error): void => {
console.error(`Source switch failed: ${error.message}`);
});
}
将 initSwitch() 放在页面创建 GuidePlaybackService 并订阅快照之后。首次进入可调用 chooseNode('entrance');其它按钮分别传入 'bronze'、'calligraphy'、'digital'。页面离开时先调用 coordinator.cancelPending(),随后释放播放服务,防止迟到的选择继续接入新的资源。

五、避免失败回退变成无限循环
资源回退必须有上界。策略输出的候选列表固定、去重,最多尝试首选、同节点普通讲解、公共讲解三级。失败不应无条件立即重试首选,否则坏文件会反复进入加载阶段,用户始终看不到明确的错误结果。将失败原因归类为“文件不存在、解码失败、准备超时、播放阶段错误”,并在日志中保留节点 ID 与候选文件名,能够迅速定位内容打包或格式兼容问题。
另一种边界情况是系统开关在切换过程中发生变化。例如音频准备期间用户关闭空间音效,第一篇的状态快照可能已更新。对于本地资源,正在准备的候选可以完成当前流程;下一次节点切换重新通过 AudioSourcePolicy 读取最新快照。若业务要求立即切到普通讲解,应在 SpatialStatusService 的状态通知中提交新的 select() 请求,而不是直接在系统事件回调里修改播放器数据源。
需要特别注意失败后的资源释放。上一播放器必须完成 release(),对应 getRawFd() 资源再被 closeRawFd() 关闭,才能把新文件描述符交给下一播放器。使用一条串行队列可以令这个顺序更直观,也方便未来增加播放打断、前后台恢复和路由变更处理。
六、让切换逻辑成为长期可维护的服务
这一阶段形成了一条相对稳定的播放链路:GuideCatalog 确定讲解节点,SpatialStatusService 提供当前输出设备条件,AudioSourcePolicy 生成音源候选,SourceSwitchCoordinator 串行处理用户选择,GuidePlaybackService 完成媒体准备和播放状态上报。以后更换音频版本时,只需调整候选资源表;以后更改页面布局时,不必让按钮直接操作文件描述符。
资源切换和播放状态管理的核心不是尽可能快地调用 play(),而是保证每次最终呈现的节点、资源和播放器状态都对应同一个有效请求。下一篇会在此基础上将方位提示、节点位置与界面联动起来,使声音导览不仅能够可靠播放,也能够反映参观者选择的展区与方向。
更多推荐
所有评论(0)