HarmonyOS 7 空间音频导览连载05(共08篇):方位提示与界面联动【共创稿事节】

展馆讲解与路线提示接入同一个页面后,常见的交互矛盾是:音频已经切换到青铜器展区,地图高亮却停留在上一件展品;用户在列表里点了书画长廊,界面的方位箭头仍指向青铜器;播放页退出再进入时,距离文字又按照旧的起点计算。空间音频负责让讲解获得更丰富的听觉体验,导览方向则属于展馆坐标和页面状态问题。两者需要共享稳定的节点标识,却不应把屏幕箭头直接等同于声场声源的真实空间位置。

展馆路线与讲解节点同步

一、沿用节点目录,增加独立的方位计算层

前面的连载已经形成 GuideNode、GuideCatalog、GuidePlaybackService 和 SourceSwitchCoordinator。这里不另建音频播放入口,而是给节点选择动作增加一个可重复计算的方位快照。已知入口、青铜器展区、书画长廊和数字展厅的平面坐标,页面可以在用户选择一个当前位置后,展示目标展品的相对距离、展馆平面方位和路线说明。

必须明确两种方向:地图方位来自展馆自己的 x、y 坐标,用户面对的方向来自应用当前保存的朝向角度。上一章尚未引入室内定位和朝向传感器,因此本篇让用户选择“当前位置”和“面向方向”,不把这些值解释为系统自动测得的位置。设 y 轴指向平面图上方,x 轴指向右方;从起点指向目标的向量为 (dx, dy),距离由勾股定理计算,方位角采用从上方起顺时针的约定。

输入含义页面表现
originId用户当前选择的展区节点顶部当前位置
targetId计划前往的讲解节点目标卡片与列表高亮
headingDeg面向方向的平面角度前方、右侧等相对指向
distanceM两节点欧氏距离直线距离提示
selectedAudioId播放会话确认的节点播放栏标题及进度

欧氏距离只描述两点在平面上的直线间距。真实参观路线可能绕过隔墙、展柜和关闭通道,不能把它当成可通行路线的步行长度。如果馆内有走廊连通图,应由路线规划层另行给出可达距离,方位计算层只消费最终选中的下一目标。

二、把角度计算写成可独立复用的服务

在 entry/src/main/ets/services 下新建 DirectionService.ets。函数输入沿用连载02的 Point2D,不依赖 ArkUI 或播放器,这样地图页、语音列表和导航卡片可以得到同一种方位描述。角度归一化至 [0,360),保证朝向从 359° 切换到 1° 时不会突然显示大幅反向偏移。距离很小时直接标识“已到达”,避免出现没有意义的方向箭头。

// entry/src/main/ets/services/DirectionService.ets
import { Point2D } from '../model/GuideNode';

export interface DirectionSnapshot {
  distanceM: number;
  absoluteDeg: number;
  relativeDeg: number;
  label: string;
  arrived: boolean;
}

export class DirectionService {
  private static wrap(angle: number): number {
    return (angle % 360 + 360) % 360;
  }

  public static calculate(origin: Point2D, target: Point2D,
    headingDeg: number): DirectionSnapshot {
    const dx: number = target.x - origin.x;
    const dy: number = target.y - origin.y;
    const distance: number = Math.hypot(dx, dy);
    const absolute: number = this.wrap(Math.atan2(dx, dy) * 180 / Math.PI);
    const relative: number = this.wrap(absolute - headingDeg);
    const arrived: boolean = distance < 1.5;
    let label: string = '后方';
    if (relative < 22.5 || relative >= 337.5) {
      label = '正前方';
    } else if (relative < 67.5) {
      label = '右前方';
    } else if (relative < 112.5) {
      label = '正右方';
    } else if (relative < 157.5) {
      label = '右后方';
    } else if (relative < 202.5) {
      label = '正后方';
    } else if (relative < 247.5) {
      label = '左后方';
    } else if (relative < 292.5) {
      label = '正左方';
    } else {
      label = '左前方';
    }
    return {
      distanceM: Math.round(distance * 10) / 10,
      absoluteDeg: absolute,
      relativeDeg: relative,
      label: arrived ? '已到达目标附近' : label,
      arrived: arrived
    };
  }
}

