《Cocos 游戏开发入门》9.3 移动端音频限制与解锁

前两节的音频在桌面浏览器跑得好好的,一上手机就出问题:首次进游戏没声音、切到后台再回来 BGM 断了、iOS 静音键开着就完全没声。本节讲清浏览器的自动播放策略,用首次用户交互解锁 AudioContext,用 visibilitychange 处理切后台暂停与回前台恢复,并给出 Cocos Creator 3.8 的对照。

9.3 移动端音频限制与解锁

9.1 和 9.2 的音频系统在桌面浏览器里一切正常:进游戏、BGM 响起、音效叠加、总线控制,都符合预期。可一旦把它发布到 H5 或微信小游戏,立刻会收到三类反馈:

  • 「第一次进游戏没有声音,点了下屏幕才响。」
  • 「切出去接个电话,回来BGM 就没了。」
  • 「iPhone 上静音键一开,游戏一点声都没有。」

这些都不是 bug,而是浏览器与移动操作系统的主动限制。音频是少数会被平台出于用户体验(防止网页突然发声)和电量考虑而「管起来」的能力。本节把这些限制讲透,并给 mini2d 写出一套统一的解锁与生命周期处理。

与站内 game/ 顶层「引擎概念综述」的分工:那批文章讲概念,本套书则亲手把 mini2d 的解锁逻辑写出来并真跑测试。Cocos Creator 3.8 的对照放在本节末。

9.3.1 自动播放策略:为什么第一次没有声音

现代浏览器普遍实施 autoplay policy(自动播放策略):在用户对页面产生交互(点击、触摸、按键)之前,禁止音频自动播放。Chrome、Safari、Firefox 都有类似规则,移动端比桌面更严格。

规则的核心是一个概念——用户手势(user gesture)。只有当音频的播放动作发生在某个真实用户手势的事件回调里(如 click、touchend、keydown),浏览器才认为「这是用户主动要听的」,从而放行。

这对游戏意味着:游戏循环里调用的 play() 不算用户手势。哪怕玩家刚点过开始按钮,只要真正播放 BGM 的那一帧发生在几毫秒后的 requestAnimationFrame 里,就不在「手势窗口」内,照样被拦。

场景是否算用户手势结果
button.onclick = () => audio.play()是放行
setTimeout(() => audio.play(), 100) 由点击触发否被拦截
游戏循环首帧自动播 BGM否被拦截
玩家 touchend 后立刻播音效是放行

9.3.2 首次交互解锁:把 AudioContext 唤醒

被拦截时,HTMLAudioElement.play() 返回一个被拒绝的 Promise,错误名通常是 NotAllowedError。我们不能忽略它,而要捕获它、标记待解锁、等用户第一次交互时补播:

// mini2d/audio/unlock.ts
export class AudioUnlocker {
  private unlocked = false;
  private pending: Array<() => void> = [];

  get isUnlocked(): boolean {
    return this.unlocked;
  }

  /** 注册一次待解锁后要执行的动作(例如补播 BGM) */
  whenUnlocked(fn: () => void): void {
    if (this.unlocked) fn();
    else this.pending.push(fn);
  }

  /** 在首次真实用户手势里调用 */
  unlock(): void {
    if (this.unlocked) return;
    this.unlocked = true;
    for (const fn of this.pending) fn();
    this.pending = [];
  }
}

再把「捕获被拒绝的 play」封装成一个安全播放函数:

export async function safePlay(
  el: HTMLAudioElement,
  unlocker: AudioUnlocker,
  retry: () => void,
): Promise<void> {
  try {
    await el.play();
  } catch (err) {
    // 自动播放被拦截:记下来,等用户交互后重试
    unlocker.whenUnlocked(retry);
  }
}

解锁的触发点要挂在一组手势事件上,任一命中即解锁,然后立刻摘掉监听:

export function installUnlockListeners(unlocker: AudioUnlocker): void {
  const events: Array<keyof WindowEventMap> = ['pointerdown', 'touchend', 'keydown'];
  const handler = (): void => {
    unlocker.unlock();
    for (const e of events) window.removeEventListener(e, handler);
  };
  for (const e of events) window.addEventListener(e, handler, { passive: true });
}

只监听一次、解锁后立即移除,是刻意的:这些事件在游戏中会被高频触发,留着监听器只是白白消耗性能。用 pointerdown 而非 click,是因为移动端 click 有约 300ms 延迟,玩家会感觉「点了半天才出声」。

9.3.3 Web Audio 的 resume:另一种被挂起的状态

如果你用 Web Audio API(AudioContext)而不是 HTMLAudioElement,限制会以另一种形式出现:AudioContext 创建后处于 suspended(挂起)状态,必须调用 resume() 才能出声。

// mini2d/audio/context.ts
let ctx: AudioContext | null = null;

export function getAudioContext(): AudioContext {
  if (!ctx) ctx = new AudioContext();
  return ctx;
}

export async function resumeAudioContext(): Promise<void> {
  const c = getAudioContext();
  if (c.state === 'suspended') {
    await c.resume(); // 同样必须在用户手势回调里调用
  }
}

两种后端的对应关系可以这样记:

概念HTMLAudioElementWeb Audio API
播放单元<audio> 元素AudioBufferSourceNode
被挂起的状态play() 返回 rejected Promisectx.state === 'suspended'
解锁动作重新 play()ctx.resume()
音量控制el.volumeGainNode.gain

9.2 里我们用 el.volume 做总线,正是因为 mini2d 主后端选了 HTMLAudioElement。如果改用 Web Audio,总线就应改成串联 GainNode:master 节点接 destination,bgm/sfx 节点接 master,每条声音接自己的总线——乘法关系天然由节点图的串联表达,比手动乘更不易写错。

