HarmonyOS 7 空间音频与室内导览交互连载05(共08篇):方位提示与界面联动【共创稿事节】
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 同时呈现方向卡片和讲解操作

页面让用户从入口或已有展区选择当前位置,通过朝向按钮改变参考角度,点击目的地时立即重算方向。下面展示可以独立嵌入 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 参考。
更多推荐
所有评论(0)