〖共创稿事节〗HarmonyOS 7 新特性实战(45):耳机拔出以后怎么办——输出路由监控与空间音频降级
# 〖共创稿事节〗HarmonyOS 7 新特性实战(45):耳机拔出以后怎么办——输出路由监控与空间音频降级
戴着耳机听馆长讲解,听到一半顺手把耳机拔了。声音跳到外放,这没什么问题;有问题的是屏幕上那行字——"昆仑 · 左前 -30° · 4.8 米"。耳机在的时候这句话是对的,两只耳朵各听到一份不同的信号,方位是听出来的。耳机一拔,两只喇叭相隔几厘米,同一个声场坐标送出去,左右声道的差异落在耳朵里已经几乎为零。字还在,意义没了。
这篇处理的就是这个瞬间:设备能力没变、空间渲染管线没变、讲解也没打算停,变的只是"声音从哪儿出来"。要把它接住,得先承认两件事是分开的——能不能渲染方位,是一件事;此刻声音进了哪只设备,是另一件。
官方 API 参考:@ohos.multimedia.audio(AudioRoutingManager)(核对日期 2026-10-29)
一、两个布尔值回答不了两个问题
上一篇结尾留了一句"当前设备不支持空间声源"。这句话来自能力探测:建一个转换引擎,问它认不认空间渲染节点,答案是 spaceRender 这个布尔值。它回答的是"这台设备有没有这套本事",而且是一次性的——同一台设备上,答案今天和明天一样,所以我在 ShanhaiMapSoundField 里把它缓存起来,全应用只探一次。
路由是另一种东西:它会变。插上耳机、拔掉耳机、连上蓝牙音箱、蓝牙断连,每一次都可能改变系统的输出通路。一个缓存下来的布尔值描述不了它,一个只在启动时读一次的值也描述不了它。
| 问题 | 谁回答 | 什么时候变 | 失败时的表现 |
|---|---|---|---|
| 这台设备能不能做空间渲染 | OHAudioSuite 能力探测 | 基本不变,探一次缓存 | 页头写"当前设备不支持空间声源" |
| 此刻声音从哪只设备出来 | AudioRoutingManager | 每次插拔、每次蓝牙连断 | 还按耳机假设报方位,读数失真 |
两行合起来才够用:能力为真、路由为耳机,才谈得上"方位听得出来";能力为真、路由是外放,方位读数就该退成参考值。能力为假时路由是什么都无所谓,页头本来就走降级文案。
二、读全量设备,别直接相信事件
AudioRoutingManager 的事件回调长这样:on('deviceChange', DeviceFlag.OUTPUT_DEVICES_FLAG, callback),回调收到一个 DeviceChangeAction,里面有 type(CONNECT / DISCONNECT)和 deviceDescriptors(变化的那台或那几台设备)。很自然的一个写法是拿 deviceDescriptors 直接判定"现在是耳机还是外放",我一开始也这么想,直到意识到拔出事件描述的是"这台设备走了",不是"剩下的设备里谁在响"。
拔掉有线耳机的瞬间,系统移除设备与查询结果之间可能差一拍;如果这一拍里用户还连着蓝牙音箱,只看事件里的那台耳机,结论会变成"外放",而实际输出是蓝牙。所以事件只当触发器用,结论一律重新查全量:
// 读全量输出设备,按「有线 > 蓝牙 > 其他外接 > 机身」排出当前路由
private static read(): ShanhaiAudioRouteSnapshot {
const manager: audio.AudioRoutingManager | undefined = ShanhaiAudioRouteMonitor.routing;
if (manager === undefined) {
return ShanhaiAudioRouteMonitor.snapshot;
}
let devices: audio.AudioDeviceDescriptors = [];
try {
devices = manager.getDevicesSync(audio.DeviceFlag.OUTPUT_DEVICES_FLAG);
} catch (err) {
hilog.warn(0x2026, TAG, 'getDevicesSync failed: %{public}s', JSON.stringify(err));
return ShanhaiAudioRouteMonitor.snapshot;
}
let kind: ShanhaiAudioRouteKind = 'BUILT_IN';
for (const device of devices) {
const next: ShanhaiAudioRouteKind = ShanhaiAudioRouteMonitor.classify(device.deviceType);
if (ShanhaiAudioRouteMonitor.rank(next) > ShanhaiAudioRouteMonitor.rank(kind)) {
kind = next;
}
}
return {
kind: kind,
label: ShanhaiAudioRouteMonitor.labelOf(kind),
external: kind !== 'BUILT_IN',
deviceTypes: ShanhaiAudioRouteMonitor.listTypes(devices)
};
}
查询失败时不抛错、不留 undefined:返回上一次的快照,界面读到的是旧结论而不是空白。这类监听最怕的就是回调里抛异常把整条链路带崩,能降级的地方就地降级。
设备类型到路由的归类只有四档,判据是设备枚举,不是设备名字。名字是给人看的,同一副耳机在不同系统语言下写法不同;deviceType 才是稳定的判据。
| 路由档位 | 覆盖的设备类型 | 判定理由 |
|---|---|---|
| 有线耳机 | WIRED_HEADSET、WIRED_HEADPHONES、USB_HEADSET | 直插,延迟最低,方位最可信 |
| 蓝牙音频 | BLUETOOTH_A2DP、BLUETOOTH_SCO | 无线,仍有左右声道,但编码延迟不稳定 |
| 外接音频 | DISPLAY_PORT、REMOTE_CAST 及其他未列出类型 | 不是机身喇叭,但形态未知 |
| 外放扬声器 | SPEAKER、EARPIECE、INVALID | 机身发声,左右间距不足 |
四档有先后:同时插着有线耳机又连着蓝牙音箱时,系统实际走有线,所以优先级是 3 > 2 > 1 > 0,取最高的一档作为结论。deviceTypes 字段把本次查到的全部类型拼成 wired_headset+speaker 这样的串,不进界面,只进日志——验收时一眼就能看出"系统当时到底认出了几台输出设备"。
三、订阅只注册一次,退出时摘掉
路由监听挂在 Ability 上,不在页面里。页面会被反复创建销毁,Ability 在一个进程里只有一份;监听跟着页面走,就会出现同一件事注册好几份回调、页面一关就失效的问题。
start() 里的第一行是守卫:已经启动过就直接返回。EntryAbility.onCreate 调用它,onDestroy 调用 stop(),一进一出对得上。
// 只注册一次。重复调用直接返回,避免 EntryAbility 重建时叠加多份订阅。
static start(): void {
if (ShanhaiAudioRouteMonitor.started) {
return;
}
ShanhaiAudioRouteMonitor.started = true;
try {
ShanhaiAudioRouteMonitor.routing = audio.getAudioManager().getRoutingManager();
} catch (err) {
// 没有 AudioRoutingManager 的设备仍是可用的阅读器,只是不再报告路由变化。
hilog.warn(0x2026, TAG, 'routing manager unavailable: %{public}s', JSON.stringify(err));
return;
}
// 先读再订阅:顺序反过来的话,订阅成功到首次读取之间发生的插拔会漏掉。
const initial: ShanhaiAudioRouteSnapshot = ShanhaiAudioRouteMonitor.read();
ShanhaiAudioRouteMonitor.lastKind = initial.kind;
ShanhaiAudioRouteMonitor.publish(initial, '');
hilog.info(0x2026, TAG, 'route init ' + ShanhaiAudioRouteMonitor.describe(initial));
const routing: audio.AudioRoutingManager = ShanhaiAudioRouteMonitor.routing;
try {
routing.on('deviceChange', audio.DeviceFlag.OUTPUT_DEVICES_FLAG,
(action: audio.DeviceChangeAction) => {
ShanhaiAudioRouteMonitor.onDeviceChange(action);
});
hilog.info(0x2026, TAG, 'deviceChange subscribed');
} catch (err) {
hilog.warn(0x2026, TAG, 'deviceChange subscribe failed: %{public}s', JSON.stringify(err));
}
}
"先读再订阅"这个顺序是刻意的。反过来写,订阅成功到首次读取之间有一个窗口,用户恰好在这个窗口里拔了耳机,事件已经发出、监听已经就位,但初始状态还是订之前的旧值,界面会先显示一次错误结论再被下一次事件纠正。先读后订,最坏情况是漏掉一次变化,而这次变化的结果本来就在首次读取里。
stop() 对应地 off('deviceChange'),并清掉 manager 引用。应用退出时把回调摘掉,进程若还活着,也不该继续持有一份没人消费的订阅。
四、降级不是停播:只换一句话
有了前后两态,判定就变成查表。前态外接、后态机身,就是拔出;反过来是插入;同属外接但换了设备(有线换蓝牙),是切换。三种情况三句话,其中只有拔出这一句要写明降级结果。
private static onDeviceChange(action: audio.DeviceChangeAction): void {
const change: string = action.type === audio.DeviceChangeType.CONNECT ? 'CONNECT' : 'DISCONNECT';
const names: string = ShanhaiAudioRouteMonitor.listTypes(action.deviceDescriptors);
hilog.info(0x2026, TAG, 'deviceChange ' + change + ' devices=' + names);
// 事件只描述变化的那台设备;结论以全量查询为准,拔出时查询结果可能比事件晚一拍。
const next: ShanhaiAudioRouteSnapshot = ShanhaiAudioRouteMonitor.read();
const previous: ShanhaiAudioRouteKind = ShanhaiAudioRouteMonitor.lastKind;
ShanhaiAudioRouteMonitor.lastKind = next.kind;
let notice: string = '';
if (previous !== 'BUILT_IN' && next.kind === 'BUILT_IN') {
notice = NOTICE_DISCONNECT;
} else if (previous === 'BUILT_IN' && next.kind !== 'BUILT_IN') {
notice = NOTICE_CONNECT;
} else if (previous !== next.kind) {
notice = '输出已切换到' + next.label + '。';
}
ShanhaiAudioRouteMonitor.publish(next, notice);
hilog.info(0x2026, TAG, 'route -> ' + ShanhaiAudioRouteMonitor.describe(next) +
' notice=' + (notice.length > 0 ? notice : 'none'));
}
这里有一条明确的产品决定:路由变化不暂停讲解。听书类应用的惯例是耳机拔出即暂停,因为外放会打扰旁人;但这个应用是主动点开讲解才播的,用户拔耳机多半是"听完了,想边看边听",此时停掉需要再点一次"重新讲解",反而打断了正在进行的阅读。所以监控里没有一行碰 ShanhaiNarrationService,它只读状态、只写提示。
| 前态 | 后态 | 提示语 | 讲解行为 | 方位读数 |
|---|---|---|---|---|
| 有线/蓝牙/外接 | 外放扬声器 | 耳机已断开,方位音效降级为普通立体声,讲解继续。 | 不暂停 | 保留,但只作参考 |
| 外放扬声器 | 有线/蓝牙/外接 | 已连接耳机,空间方位恢复立体声。 | 不重启 | 恢复 |
| 外接 A | 外接 B | 输出已切换到「蓝牙音频」。 | 不暂停 | 保留 |
| 无变化 | 无变化 | 空串,界面不显示 | — | — |
状态怎么送到界面?没有用回调列表,也没有把 monitor 塞进页面。两个值写进 AppStorage:一个是路由快照(JSON,验收对表用),一个是提示语(界面用)。页面用 @StorageProp 只读订阅,并行视界里两份页面实例各自绑定同一份状态,不需要谁去手动通知谁。
// 两个键一起写:页面读提示,验收读快照。AppStorage 是跨页只读状态,不需要回调。
private static publish(snapshot: ShanhaiAudioRouteSnapshot, notice: string): void {
ShanhaiAudioRouteMonitor.snapshot = snapshot;
AppStorage.setOrCreate<string>(SHANHAI_AUDIO_ROUTE_KEY, JSON.stringify(snapshot));
AppStorage.setOrCreate<string>(SHANHAI_AUDIO_ROUTE_NOTICE_KEY, notice);
}
界面上的落点是地图页里那张说明卡。它原本已经有三行:模式标题、操作提示、声源方位;提示语只在非空时补第四行,插拔都没发生过的用户看不到任何变化。
// 地图页:只读订阅,不参与判定
@StorageProp(SHANHAI_AUDIO_ROUTE_NOTICE_KEY) audioRouteNotice: string = '';
// 说明卡:有提示才多一行
if (this.audioRouteNotice.length > 0) {
Text(this.audioRouteNotice)
.fontSize(11)
.fontColor(ShanhaiTheme.textSecondary(this.isDarkMode))
}

