后台播放链路打通以后,真正考验播放器的是另一种变化:声音突然被来电或其他媒体打断。应用按钮还显示暂停图标,系统已经让音频流安静下来;通话结束后,应用又可能在用户不期望的时候重新出声。声笺系列最后一篇把焦点事件接回同一个引擎,并采用清楚的产品策略:中断结束后提示可以继续,由用户主动恢复播放。

音频焦点切换与用户恢复意图的技术封面

一、先区分两种“共享”

音频焦点里有两组容易混淆的概念。共享焦点模式描述同一应用内多个音频流如何参与焦点管理;共享打断类型描述某一次事件需要由应用自行响应。名称相近,作用层级不同。声笺只有一个音频流,保持默认共享焦点模式即可;收到事件后,仍需检查具体提示和是否已经由系统强制执行,不能因为采用共享模式就认为所有中断都交给应用处理。

接口从 @kit.AudioKit 引入音频类型,事件通过 AVPlayer 的 audioInterrupt 监听。本文仍使用 API 12 基线,焦点模式只在播放器允许的状态设置;音频渲染用途若要修改,则需在初始化状态、第一次准备之前配置。当前资源是普通音乐型音频,默认用途与业务一致,没有必要为争抢焦点冒用通话或闹钟用途。

官方 焦点管理指南 说明了暂停、停止、降音、恢复音量与允许继续的事件语义。这里最关键的是:允许继续只表示竞争条件变化,不代表用户的新播放指令。把允许恢复直接绑定到播放函数,在连续来电、用户手动暂停和切换音频等场景中,都容易形成意料之外的声音。

另一条经验是不要把底层音频流状态直接等同于 AVPlayer 状态。官方 音频焦点最佳实践 特别指出,强制暂停了音频流之后,AVPlayer 的状态不一定自动切到暂停,应用仍需调用播放器暂停接口完成状态一致性。这个细节也解释了为什么只改页面图标或者只相信“系统已处理”都不够。

二、把焦点信息加入同一份快照

修改第一篇的 AudioSnapshot,增加焦点提示和降音标记,并在 AudioEngine.snapshot() 中复制它们。既然页面已经订阅快照,就不再增加一个独立事件总线。媒体会话继续关注播放器状态,页面增加焦点原因说明;这样“当前暂停”和“为什么暂停”分别有数据表达,不需要让一个状态字符串承担所有含义。

// AudioSnapshot 类内增加。
focusText: string = '音频焦点就绪';
ducked: boolean = false;

// AudioEngine.snapshot() 中,return next 之前增加。
next.focusText = this.value.focusText;
next.ducked = this.value.ducked;

在 AudioEngine.ets 导入音频模块,并把下面的方法加入 AudioEngine。暂停和停止提示都会经过原有暂停入口,其内部仍会在命令执行时检查播放器状态。停止提示表示焦点不会按临时暂停路径自动归还,声笺保留位置供用户之后主动播放,不自动发起新的焦点争夺。

import { audio } from '@kit.AudioKit';

// 以下字段与方法加入 AudioEngine 类。
private sharedDuckActive: boolean = false;

private handleInterrupt(event: audio.InterruptEvent): void {
  if (this.closing) { return; }
  const forced = event.forceType === audio.InterruptForceType.INTERRUPT_FORCE;
  switch (event.hintType) {
    case audio.InterruptHint.INTERRUPT_HINT_PAUSE:
      this.value.ducked = false;
      this.value.focusText = '音频被暂时打断';
      this.pause();
      this.clearSharedDuckAfterPause();
      break;
    case audio.InterruptHint.INTERRUPT_HINT_STOP:
      this.value.ducked = false;
      this.value.focusText = '焦点已释放,请手动播放';
      this.pause();
      this.clearSharedDuckAfterPause();
      break;
    case audio.InterruptHint.INTERRUPT_HINT_RESUME:
      this.value.focusText = '焦点已恢复,可手动播放';
      break;
    case audio.InterruptHint.INTERRUPT_HINT_DUCK:
      this.value.ducked = true;
      this.value.focusText = forced ? '系统已降低音量' : '收到降音请求';
      if (!forced) {
        this.sharedDuckActive = true;
        this.setSharedDuckVolume(0.2);
      }
      break;
    case audio.InterruptHint.INTERRUPT_HINT_UNDUCK:
      this.value.ducked = false;
      this.value.focusText = forced ? '系统已恢复音量' : '收到音量恢复请求';
      if (!forced) {
        this.sharedDuckActive = false;
        this.setSharedDuckVolume(1);
      }
      break;
    default:
      return;
  }
  this.emit();
}

