用途
该技能用于解决 Android 应用中同时存在多个播放器实例时的音频焦点管理问题。Android 默认的每播放器焦点处理假设整个应用生命周期内只有一个播放器,而交叉淡入淡出等场景会不断创建、提升和释放播放器实例,从而打破这一假设。
问题背景
在交叉淡入淡出中,释放即将退出的实例会放弃整个应用的音频焦点;而新进入的实例在构建时关闭了焦点处理,因此不会重新申请焦点。结果是应用在无焦点状态下播放,系统会在下一个时机暂停它,或者其它应用接管后它再也无法恢复。
主要能力
- 由负责切换的组件统一持有一次焦点请求,使焦点在每次切换后依然存活,因为没有任何播放器实例持有过它。
- 所有播放器实例(包括预缓存的实例)均设置
handleAudioFocus = false。 - 提供幂等的焦点申请方法,可在多条启动播放的路径上安全调用。
使用方式
构建播放器时关闭焦点处理:
ExoPlayer.Builder(context)
.setAudioAttributes(audioAttributes, /* handleAudioFocus = */ false) // every instance
.build()
使用惰性字段持有单个焦点请求:
private val audioFocusRequest by lazy {
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(/* USAGE_MEDIA, CONTENT_TYPE_MUSIC */)
.setOnAudioFocusChangeListener(audioFocusListener)
.setWillPauseWhenDucked(false) // we duck ourselves
.build()
}
幂等的焦点申请:
private fun requestAudioFocusInternal(): Boolean {
if (hasAudioFocus) return true // idempotent
hasAudioFocus = audioManager.requestAudioFocus(audioFocusRequest) ==
AudioManager.AUDIOFOCUS_REQUEST_GRANTED
return hasAudioFocus
}
陷阱
- 仅在“主”实例上保留
handleAudioFocus = true。不存在主实例——持有焦点的实例在每次切换结束时都会被释放。应对每个构建的实例(包括预缓存的实例)都设为 false,让适配器持有唯一的请求。 - 仅从播放按钮申请焦点。播放也会从用户未点击的路径启动:启动新进入实例的切换,以及队列推进时使用的加载并播放路径。每条路径都必须调用幂等的申请。由于在持有焦点时它是空操作,在三处调用没有成本,并能堵住瞬时丢失后应用在无焦点状态下恢复的缺口。
- 每次释放播放器都放弃焦点。只在播放真正结束的地方放弃——
stop()、release(),以及将播放交给另一个输出的分支。作为切换一部分的释放不得触碰焦点。将请求对象本身保留为单个惰性字段:用与已授予的请求对象不同的对象放弃焦点不会产生任何效果,并且会静默失败。 - 在与淡入淡出斜坡相同的增益线上做 ducking。交叉淡入淡出在每次切换中会重写播放器音量约 50 次。写入同一行的 duck 会被下一个斜坡步骤擦除,而当 duck 解除时它会在淡入淡出进行中写入满音量——听起来就像音乐在切换中途跳回满音量。应在淡入淡出标志上对两个方向都加保护:
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK ->
if (!isCrossfading) currentPlayer?.volume = internalVolume * duckVolumeFactor
AudioManager.AUDIOFOCUS_GAIN ->
if (!isCrossfading) currentPlayer?.volume = internalVolume // don't fight the ramp
这是一种变通方法,不是根治方案。结构性修复是睡眠定时器淡出已经需要的:给 duck 自己的增益线,并在音频路径上将各条线相乘,这样任何写入者都无法擦除另一个。一旦出现第二条自动增益就应采用该方案——一条线上两个写入者就是这种模式,每新增一个都会使交互翻倍。
- 将 duck 恢复到硬编码的 1.0f。应恢复到用户级别(
internalVolume),绝不要恢复到满音量。恢复到满音量会在每次导航提示后静默丢弃音量设置。 - 将三种丢失同等对待。它们含义不同,需要不同的响应:
| 焦点变化 | 响应 |
|---|---|
| LOSS | 永久——暂停,清除恢复标志,标记焦点未持有 |
| LOSS_TRANSIENT | 暂停,并记录当时是否确实在播放,以便仅在此时恢复 |
| LOSS_TRANSIENT_CAN_DUCK | 衰减,不暂停 |
| GAIN | 恢复级别,仅在标志已设置时恢复播放 |
在 GAIN 时无条件恢复会播放用户在中断前已暂停的音乐。
- 在程序化淡入淡出运行时 ducking。duck 和睡眠定时器淡出是两种独立的衰减。一旦各自拥有自己的线,答案就只是它们相乘;共享一条线会让它成为一个没有好答案的问题。
- 假设焦点和媒体会话一致。切换会将会话的监听器移到新进入的实例。焦点无关且是应用范围的。不要从一方推导另一方,也不要让会话自身的焦点处理与你的同时启用——两个所有者会互相冲突。
验证方法
- 在每次申请和每次放弃时记录授予结果。从第一个项目到最后一个项目之间,
hasAudioFocus绝不能变为 false;队列中间出现 false 就是 bug。 - 在屏幕关闭的情况下播放至少三次切换。播放器在项目之间中断是切换时焦点丢失的典型特征。
- 在稳定播放期间和淡入淡出中途分别触发 duck(导航提示,或任何请求 transient-can-duck 的应用),并确认两种情况后级别都恢复到用户级别。
- 在播放中途和淡入淡出中途接听电话。正确:暂停,然后以正确级别恢复。错误:以满音量恢复,或在你已暂停时恢复。
grep -n "requestAudioFocus\|abandonAudioFocus"应显示一个请求对象、来自每条启动音频路径的申请,以及仅来自终止路径的放弃。
适用场景
适用于 Android 应用中同时存在多个播放器实例的场景,例如双播放器交叉淡入淡出、预缓存播放器等。
限制
该技能描述的 ducking 保护是一种变通方法,而非根治方案;结构性修复需要为 duck 提供独立的增益线并在音频路径上相乘。