9.3.4 切后台:visibilitychange 暂停与恢复

第二个高频问题是「切出去再回来就没声了」。原因是移动浏览器在页面不可见时会挂起音频以省电,回到前台不会自动恢复。

解法是监听 visibilitychange:隐藏时暂停、可见时恢复。注意恢复 BGM 要重新 play(),因为被挂起的元素不会自己续播:

// mini2d/audio/lifecycle.ts
import type { AudioUnlocker } from './unlock';

export function installVisibilityHandler(
  pauseAll: () => void,
  resumeAll: () => void,
): void {
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      pauseAll(); // 切到后台:停掉 BGM 与所有音效,省电
    } else {
      resumeAll(); // 回前台:恢复 BGM(音效不补播)
    }
  });
}

一个关键设计决策:回前台只恢复 BGM,不补播音效。音效是「事件驱动」的,事件早已过去,补播反而突兀;BGM 是「状态驱动」的,玩家回来时它本就该继续响。

// 在《方块冲刺》里接线
const unlocker = new AudioUnlocker();
installUnlockListeners(unlocker);
installVisibilityHandler(
  () => bgm.pause(),
  () => bgm.resume(), // 内部:若未解锁则挂到 unlocker,否则直接 play
);

9.3.5 iOS 的两个额外坑

移动端还有两个平台特有的行为,值得单独记一笔:

  1. iOS 静音开关:iPhone 侧边的物理静音键打开时,HTMLAudioElement 会完全静音(Web Audio 在某些版本下反而不受影响)。这是系统级行为,网页无法绕过,也无法检测静音键状态。上线前必须在真机上测,不要只在模拟器里听。

  2. 音频会话中断:来电、闹钟、其它 App 抢音频焦点时,iOS 会中断当前音频会话。恢复后 AudioContext 可能停在 interrupted(中断)状态,需要再次 resume()。我们的 resumeAudioContext 每次回前台都调用一次,正好能覆盖这种情况。

把这三类问题汇总成一张速查表,上线前逐条对照,能挡掉绝大多数「声音不对劲」的反馈:

症状根因解法
首次进游戏没声音,点一下才响自动播放策略拦截了非手势播放手势回调里 unlock(),补播挂起的请求
切后台回来 BGM 断了页面隐藏时音频被挂起visibilitychange 回前台重新 play()
iOS 全程静音物理静音键打开真机测试;无法用代码绕过
来电后声音异常音频会话被中断回前台再次 resume()
音效偶尔丢几声并发通道超限被丢弃9.1 的音效池抢占策略
连点后声音越来越吵同类音效无限叠加播放前 stopByName 或设并发上限

把「解锁」和「生命周期」合并成统一入口,mini2d 的音频系统对外就只剩两件事要做:

// 启动时
const unlocker = new AudioUnlocker();
installUnlockListeners(unlocker);
installVisibilityHandler(() => bgm.pause(), () => bgm.resume());

// 需要播放时,一律走 whenUnlocked 包装
unlocker.whenUnlocked(() => bgm.play('level_run'));

所有播放请求都经过 whenUnlocked,是这套设计的纪律。它把「现在能不能播」的判断集中到一处,业务代码再也不用自己判断解锁状态。

9.3.6 Cocos 对照:sys 与 Game.EVENT_HIDE / EVENT_SHOW

Cocos Creator 3.8 把这些平台差异封在引擎里,但仍需我们在脚本里响应生命周期事件。以下为 L2 片段(import { ... } from 'cc'):

import { _decorator, Component, game, Game, sys, AudioSource } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('AudioLifecycle')
export class AudioLifecycle extends Component {
  @property(AudioSource)
  bgm: AudioSource | null = null;

  onLoad(): void {
    game.on(Game.EVENT_HIDE, this.onHide, this);
    game.on(Game.EVENT_SHOW, this.onShow, this);
  }

  onDestroy(): void {
    game.off(Game.EVENT_HIDE, this.onHide, this);
    game.off(Game.EVENT_SHOW, this.onShow, this);
  }

  private onHide(): void {
    this.bgm?.pause();
  }

  private onShow(): void {
    if (sys.isMobile) {
      this.bgm?.play(); // 移动端回前台需显式恢复
    } else {
      this.bgm?.play();
    }
  }
}

对照要点:

  • Game.EVENT_HIDE 的字符串值在 cc.d.ts 里是 "game_on_hide",Game.EVENT_SHOW 与之对应;game 是引擎导出的单例(export const game: Game),game.on(type, callback, target?, once?) 的签名与浏览器 addEventListener 类似,但注册了就要在 onDestroy 里 game.off,否则节点销毁后回调仍会触发。
  • sys.isMobile 与 sys.platform(取值来自 sys.Platform)可以判断运行平台,用来分支处理移动端专属的恢复逻辑。
  • 自动播放解锁同样存在:Cocos 官方建议在 TOUCH_END 或 MOUSE_UP 的回调里调用 AudioSource.play(),这正是 9.3.2 中「在手势回调里播放」的引擎级封装。若首次在非手势环境播放,引擎会推迟到下一次用户交互时自动播放。
  • 持久化偏好用 sys.localStorage,接口与浏览器原生一致,9.2 的 loadSettings / saveSettings 可直接复用。

到这里,第九章的音频系统就完整了:9.1 的音效池解决并发与复用,9.2 的总线解决音量组织,9.3 的解锁与生命周期解决平台限制。三节合起来,是一套能在桌面浏览器、H5、微信小游戏上都稳定发声的音频层。下一章转向资源与加载——当音频、图片、配置都以文件形式存在时,怎么组织清单、异步加载并正确释放它们。

阅读导航:上一节:9.2 BGM、淡入淡出与音量总线 · 下一节:10.1 资源清单与依赖图 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画