Cocos Creator 切后台后背景音乐不恢复?这招比 pause/resume 更靠谱

问题背景

做 Web 游戏和小游戏的同学应该都遇到过这个坑:用户切到后台再回来,背景音乐要么不恢复,要么干脆不受控制了。特别是在微信小游戏里,cc.audioEngine.resumeMusic() 经常不起作用。

这篇文章是我在实际项目中踩坑后的经验总结。问题根源在于 Web Audio 的 AudioContext 会在切后台时被浏览器挂起或释放,导致后续操作失效。

Cocos Creator 音频系统概览

音频引擎架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
┌─────────────────────────────────────────────────────────────────────┐
│ Cocos Creator 音频系统架构 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ JavaScript API Layer │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ cc.audioEngine │ │
│ │ • playMusic(url, loop) - 播放背景音乐 │ │
│ │ • stopMusic() - 停止背景音乐 │ │
│ │ • pauseMusic() - 暂停背景音乐 │ │
│ │ • resumeMusic() - 恢复背景音乐 │ │
│ │ • setMusicVolume(vol) - 设置音量 │ │
│ │ • playEffect(url, loop) - 播放音效 │ │
│ │ • stopEffect(id) - 停止指定音效 │ │
│ │ • stopAllEffects() - 停止所有音效 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Platform Abstraction Layer │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Web │ │ WeChat │ │ Native │ │ ByteDance │ │
│ │ Audio │ │ MiniGame │ │ Audio │ │ MiniGame │ │
│ │ (WebAudio │ │ (Inner │ │ (OpenAL │ │ │ │
│ │ / HTML5) │ │ Audio) │ │ / Audio) │ │ │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

音频类型区分

类型 API 特点 使用场景
Music playMusic / stopMusic 单轨播放,同一时间只能一首 背景音乐
Effect playEffect / stopEffect 多轨并发,有独立 ID 音效、UI 反馈

切后台问题的根源分析

问题现象

在 Web 浏览器或微信小游戏环境中,当用户执行以下操作时:

  1. 按下 Home 键切换到其他应用
  2. 锁屏
  3. 切换到其他浏览器标签页
  4. 接听电话

游戏进入后台,此时调用 cc.audioEngine.pauseMusic() 暂停音乐。当用户返回游戏时,调用 cc.audioEngine.resumeMusic(),却发现:

  • 音乐没有恢复播放
  • 或者播放了但不受 cc.audioEngine 控制(后续调用 stop/resume 无效)
  • 或者出现爆音、音频上下文错误

问题原因

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌─────────────────────────────────────────────────────────────┐
│ Web Audio 上下文生命周期 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 正常状态: │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 创建 │────►│ 运行 │────►│ 关闭 │ │
│ │ AudioCtx │ │ running │ │ closed │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ 切后台影响: │
│ │ │
│ 用户切后台 ────────────────►│ 浏览器自动挂起音频上下文 │
│ 用户切回前台 ▼ │
│ │ │
│ resumeMusic() ────────────►│ 上下文状态为 suspended │
│ │ 需先 resume() 上下文 │
│ │ │
│ 某些浏览器: │
│ │ 上下文被彻底释放 │
│ │ 必须重新创建和加载音频 │
│ │
└─────────────────────────────────────────────────────────────┘

根本原因分析:

平台 问题原因 严重程度
Chrome/Edge AudioContext 被挂起,需要用户交互才能恢复
Safari 对后台音频限制严格,经常丢失音频上下文
Firefox 相对正常,但偶尔出现上下文状态异常
微信内置浏览器 与系统音频焦点冲突,resume 无效
微信小游戏 InnerAudioContext 实例失效

为何 pause/resume 不可靠

1
2
3
4
5
6
7
8
9
10
// Cocos Creator 底层简化逻辑示意
// 当 AudioContext 状态异常时:
audioContext.resume().then(() => {
// Chrome 需要用户手势才能 resume
// 但切回前台不一定是用户手势触发的
});

// 某些情况下音频节点已断开连接
// resumeMusic() 只能操作旧的 AudioBufferSourceNode
// 但该节点可能已经失效

核心思路:别用 pause/resume

