HarmonyOS 7 空间音频与室内导览交互连载01(共08篇):设备能力与状态订阅【共创稿事节】
HarmonyOS 7 空间音频导览连载01(共08篇):设备能力与状态订阅【共创稿事节】
博物馆室内导览通常会把展品讲解、路线提示和背景声音组合到同一个播放页面。空间音频加入以后,页面上的一个“已开启”角标就不够用了:用户可能在系统设置里开启了空间音频,但正在使用的输出设备不支持相应渲染;也可能在播放过程中更换耳机,原先的设备判断立即失效。导览应用应当先解决“当前输出是什么、是否支持、系统开关怎样变化”三个问题,再进入音源播放和声场设计。

一、把系统状态与导览业务状态分开
在工程中,空间音频状态至少包含三个层次。输出路由是媒体播放将优先选择的设备;设备能力来自输出设备描述符;用户开关来自空间音频管理器。三者不能混成一个布尔值。一个更稳定的页面模型应保留每项原始信息,把状态文本交给业务层整理。
| 信息 | 读取方式 | 页面用途 |
|---|---|---|
| 优先输出设备 | getPreferredOutputDeviceForRendererInfoSync | 呈现当前媒体输出路径 |
| 设备空间化支持 | AudioDeviceDescriptor.spatializationSupported | 决定是否显示支持能力 |
| 当前设备空间音频开关 | isSpatializationEnabledForCurrentDevice() | 反映用户的系统设置 |
| 空间音频开关变化 | spatializationEnabledChangeForCurrentDevice | 在系统设置变化后更新页面 |
这里的“优先输出”按媒体流的 StreamUsage 查询;它比遍历所有已连接设备更接近当前播放链路。查询的是系统空间化条件,不是音频文件是否带有对象音频元数据。后续真正播放时,还需要结合资源编码、播放器状态和输出路由再判断体验等级。
项目采用 Stage 模型,在 entry/src/main/ets 下增加 services/SpatialStatusService.ets 和 pages/Index.ets。页面只负责呈现状态;服务负责从 Audio Kit 读取信息并维护订阅,后续连载可以继续复用这个服务接口。工程依赖系统提供的 @kit.AudioKit,不必为了查询开关而申请修改系统音效的权限。
二、建立可被页面消费的状态对象
同步 API 读取方便,但不宜直接散落在多个 ArkUI 组件中。一个组件读“支持”,另一个组件读“开关”,恰逢耳机切换就可能出现跨时刻的信息组合。服务层采用一次刷新、一次广播的方式,把页面需要的信息合成为快照。查询失败保留错误消息,避免把异常简单归类为“不支持”。
// entry/src/main/ets/services/SpatialStatusService.ets
import { audio } from '@kit.AudioKit';
export interface SpatialSnapshot {
outputName: string;
supported: boolean;
systemEnabled: boolean;
available: boolean;
message: string;
}
export type SpatialObserver = (value: SpatialSnapshot) => void;
export class SpatialStatusService {
private manager: audio.AudioManager = audio.getAudioManager();
private routing: audio.AudioRoutingManager = this.manager.getRoutingManager();
private spatial: audio.AudioSpatializationManager =
this.manager.getSpatializationManager();
private rendererInfo: audio.AudioRendererInfo = {
usage: audio.StreamUsage.STREAM_USAGE_MEDIA,
rendererFlags: 0
};
private observer: SpatialObserver | undefined = undefined;
private started: boolean = false;
private changeCallback: (enabled: boolean) => void =
(_enabled: boolean): void => { this.refresh(); };
public current: SpatialSnapshot = {
outputName: '正在读取输出设备',
supported: false,
systemEnabled: false,
available: false,
message: '正在读取音频状态'
};
public setObserver(observer: SpatialObserver): void {
this.observer = observer;
this.observer(this.current);
}
public clearObserver(): void {
this.observer = undefined;
}
public start(): void {
if (this.started) {
this.refresh();
return;
}
try {
this.spatial.on('spatializationEnabledChangeForCurrentDevice',
this.changeCallback);
this.started = true;
} catch (error) {
console.error(`SpatialStatus subscribe failed: ${JSON.stringify(error)}`);
}
this.refresh();
}
public refresh(): void {
try {
const outputs: audio.AudioDeviceDescriptors =
this.routing.getPreferredOutputDeviceForRendererInfoSync(this.rendererInfo);
const output: audio.AudioDeviceDescriptor | undefined = outputs[0];
const enabled: boolean =
this.spatial.isSpatializationEnabledForCurrentDevice();
const supported: boolean = output ? output.spatializationSupported : false;
this.publish({
outputName: output ? output.displayName || output.name || '媒体输出设备' : '无可用输出设备',
supported: supported,
systemEnabled: enabled,
available: !!output,
message: output ? '空间音频条件已更新' : '请检查音频输出设备'
});
} catch (error) {
console.error(`SpatialStatus refresh failed: ${JSON.stringify(error)}`);
this.publish({
outputName: '音频信息读取失败',
supported: false,
systemEnabled: false,
available: false,
message: '请稍后刷新音频状态'
});
}
}
private publish(value: SpatialSnapshot): void {
this.current = value;
if (this.observer) {
this.observer(value);
}
}
public stop(): void {
if (this.started) {
this.spatial.off('spatializationEnabledChangeForCurrentDevice',
this.changeCallback);
this.started = false;
}
this.clearObserver();
}
}
export const spatialStatusService: SpatialStatusService = new SpatialStatusService();
changeCallback 使用稳定的函数引用,注销时传入同一回调。start() 设立幂等保护,避免页面反复进入时重复订阅;stop() 同时处理系统订阅与页面观察者。这种写法适合当前只有一个导览页的结构。后续加入播放后台服务时,订阅的所有权应转移到播放会话,避免页面离开就终止仍需要的状态采集。
三、页面只消费快照
ArkUI 页面中的 @State 存储一个新的快照对象,服务发布变化时整体替换,文字和状态色即可跟随更新。页面提供显式刷新入口,以便用户在连接耳机或切换音频输出后主动同步一次。注意这里只展示“系统开关已开启”和“设备支持”,不把两项同时成立直接写成三维声源已被渲染。