这里使用 Math.atan2(dx,dy) 是有意为之:常见数学坐标以右方为零度,而页面约定以平面图上方为零度。两种坐标系若混用,箭头可能恰好偏转九十度。UI 只显示相对角度 relativeDeg,而绝对角度仍保留,方便以后导入馆内定位或路径规划数据。

三、让目标节点与正在播放的节点拥有不同状态

用户选中目标,是希望导航到哪个展区;播放器确认开始,是当前正在讲解哪件展品。这两个事实通常一致,但切换音源需要异步准备,短时间内可能不同。状态层必须同时存储 targetId 和 selectedAudioId:目标卡片可以立即跳转,播放栏则等待连载03的 GuidePlaySnapshot 更新。业务层采用稳定 ID 查找目录记录,不能用列表下标充当节点身份。

下列 GuideDirectionState 只处理位置与节点选择。它从连载02的目录读取现有数据,通过观察者给页面推送单一快照。创建时以入口为起点,朝向为上方;无效节点 ID 时保留原状态并抛出明确错误,避免页面静默跳到其他展品。

// entry/src/main/ets/services/GuideDirectionState.ets
import { GuideNode } from '../model/GuideNode';
import { GUIDE_NODES } from '../model/GuideCatalog';
import { DirectionService, DirectionSnapshot } from './DirectionService';

export interface GuideDirectionView {
  origin: GuideNode;
  target: GuideNode;
  headingDeg: number;
  direction: DirectionSnapshot;
}

export class GuideDirectionState {
  private origin: GuideNode = GUIDE_NODES[0];
  private target: GuideNode = GUIDE_NODES[1];
  private heading: number = 0;
  private observer?: (state: GuideDirectionView) => void;

  private find(id: string): GuideNode {
    const node: GuideNode | undefined = GUIDE_NODES.find(
      (item: GuideNode): boolean => item.id === id);
    if (!node) { throw new Error('找不到对应的展馆节点'); }
    return node;
  }

  public listen(observer: (state: GuideDirectionView) => void): void {
    this.observer = observer;
    this.publish();
  }

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

  public setOrigin(id: string): void {
    this.origin = this.find(id);
    this.publish();
  }

  public setTarget(id: string): void {
    this.target = this.find(id);
    this.publish();
  }

  public setHeading(degrees: number): void {
    if (!Number.isFinite(degrees)) { return; }
    this.heading = (degrees % 360 + 360) % 360;
    this.publish();
  }

  private publish(): void {
    this.observer?.({
      origin: this.origin,
      target: this.target,
      headingDeg: this.heading,
      direction: DirectionService.calculate(
        this.origin.position, this.target.position, this.heading)
    });
  }
}

这一层没有创建播放器。当前展品发生变化时,UI 先调用 setTarget(id),再将同一个 GuideNode 提交给已有的 SourceSwitchCoordinator.select(node)。播放器准备失败后,目标卡片仍可保留用户选择,同时在播放栏显示资源不可用;这比让地图强行回退到上一个节点更符合用户预期。

四、ArkUI 同时呈现方向卡片和讲解操作

DevEco Studio 中的方位计算与页面预览

页面让用户从入口或已有展区选择当前位置,通过朝向按钮改变参考角度,点击目的地时立即重算方向。下面展示可以独立嵌入 pages/GuideDirectionPage.ets 的核心页面;音频播放的完整生命周期仍由前篇服务管理,页面中给播放协调器留出同一节点的接入位置。

// entry/src/main/ets/pages/GuideDirectionPage.ets
import { GuideDirectionState, GuideDirectionView } from
  '../services/GuideDirectionState';
import { GUIDE_NODES } from '../model/GuideCatalog';
import { GuideNode } from '../model/GuideNode';

@Entry
@Component
struct GuideDirectionPage {
  private guide: GuideDirectionState = new GuideDirectionState();
  @State originTitle: string = '序厅·城市记忆';
  @State targetTitle: string = '青铜器展区';
  @State labelText: string = '正前方';
  @State distance: number = 0;
  @State heading: number = 0;
  @State selectedId: string = 'bronze';

  aboutToAppear(): void {
    this.guide.listen((state: GuideDirectionView): void => {
      this.originTitle = state.origin.title;
      this.targetTitle = state.target.title;
      this.labelText = state.direction.label;
      this.distance = state.direction.distanceM;
      this.heading = state.headingDeg;
    });
  }

  aboutToDisappear(): void { this.guide.unlisten(); }