与其指望浏览器乖乖恢复 AudioContext,不如在切后台时彻底释放音频资源,切回前台时重新加载播放。虽然会有短暂延迟,但稳定性有保障。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌─────────────────────────────────────────────────────────────┐
│ 推荐方案:后台释放 + 前台重载 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 前台运行: │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 播放音乐 │───►│ 正常游戏 │───►│ 用户切走 │ │
│ └─────────┘ └─────────┘ └────┬────┘ │
│ │ │
│ 后台状态: ▼ │
│ ┌─────────┐ │
│ │ stopMusic│ 彻底停止并释放 │
│ │ 记录状态 │ 记录当前播放信息 │
│ └────┬────┘ │
│ │ │
│ 切回前台: ▼ │
│ ┌─────────┐ │
│ │重新加载 │ 重新 load 音频资源 │
│ │恢复播放 │ playMusic 从头播放 │
│ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘

基础实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 监听游戏生命周期事件
cc.game.on(cc.game.EVENT_HIDE, this.onEnterBackground, this);
cc.game.on(cc.game.EVENT_SHOW, this.onEnterForeground, this);

onEnterBackground() {
// 如果当前正在播放音乐,则直接停掉
if (this._enableMusic) {
cc.audioEngine.stopMusic();
}
},

onEnterForeground() {
// 如果之前是播放状态,则重新播放音乐
if (this._enableMusic) {
cc.loader.load(cc.url.raw("resources/audio/bg.ogg"), (errors, clip) => {
if (errors) {
cc.error('Failed to load background music:', errors);
return;
}
cc.audioEngine.playMusic(clip, true);
});
}
},

完整的背景音乐管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
const { ccclass, property } = cc._decorator;

/**
* 背景音乐管理器
* 处理播放、暂停、切后台恢复等完整生命周期
*/
@ccclass
export default class BackgroundMusicManager extends cc.Component {

@property({
type: cc.AudioClip,
tooltip: '背景音乐资源(Web 平台建议用 ogg,原生用 mp3)'
})
musicClip: cc.AudioClip = null;

@property
defaultVolume: number = 0.5;

@property
fadeDuration: number = 1.0; // 淡入淡出时长(秒)

// 内部状态
private _isPlaying: boolean = false;
private _currentVolume: number = 0.5;
private _fadeTimer: number = 0;
private _isFading: boolean = false;
private _fadeTargetVolume: number = 0;
private _fadeStartVolume: number = 0;
private _musicId: number = -1;

onLoad() {
this._currentVolume = this.defaultVolume;

// 注册游戏生命周期监听
cc.game.on(cc.game.EVENT_HIDE, this._onGameHide, this);
cc.game.on(cc.game.EVENT_SHOW, this._onGameShow, this);
}

onDestroy() {
cc.game.off(cc.game.EVENT_HIDE, this._onGameHide, this);
cc.game.off(cc.game.EVENT_SHOW, this._onGameShow, this);

if (this._isPlaying) {
this.stop();
}
}

// ============ 公共接口 ============

/**
* 播放背景音乐
*/
play(loop: boolean = true, fadeIn: boolean = true) {
if (this._isPlaying) {
this.stop();
}

if (!this.musicClip) {
cc.warn('BackgroundMusicManager: musicClip is not set');
return;
}

// Web 平台重新加载资源(避免音频上下文问题)
if (CC_JSB) {
// 原生平台直接使用资源
this._doPlay(this.musicClip, loop);
} else {
// Web 平台:重新加载确保音频上下文正常
const url = cc.assetManager.utils.getUrlWithUuid(
this.musicClip._uuid,
{ isNative: true, nativeExt: '.ogg' }
);

cc.assetManager.loadAny(url, (err, clip: cc.AudioClip) => {
if (err) {
cc.error('Failed to load music:', err);
return;
}
this._doPlay(clip, loop);
});
}

if (fadeIn) {
this._startFade(0, this._currentVolume, this.fadeDuration);
}
}

/**
* 停止背景音乐
*/
stop(fadeOut: boolean = true) {
if (!this._isPlaying) return;

if (fadeOut && this.fadeDuration > 0) {
this._startFade(this._currentVolume, 0, this.fadeDuration);
// 淡出完成后真正停止
this.scheduleOnce(() => {
cc.audioEngine.stopMusic();
this._isPlaying = false;
}, this.fadeDuration);
} else {
cc.audioEngine.stopMusic();
this._isPlaying = false;
}
}

/**
* 暂停(不推荐在 Web 平台使用)
*/
pause() {
if (!this._isPlaying) return;
cc.audioEngine.pauseMusic();
}

/**
* 恢复(不推荐在 Web 平台使用)
*/
resume() {
if (!this._isPlaying) return;
cc.audioEngine.resumeMusic();
}

/**
* 设置音量
*/
setVolume(volume: number, fade: boolean = false) {
volume = cc.misc.clamp01(volume);
this._currentVolume = volume;

if (this._isPlaying) {
if (fade) {
this._startFade(cc.audioEngine.getMusicVolume(), volume, 0.5);
} else {
cc.audioEngine.setMusicVolume(volume);
}
}
}

/**
* 获取当前播放状态
*/
get isPlaying(): boolean {
return this._isPlaying;
}

// ============ 私有方法 ============

private _doPlay(clip: cc.AudioClip, loop: boolean) {
cc.audioEngine.setMusicVolume(this._currentVolume);
this._musicId = cc.audioEngine.playMusic(clip, loop);
this._isPlaying = true;
}

private _onGameHide() {
cc.log('Game entering background, stopping music');
// 后台时直接停止(不使用淡出,避免恢复问题)
if (this._isPlaying) {
cc.audioEngine.stopMusic();
// 注意:不修改 _isPlaying,标记为"待恢复"
}
}

private _onGameShow() {
cc.log('Game returning to foreground');
// 如果之前是播放状态,重新播放
if (this._isPlaying) {
this.play(true, true);
}
}

// ============ 淡入淡出处理 ============

private _startFade(from: number, to: number, duration: number) {
this._fadeStartVolume = from;
this._fadeTargetVolume = to;
this._fadeTimer = 0;
this._isFading = true;
}

update(dt: number) {
if (!this._isFading) return;

this._fadeTimer += dt;
const progress = Math.min(this._fadeTimer / this.fadeDuration, 1);

// 线性插值
const volume = this._fadeStartVolume +
(this._fadeTargetVolume - this._fadeStartVolume) * progress;

cc.audioEngine.setMusicVolume(volume);

if (progress >= 1) {
this._isFading = false;
}
}
}