private clearSharedDuckAfterPause(): void {
  if (!this.sharedDuckActive) { return; }
  this.enqueue(async () => {
    const player = this.player;
    if (this.closing || !player) { return; }
    if (['prepared', 'paused', 'completed'].includes(player.state)) {
      player.setVolume(1);
      this.sharedDuckActive = false;
    }
  });
}

private setSharedDuckVolume(volume: number): void {
  this.enqueue(async () => {
    const player = this.player;
    if (this.closing || !player) { return; }
    if (['prepared', 'playing', 'paused', 'completed'].includes(player.state)) {
      player.setVolume(volume);
    }
  });
}

降音需要检查强制类型。系统已经降低音量时,应用再乘一次衰减系数,就可能产生重复衰减;恢复时盲目写最大音量,也可能覆盖用户自己的音量选择。当前示例没有应用内音量设置,默认媒体相对音量为一,因此共享降音请求使用固定比例,恢复到原基线。若共享降音之后又收到暂停或停止,先把暂停入队,再恢复应用音量基线;清理方法再次确认播放器确实已暂停,避免暂停失败时擅自恢复大音量。若后续增加音量滑块,必须保存用户基线,再计算临时衰减,不能继续硬编码恢复到一。

setVolume 接口发出的是设置请求,真正生效可通过音量变化事件观察。代码没有把共享降音请求文案写成测量结论;必要时可增加该事件订阅来完善反馈。对于系统强制降音,按照事件语义显示系统已处理即可,不需要再自己建立一个更高频的音量监测循环。焦点事件中的数字也不应被包装成应用的性能测试结果。

三、注册时机、清理与页面反馈一起改

在第一篇 load() 中、设置资源之前加入监听。保持第三篇的跳转监听与错误处理,不替换已有状态事件。退出时注销音频中断监听,确保已经结束的引擎不会继续响应下一轮系统焦点事件。

// 与 stateChange、timeUpdate 等监听放在一起。
player.on('audioInterrupt', (event: audio.InterruptEvent) => {
  this.handleInterrupt(event);
});

// close() 中,与其他 off 相邻,放在 release() 之前。
player.off('audioInterrupt');

在原来 stateChange 的准备完成分支中声明共享焦点模式,保留读取时长的代码。在播放状态真正到达时再把提示恢复为正常播放,避免用户点击按钮但起播失败时,页面已经提前清除了中断原因。

if (state === 'prepared') {
  this.value.durationMs = Math.max(0, player.duration);
  player.audioInterruptMode = audio.InterruptMode.SHARE_MODE;
}
if (state === 'paused' || state === 'completed') {
  this.value.ducked = false;
  this.clearSharedDuckAfterPause();
}
if (state === 'playing' && !this.value.ducked) {
  this.value.focusText = '正常播放';
}

在第一篇 play() 允许播放的分支中,紧接 await player.play() 之前补上以下代码。它兜住快速暂停后立即播放的顺序:若基线恢复还在队列后方,起播前先恢复一次;真正仍处于共享降音时则保留衰减。

if (this.sharedDuckActive && !this.value.ducked) {
  player.setVolume(1);
  this.sharedDuckActive = false;
}

在第四篇播放页面 Column 的时间文本之后加入下面的提示。原来的播放按钮继续调用引擎,系统播控仍走 MediaBridge,所以用户从任意控制入口主动恢复,都走第一篇已有的状态校验与命令串行链。没有任何恢复分支直接把快照设置成播放中。

