# 〖共创稿事节〗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 引擎各自的暂停/恢复还没纳入同一条状态机。本篇的监控只管路由,不管焦点。

三态判定与界面落点

七、一句话收尾

能力探测回答"这台设备能不能做",路由监控回答"此刻做到哪去了"。前者的答案可以缓存一辈子,后者必须每次都重新问系统。把这两个问题混成一个布尔值,就会得到那种"设备支持空间音频,所以页头永远敢报方位"的界面——它没错,只是在耳机拔掉之后不再成立。

下一篇继续沿着"声音真的到了该去的地方"这条线走:把讲解放进后台之后,跨应用的播控与状态同步怎么对齐。

Logo

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

更多推荐