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,订阅与退出也有明确的清理路径。这样后续扩展音源选择、路由中断、前后台恢复时,不必反复改写页面上的状态判断。

相关资料:AudioManager API 参考、空间计算能力与空间音频、HarmonyOS 开发者文档中心。

Logo

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

更多推荐