  private chooseTarget(node: GuideNode): void {
    this.selectedId = node.id;
    this.guide.setTarget(node.id);
  }

  build() {
    Column({ space: 16 }) {
      Text('室内导览').fontSize(26).fontWeight(FontWeight.Bold)
      Text(`当前位置:${this.originTitle}`).fontSize(16)
      Row({ space: 8 }) {
        ForEach(GUIDE_NODES, (node: GuideNode) => {
          Button(node.title).fontSize(11).onClick((): void => {
            this.guide.setOrigin(node.id);
          })
        }, (node: GuideNode): string => node.id)
      }.width('100%')
      Column({ space: 10 }) {
        Text(`前往:${this.targetTitle}`).fontSize(20)
        Text(this.labelText).fontSize(28).fontColor('#146CF3')
        Text(`直线距离 ${this.distance.toFixed(1)} 米`)
        Text(`当前参考朝向 ${this.heading}°`).fontSize(13)
        Row({ space: 10 }) {
          Button('向上').onClick((): void => this.guide.setHeading(0))
          Button('向右').onClick((): void => this.guide.setHeading(90))
          Button('向下').onClick((): void => this.guide.setHeading(180))
          Button('向左').onClick((): void => this.guide.setHeading(270))
        }
      }.padding(18).backgroundColor('#ECF4FF').borderRadius(18)
      List() {
        ForEach(GUIDE_NODES, (node: GuideNode) => {
          ListItem() {
            Row() {
              Text(node.title).layoutWeight(1)
              Text(this.selectedId === node.id ? '当前目标' : '设为目标')
                .fontColor('#146CF3')
            }.padding(14).onClick((): void => this.chooseTarget(node))
          }
        }, (node: GuideNode): string => node.id)
      }.layoutWeight(1)
    }.padding(18).width('100%').height('100%')
  }
}

此处列表点击负责导航目标;接入已有播放页时,在 chooseTarget() 内通过目录取得同一个 GuideNode,再向 SourceSwitchCoordinator 提交选择请求。这样地图与播放共享 ID,但页面不会在资源准备尚未结束时写出“播放中”。

展馆方向、目标与讲解播放组合页面

五、处理移动、切换与界面刷新之间的时序

实际交互里需要区分三种更新。用户改变参考起点,距离与方位应重新计算,但音频不需要重新加载;用户改变目标节点,方向先更新,播放服务按候选资源策略准备讲解;用户只是打开列表或返回页面,原有目标与播放快照都应保持,不能因为组件重新构建就把起点改回默认值。若希望跨页面保留这些状态,应将 GuideDirectionState 提升为页面共享的会话对象,由应用级容器持有,而不是每个子组件各创建一份。

节点距离也应检查合法性。来自内容接口的坐标若缺失、出现 NaN 或正负无穷,直接做 Math.atan2 会把异常传递到箭头属性;在数据进入 GuideCatalog 时,应保证 x 与 y 都是有限数字。另一种问题发生在不同楼层:二维距离无法表达楼梯和电梯成本,可以按楼层划分节点组,在有可通行路线后输出首段行进方向。地图缩放只改变视觉坐标,不应反过来修改节点所保存的米制坐标。

还需要注意无障碍呈现:方位箭头可以作为视觉强化,但文本必须保留“右前方、距离多少米、目标展区名称”,便于读屏服务获取明确语义。对于方向接近区间边界的情况,可以给动态朝向设置角度滞后阈值,避免轻微变化引发“前方—右前方”频繁跳动。当前手动角度输入没有高频更新需求,因此无需引入额外防抖计时器。

六、形成稳定的导览联动契约

这一篇把方位和音频会话连接在同一套节点身份上,却保留了各自独立的状态来源。地图方向由展馆坐标计算,播放状态由 AVPlayer 事件确认,选中状态则来自用户操作。只要三者不互相推断,资源准备较慢、路线重新选择或展区临时关闭,都不会让界面出现难以解释的跳动。

下一篇需要面对的是另一类变化:系统把声音从耳机转到扬声器,或者来电等音频焦点事件暂时打断讲解。它们并非用户选择了新展品,也不应篡改当前路线目标。相关资料:HarmonyOS 开发者文档中心、ArkUI Canvas 使用说明、AudioManager API 参考。

Logo

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

更多推荐