Text(this.snapshot.focusText)
  .fontSize(14)
  .fontColor(this.snapshot.ducked ? '#9A6428' : '#607580')

AudioEngine 的中断分支与右侧待手动恢复界面

这时第四篇的后台管理也会自然接入:当暂停操作使播放器进入暂停状态,运行时收到快照变化并取消长时任务;用户重新播放后,实际播放事件触发新的申请。焦点层不再直接操作后台任务,避免同一次中断从两个地方重复取消。不同模块只处理自己的职责,通过事实变化协作。

如果暂停调用失败,错误仍由第一篇命令链捕获。此时不能仅凭中断文案断言播放器状态已经同步,需要结合实际状态与错误继续定位。系统已让声音安静下来与播放器内部状态已切换,是两件相关但不同的事。保留事件、命令结果和最终状态三条证据,才能解释这类表面上“没有声音却仍显示播放”的问题。

四、把恢复策略写成产品规则

声笺选择手动恢复,是针对专注音景这一产品场景做出的取舍:用户离开通话后可能已经转去其他任务,应用先提示可继续,避免突然出声。这不是系统强制要求所有音频应用都采用的规则。音乐播放器可以选择自动恢复,但需要记录中断前是否正在播放、用户是否在中断期间主动暂停、音源是否被切换以及业务是否仍存活。

自动恢复至少应有一个可失效的恢复凭据,而不是一个永久保存的布尔值。中断前正在播放时创建凭据,用户主动暂停或切换音源时使其失效,恢复事件到达后只消费仍然有效的凭据。凭据还应关联当前音源或播放代次,防止旧音源收到的恢复事件把新内容拉起来。本文没有自动恢复需求,所以直接取消这条隐含动作,比维护一个没有必要的复杂凭据更清楚。

降音与暂停也不能统一处理。导航提示可能只需要降低背景音乐音量,播放器应继续推进位置,后台任务也继续匹配播放业务;暂停则保留位置并停止推进。若把两者都改成暂停,短促提示会让音频频繁断裂;若把暂停都当成降音,通话期间仍可能继续消耗播放位置。每一种提示都需要与实际产品体验对应。

永久失去焦点之后也不应等待一个必然会来的恢复事件。停止提示与临时暂停的后续语义不同,声笺会明确提示手动播放。用户再次发出播放意图时,再由系统判断新的焦点申请是否可接受;应用不在后台用定时器循环尝试抢回播放权。这也让失败反馈和资源使用更容易追踪。

五、完成五篇链路的联合验证

联合验证从正常播放开始,先触发临时打断,再结束打断。观察声音暂停、播放器状态变化、系统播控状态更新和后台任务取消;结束后页面提示可以手动播放,音频不会自行继续。接着主动播放,观察真实播放事件与后台重新申请。这里关注的是因果顺序,不预设不同设备上的通知布局完全相同。

第二组使用会导致降音的并发音频场景,检查位置仍然推进,页面提示改变,音量恢复时不触发额外播放调用。第三组让另一媒体应用获得播放焦点,检查声笺不反复抢回。第四组在中断期间退出播放业务,确认之后到达的事件无法重新打开播放器,也不会让已经销毁的页面刷新。

未分层处理与按焦点语义处理的行为对比

验证还应覆盖页面不在前台的情况。焦点监听属于引擎,不能依赖当前页面存在;系统播控和长时任务也由业务运行时持有。若只有打开页面时能暂停一致、切后台就出现状态残留,优先检查监听是否错误地绑在页面生命周期,而不是先添加新的后台权限。前四篇建立的所有权关系在这里有了完整的检验场景。

至此,声笺的最小播放链路有了清楚的分层:引擎处理系统播放器及焦点,桥接层翻译媒体会话,运行时管理 Ability 生命周期与后台任务,页面只显示快照并提交交互意图。进度草稿没有进入系统状态,系统恢复提示没有冒充用户指令,页面消失也没有等同于业务结束。继续扩展播放列表、进度持久化或网络音源时,应该保留这些边界,再为新增状态建立自己的确认与清理路径。

参考资料

Logo

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

更多推荐