平台差异化处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
* 平台音频适配工具
*/
const AudioPlatformAdapter = {

/**
* 获取当前运行平台
*/
getPlatform() {
if (CC_JSB) {
if (cc.sys.os === cc.sys.OS_ANDROID) return 'android';
if (cc.sys.os === cc.sys.OS_IOS) return 'ios';
return 'native_other';
}
if (cc.sys.platform === cc.sys.WECHAT_GAME) return 'wechat';
if (cc.sys.platform === cc.sys.BYTEDANCE_MINI_GAME) return 'bytedance';
if (cc.sys.browserType === cc.sys.BROWSER_TYPE_WECHAT) return 'wechat_web';
return 'web';
},

/**
* 是否需要后台停止 + 前台重载方案
*/
needRecreateOnResume() {
const platform = this.getPlatform();
return ['web', 'wechat_web', 'wechat'].includes(platform);
},

/**
* 获取推荐的音频格式
*/
getRecommendedFormat() {
const platform = this.getPlatform();
switch (platform) {
case 'web':
// Web 平台:ogg 兼容性较好,mp3 也行
return CC_JSB ? 'mp3' : 'ogg';
case 'wechat':
case 'bytedance':
// 小游戏平台通常使用 mp3
return 'mp3';
case 'android':
return 'ogg'; // Android 对 ogg 支持好
case 'ios':
return 'mp3'; // iOS 对 mp3 支持好
default:
return 'mp3';
}
},

/**
* 检查音频上下文状态(Web 平台)
*/
checkAudioContext() {
if (typeof AudioContext !== 'undefined' || typeof webkitAudioContext !== 'undefined') {
// Cocos Creator 内部可能维护了 AudioContext
// 这里提供一个检查方法
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
return ctx.state; // 'running', 'suspended', 'closed'
} catch (e) {
return 'unsupported';
}
}
return 'unsupported';
}
};

音效管理注意事项

背景音乐和音效的管理策略不太一样:

维度 背景音乐 音效
播放数量 单轨 多轨并发
切后台处理 停止并重载 直接停止
内存管理 常驻 用完释放
加载方式 预加载 按需加载/对象池
格式选择 压缩流式 短音频优先