五、把结论钉在门禁里
这套逻辑最难测的地方在于:它只在插拔的那一秒有行为,平常永远是静默的。真机插拔当然要测,但插拔不可复现、不能天天跑,所以先把能静态钉住的部分钉死——一个契约脚本,26 条断言,读源码判断结构。
| 断言分组 | 钉住的内容 | 一旦破坏会怎样 |
|---|---|---|
| 路由查询 | 取 RoutingManager、查全量输出设备、四档归类、优先级取高 | 改成读事件里的设备 → 红 |
| 订阅生命周期 | start() 守卫、on/off 成对、Ability 一进一出 | 删守卫或删 off → 红 |
| 时序 | 先读当前路由、再订阅变化 | 调换顺序 → 红 |
| 判定 | 拔出/插入都对比前后两态 | 只比后态 → 红 |
| 文案与分发 | 拔出写"降级为普通立体声"、插入写"恢复"、两键都发布 | 删一句话 → 红 |
| 播放边界 | 监控不引用讲解服务、不调用停止 | 加一行 pause → 红 |
反向验证做了两次:把 start() 的守卫删掉、把 stop() 的 off('deviceChange') 换成 on(...),脚本只在"一次性守卫"和"取消订阅"两条上报错,退出码 1;把代码原样恢复,回到全绿。这说明断言确实咬在行为上,不是靠关键词碰运气。
构建侧:assembleHap 通过,33 个任务、15 秒 750 毫秒,签名与打包均正常,ArkTS 编译无新增错误(既有的两条 SDK 版本提示与本篇无关)。
全量回归:项目里 49 个 verify-*.mjs 全部通过,包括这篇新增的一个。
日志是留给真机的:domain 0x2026,TAG ShanhaiAudioRoute,三条固定格式——route init 外放扬声器 external=no devices=speaker 打启动时的初值,deviceChange DISCONNECT devices=wired_headset 打事件到达时系统报告的变化设备,route -> ... notice=... 打重新查询后的结论和最终提示。三行一对表,就能分清"事件说拔了"和"查询说确实只剩外放"是不是同一拍。本文的实现与门禁已按上述方式验证;插拔本身的实机日志要等设备接上再补,这里不写成已经跑过的结果。