// entry/src/main/ets/pages/Index.ets
import { spatialStatusService, SpatialSnapshot } from '../services/SpatialStatusService';
@Entry
@Component
struct Index {
@State snapshot: SpatialSnapshot = {
outputName: '正在读取输出设备', supported: false,
systemEnabled: false, available: false, message: '正在读取音频状态'
};
aboutToAppear(): void {
spatialStatusService.setObserver((value: SpatialSnapshot): void => {
this.snapshot = value;
});
spatialStatusService.start();
}
aboutToDisappear(): void {
spatialStatusService.stop();
}
@Builder
statusRow(label: string, detail: string, active: boolean) {
Row() {
Column({ space: 6 }) {
Text(label).fontSize(17).fontWeight(FontWeight.Medium)
Text(detail).fontSize(14).fontColor('#64748B')
}.alignItems(HorizontalAlign.Start)
Blank()
Text(active ? '已满足' : '未满足')
.fontSize(14)
.fontColor(active ? '#087F63' : '#B45309')
}
.width('100%').padding(18)
.borderRadius(16).backgroundColor(Color.White)
}
build() {
Scroll() {
Column({ space: 16 }) {
Text('空间音频导览').fontSize(30).fontWeight(FontWeight.Bold)
.width('100%')
Text('当前媒体输出:' + this.snapshot.outputName)
.fontSize(16).fontColor('#475569').width('100%')
this.statusRow('设备能力', '空间音频渲染支持', this.snapshot.supported)
this.statusRow('系统开关', '当前输出设备的空间音频设置',
this.snapshot.systemEnabled)
this.statusRow('输出连接', '媒体输出设备可用', this.snapshot.available)
Text(this.snapshot.message).fontSize(14)
.fontColor('#64748B').width('100%')
Button('刷新音频状态')
.width('100%')
.onClick((): void => { spatialStatusService.refresh(); })
}
.padding(20).width('100%')
}
.backgroundColor('#F4F7FB')
}
}

