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(); // 同样必须在用户手势回调里调用
}
}
两种后端的对应关系可以这样记:
| 概念 | HTMLAudioElement | Web Audio API |
|---|---|---|
| 播放单元 | <audio> 元素 | AudioBufferSourceNode |
| 被挂起的状态 | play() 返回 rejected Promise | ctx.state === 'suspended' |
| 解锁动作 | 重新 play() | ctx.resume() |
| 音量控制 | el.volume | GainNode.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 的两个额外坑
移动端还有两个平台特有的行为,值得单独记一笔:
iOS 静音开关:iPhone 侧边的物理静音键打开时,
HTMLAudioElement会完全静音(Web Audio 在某些版本下反而不受影响)。这是系统级行为,网页无法绕过,也无法检测静音键状态。上线前必须在真机上测,不要只在模拟器里听。音频会话中断:来电、闹钟、其它 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 资源清单与依赖图 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。