Cover Image 画中游 | 九道梵音 #Pixiv
Android 音频焦点与共存播放机制研究
前言
最开始处理 Flutter 音视频播放的“后台被抢焦点就暂停”问题时,很容易先入为主地认为:既然想和网易云或其它音乐软件“同时播放”,那我不去抢音频焦点不就得了?毕竟按照直觉,你不惹事,别人自然也不会来弄停你。
但真往 Android 的底层和实现里翻,就会发现事情没有这么简单。在现代 Android(尤其是 Android 15+ 引入了 AudioHardening 机制后),音频焦点的管理可以说是一门非常“玄学”且充满强权的艺术。
本文主要基于 PiliNara 的实际迭代经验和系统级日志,整理一下 Android 音频焦点的运作机制,以及在 Flutter 环境下,如何利用系统协议的“潜规则”来实现与其他应用的完美混音共存。
什么是音频焦点(Audio Focus)?
它并不是物理排他的
我们常听说的“音频焦点(Audio Focus)”,在 Android 系统里本质上并不是一个物理开关,而是一种**“礼貌协议”(Cooperative Protocol)**。
当你想播放声音时,你需要向系统(AudioManager)发出一个 requestAudioFocus 请求,声明你需要占用声道。系统会根据你请求的等级,给当前正在播放的其他 App 发送不同级别的 LOSS(丢失焦点)回调:
AUDIOFOCUS_LOSS (-1):永久丢失。比如接到了电话,或者用户打开了另一款大型的独占音乐 App。AUDIOFOCUS_LOSS_TRANSIENT (-2):临时丢失。比如微信来了条提示音,或者用户在那边刷了一个短视频。AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK (-3):临时丢失,但允许你“低头”(Duck)。比如导航播报时,你可以把自己的音量压低一半,不用完全闭嘴。
TIP对应谷歌文档的链接:AudioManager
核心事实:收到这些信号后,系统通常不会直接物理做相关的操作(前提是你是合法的媒体应用),而是看你自己的代码怎么写。常规的、老实的 App 收到 -1 或 -2 时,代码里会乖乖调用 player.pause()。于是外界看起来就像是“被抢走焦点,声音断了”。
“不申请焦点”行不通吗?
既然收到信号就要乖乖暂停,那最直觉的想法自然是:那我干脆一上来就不申请音频焦点,是不是系统就不管我了?
在早年间的 Android 系统中,这确实可行。但在最新的 Android 15+ 机制下,这其实是在“裸奔”。系统引入了 AudioHardening 策略。如果你作为一个后台媒体应用,试图在没有持有有效音频焦点(或相应权限等级不足)的情况下向硬件输出音频数据,系统会立刻探测到你的非法行为,并从硬件层强制执行 Mute(静音)。
抓到的底层日志非常直白:
AudioHardening background playback would be muted for com.example.pilinara (10164), level: full所以得出一个非常明确的结论:想要播放,你必须去拿焦点,这是“准入证”。
“厚脸皮”协议:两个独占焦点如何共存
既然必须老老实实当“大哥”去申请最高级别的独占媒体身份(AUDIOFOCUS_GAIN,即 req=1),那当其他音乐软件也申请 req=1 的时候,必定有一方会收到 LOSS_TRANSIENT 或 LOSS 信号。那为什么我们在用网易云音乐时,开启了对应的与其他应用同时播放功能却能一边听歌一边顺畅地刷 B站?
这就是 Android 音频焦点机制中最有意思的地方。
如前所述,系统发出的 LOSS 信号更像是一种“道德约束”。当网易云(App B)发起 req=1 抢焦点时,系统给当前正在播放的前台 App A 发送了一个 LOSS_TRANSIENT (-2) 信号。
- 普通 App 的反应:收到信号 -> 触发监听 -> 乖乖暂停。
- 网易云 / 厚脸皮 App 的反应:收到信号 -> 代码里直接写一个
return // ignore this signal-> 拒不暂停,继续疯狂往底层的AudioTrack塞数据。
结果就是,Android 系统的底层混音器(AudioFlinger)看到两个流都有数据源,就会机械地把两个 App 的数据帧混合在一起。只要你具备了前台媒体服务(mediaPlayback)的系统权限,系统并不会直接封杀你。这就是实现两个 GAIN 级别共存的底层密码。
Flutter (audio_session) 下的最佳实践
分析完了底层逻辑,在 Flutter 项目中,尤其是当底层视频库(如基于 mpv 的 media_kit)把焦点管理都全权交给我们另外引入的 audio_session 插件时,该如何写这套逻辑呢?
这也是 PiliNara 在兼容混音播放时踩过的一个大坑。这块逻辑真正难的地方,不是“怎么去骗过系统”,而是你到底该在什么时候该装聋作哑,什么时候该乖乖闭嘴。
如果无脑忽略所有系统中断,那在用户接到语音通话时,你的视频声音还在震天响,这就是严重的体验事故了。
目前在 PiliNara 里的处理方式如下:
1. 配置层:持证上岗
首先是需要请求相关的权限
- 修改 AndroidManifest.xml (必须) 从 Android 14 开始,系统要求必须显式声明前台服务类型。对于视频/音频播放,必须声明 mediaPlayback。
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application ...> <service android:name=".YourPlaybackService" android:foregroundServiceType="mediaPlayback" android:export="false"> </service></application>对于希望“与其他应用共存”的场景,我们在 Android 端坚决不再使用类似 androidAudioFocusGainType: null 这样企图隐身的危险写法(实际上这也是违背 Dart 强类型语法的),而是恢复它作为主权媒体应用的声明。
// 在 Android 上保持不变,老老实实申请最高级别焦点await session.configure(const AudioSessionConfiguration.music());这确保了我们的 App 在系统层面拥有“准入证”。
2. 逻辑层:精准拦截“劝退”信号
真正的核心手术在 interruptionEventStream 监听器里。当产生冲突时:
- 系统发来
AudioInterruptionType.pause(对应原生的LOSS_TRANSIENT):比如打开了别的短视频软件。这时候如果用户开启了mixWithOthers选项,选择“厚脸皮”,直接return忽视他。 - 系统发来
AudioInterruptionType.unknown(对应原生的LOSS高危信号):比如打进来了电话。这种涉及严重系统资源回收或高优先级通话时,必须强制暂停。
具体的代码实现实际上非常轻量:
session.interruptionEventStream.listen((event) { if (event.begin) { switch (event.type) { case AudioInterruptionType.duck: // 被压低音量,比如系统导航语音 PlPlayerController.setVolumeIfExists(... * 0.5); break;
case AudioInterruptionType.pause: // 如果系统发来临时切断(比如外部播放音乐),且我们开启了同时播放选项,直接拦截(装聋作哑) if (Pref.mixWithOthers) return;
// 否则乖乖暂停 PlPlayerController.pauseIfExists(isInterrupt: true); break;
case AudioInterruptionType.unknown: // 来了电话这种硬断事件,无论如何都要老实暂停 PlPlayerController.pauseIfExists(isInterrupt: true); break; } } else { // 恢复逻辑... }});。
总结
到这里其实已经能得出一个明确的结论:
- 完全避开焦点申请不仅是错的,而且非常危险,尤其在现代 Android 系统的
AudioHardening环境下。 - 真正想要实现像网易云一样的系统级完美并存,本质上就是脸皮厚:去申请最大、最长效的焦点保护自己的存活,然后在收到别人想抢你位置的
LOSS_TRANSIENT通知时装作没听见。