四、事件变化要按同一条链路处理
进入页面后,订阅完成即进行首次读取。系统设置改变时,回调再次调用 refresh(),页面拿到完整新对象。用户从蓝牙耳机切换到扬声器,音频优先路由可能随之变化;页面的刷新按钮可主动重新查询,接入播放器之后还应订阅路由变化事件,确保状态与实际媒体流保持一致。这里采用媒体 STREAM_USAGE_MEDIA,避免使用语音通话场景的设备规则去推断导览播放路径。
排查界面状态时,应依次查看“是否取到设备描述符”“spatializationSupported 值”“当前设备空间音频开关值”“刷新调用来源”。如果设备支持但系统设置关闭,显示“设备支持、系统关闭”;如果没有可用设备,展示引导文案,不保留上一个耳机名称。异常时保留业务日志即可,不需要在界面泄露错误对象内部字段。
五、将状态变化整理成可排查的事件序列
音频类页面经常同时接收三种输入:页面首次进入、用户主动刷新、系统音频状态变化。它们可能在很短的时间内连续发生。服务代码使用同一个 refresh() 入口,可以让不同触发源获得一致的字段含义,但在进一步接入远端资源加载后,还应在服务内部维护递增的刷新序号,以避免异步结果覆盖较新的状态。当前三个状态值来自同步查询,调用过程相对短,因此不必为这一步引入多层 Promise 队列。
建议把排查顺序固化为“路由—能力—开关—播放链路”。例如,用户反馈从耳机切换到扬声器后页面仍显示“空间音频已开启”,先检查媒体流偏好输出描述符是否更新,再观察系统空间音频开关事件,最后检查应用是否在切换路由时刷新了快照。仅根据音频设置界面的单个开关判断,容易忽略当前发声设备与可连接设备之间的区别。遇到多个可用输出时,也不宜通过 some() 对全部设备取或操作,因为存在一个支持空间音频的外设,并不意味着当前媒体流会从该外设输出。
订阅回调和手动刷新还承担不同的责任。系统事件能覆盖空间音频开关改变,但耳机插拔、媒体投播或不同流用途引起的路由变化属于另一类事件。完整应用应使用 AudioRoutingManager 的路由变化监听,把新的媒体输出描述符写入同一份状态快照。对于首次进入、页面恢复和用户选择设备等场景,再以显式刷新作为最终收敛手段。这一设计并不会强制切换用户的音频设备,只是把已有的系统选择准确地呈现在界面上。
| 触发源 | 状态服务的动作 | 页面期望 |
|---|---|---|
| 首次进入 | 订阅后立即查询 | 快速获得完整初始快照 |
| 系统开关变化 | 按事件重新查询 | 文案与系统设置同步 |
| 用户手动刷新 | 主动查询媒体路由和开关 | 清除可能过时的设备名称 |
| 页面离开 | 注销订阅与观察者 | 避免重复通知与对象滞留 |
将设备快照转换为稳定的界面文案
视图文案由纯函数计算,不参与事件订阅。这样设备状态变化时,页面只更新文本,不需要重新创建媒体管理对象。
// model/SpatialLabels.ets
export interface SpatialLabelsInput {
supported: boolean;
enabled: boolean;
deviceName: string;
}
export function spatialStatusText(state: SpatialLabelsInput): string {
if (!state.supported) return '当前输出设备暂不支持空间音频';
if (!state.enabled) return '空间音频已关闭';
return `空间音频已开启 · ${state.deviceName}`;
}
六、为后续音源接入保留扩展点
导览应用还需要关心资源是普通立体声、多声道还是带对象元数据的音源。这部分信息不由设备开关决定,因此不应加入 SpatialSnapshot 的 systemEnabled 字段。下一篇会建立独立的展馆节点与音源数据模型,记录资源类型、相对位置和可访问区域,再通过状态组合决定播放页能呈现哪些功能。
当前实现的关键收获,是把设备能力、系统设置、播放内容三个维度拆开。UI 不再直接调用零散的 Audio Kit API,订阅与退出也有明确的清理路径。这样后续扩展音源选择、路由中断、前后台恢复时,不必反复改写页面上的状态判断。
更多推荐

所有评论(0)