音效管理器示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
const { ccclass } = cc._decorator;

@ccclass
export default class SoundEffectManager {

private static _instance: SoundEffectManager = null;
private _effectVolume: number = 1.0;
private _enabled: boolean = true;
private _playingEffects: Map<number, number> = new Map(); // id -> audioId

static getInstance(): SoundEffectManager {
if (!this._instance) {
this._instance = new SoundEffectManager();
}
return this._instance;
}

/**
* 播放音效
*/
play(clip: cc.AudioClip, loop: boolean = false): number {
if (!this._enabled || !clip) return -1;

const audioId = cc.audioEngine.playEffect(clip, loop);
cc.audioEngine.setVolume(audioId, this._effectVolume);

this._playingEffects.set(audioId, audioId);

// 播放结束后清理记录
if (!loop) {
const duration = clip.duration || 1;
setTimeout(() => {
this._playingEffects.delete(audioId);
}, duration * 1000 + 100);
}

return audioId;
}

/**
* 停止指定音效
*/
stop(audioId: number) {
if (audioId >= 0) {
cc.audioEngine.stopEffect(audioId);
this._playingEffects.delete(audioId);
}
}

/**
* 停止所有音效
*/
stopAll() {
cc.audioEngine.stopAllEffects();
this._playingEffects.clear();
}

/**
* 切后台时停止所有音效
*/
onEnterBackground() {
this.stopAll();
}

setVolume(volume: number) {
this._effectVolume = cc.misc.clamp01(volume);
// 更新正在播放的音效音量
this._playingEffects.forEach((id) => {
cc.audioEngine.setVolume(id, this._effectVolume);
});
}
}

音频格式选择建议

各平台音频格式兼容性

格式 Web (Chrome) Web (Safari) Android iOS 微信小游戏
MP3 支持 支持 支持 支持 支持
OGG 支持 不支持 支持 支持 支持
WAV 支持 支持 支持 支持 支持
M4A 支持 支持 支持 支持 支持

推荐方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────────────────────────┐
│ 音频格式选择策略 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 背景音乐: │
│ • Web 平台:OGG(Chrome/Firefox)+ MP3 fallback │
│ • 原生平台:MP3(iOS)或 OGG(Android) │
│ • 小游戏:MP3 │
│ │
│ 音效: │
│ • 短音效:WAV 或无损压缩 │
│ • 长音效:MP3 或 OGG │
│ │
│ 构建配置: │
│ • Cocos Creator 构建时可自动转换格式 │
│ • 在"项目设置 > 模块配置"中启用所需音频模块 │
│ │
└─────────────────────────────────────────────────────────────┘

完整的使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const { ccclass, property } = cc._decorator;

@ccclass
export default class GameScene extends cc.Component {

@property(BackgroundMusicManager)
bgmManager: BackgroundMusicManager = null;

@property(cc.AudioClip)
clickSound: cc.AudioClip = null;

onLoad() {
// 开始播放背景音乐
if (this.bgmManager) {
this.bgmManager.play(true, true);
}
}

/**
* 按钮点击
*/
onButtonClick() {
SoundEffectManager.getInstance().play(this.clickSound);
}

/**
* 打开设置面板时降低背景音乐音量
*/
onOpenSettings() {
this.bgmManager.setVolume(0.2, true); // 淡出到 20%
}

/**
* 关闭设置面板恢复音量
*/
onCloseSettings() {
this.bgmManager.setVolume(0.5, true); // 淡入到 50%
}

onDestroy() {
// 场景切换时停止音乐
if (this.bgmManager) {
this.bgmManager.stop(true);
}
}
}

小结

Cocos Creator 背景音乐管理的几条经验:

  1. 避免 pause/resume:在 Web 和微信环境里不可靠,切后台后容易失控
  2. 后台停止 + 前台重载:切后台调用 stopMusic() 彻底释放,切回重新 load + playMusic()
  3. 监听生命周期:使用 cc.game.EVENT_HIDE/SHOW 监听游戏前后台切换
  4. 平台差异化:原生平台可以相信 pause/resume,Web 平台需要更保守的策略
  5. 淡入淡出:重新播放时加个淡入,避免突兀
  6. 音量分开管:背景音乐和音效分开控制,方便用户调节

这套方案在我做的几个 H5 游戏项目里都验证过了,稳定性没问题。