六、这套做法没覆盖的部分
写完才发现,"耳机拔出"只是一条最直的路。下面三件事本篇没有做,也不打算含糊过去:
- 外放下的听感没有量化。 手机双扬声器的间距与朝向随握持姿势变化,同一个声场坐标在横屏和竖屏下的实际分离度不同。要说"外放时方位仍然可辨",得有 RMS 对照或主观测试,本篇没有,所以文案只说"降级为普通立体声",不说"仍能听出方向"。
- 蓝牙的延迟与重连没测。 A2DP 链路的编码缓冲会让声画不同步,重连期间路由可能短暂缺失;现在的实现按"当前查询结果"报状态,如果系统在重连窗口里返回空列表,界面会短暂回到外放文案。这是已知的抖动,没有加防抖。
- 音频焦点中断没有接。 来电、其他应用抢占焦点时,AVPlayer 与 TTS 引擎各自的暂停/恢复还没纳入同一条状态机。本篇的监控只管路由,不管焦点。

七、一句话收尾
能力探测回答"这台设备能不能做",路由监控回答"此刻做到哪去了"。前者的答案可以缓存一辈子,后者必须每次都重新问系统。把这两个问题混成一个布尔值,就会得到那种"设备支持空间音频,所以页头永远敢报方位"的界面——它没错,只是在耳机拔掉之后不再成立。
下一篇继续沿着"声音真的到了该去的地方"这条线走:把讲解放进后台之后,跨应用的播控与状态同步怎么对齐。
更多推荐
所有评论(0)