一、先聊聊闪退这件事
玩游戏最怕什么?不是怪难打,也不是猪队友,而是打到一半“啪”一下回到桌面。尤其是手机游戏,玩家好不容易打了三关,一闪退,前面全白费,内心直接崩溃。
为什么闪退会这么没预兆?很多时候不是游戏逻辑崩了,而是手机内存被我们“玩爆”了。手机系统为了保证整体流畅,会盯住每个 App 的内存占用。一旦你的游戏占得太高,系统就会悄悄把它杀掉。反应到玩家屏幕上,就是闪退。
在 Cocos Creator 做的项目里,内存失控的头号原因常常是资源管理出了问题。资源是什么?简单说,就是游戏里要用的图片、图集、声音、界面模板、模型和动画等。它们就像一个个“住客”,游戏跑起来以后会不停地住进内存这座楼里。如果这些住客只进不出,楼早晚会被塞满,系统只能把你整个游戏轰出去。
所以,我们要做的事情有两件:第一,学会正确地让“住客”离开;第二,建一个监控系统,随时盯着楼里住了多少人,发现异常立刻提醒。
二、资源管理到底管什么
资源在 Cocos Creator 里不是一次性买断的。一个资源被加载出来之后,它就会常驻在 assetManager 维护的资源表里面。需要显示图片时,你会 load 一个贴图;需要生成怪物时,你会 load 一个 Prefab。这些操作都会让资源表多出条目。
但资源表不是无限大的。每次加载一个资源,系统都要为它分配内存。如果你加载了很多资源,却不做任何释放,那么“已加载资源”的数量只会不断增加,内存占用也会像滚雪球一样越来越大。
这里面有个关键 API 是 assetManager,它是资源总管。它记录了所有已经加载过的资源,每一个资源还有一个叫 refCount 的引用计数。这个计数代表“这个资源还被多少地方在用”。如果你加载了一个资源却不释放,哪怕已经不使用了,这个计数也会一直存在,资源也一直占着内存。
所以资源管理要管的事情就一句话:该释放的释放,该监控的监控。 具体怎么做,下面慢慢说。
三、那些常见的“小偷”操作
我们先看几个特别容易踩的坑。
3.1 节点销毁了,资源还留在内存里
最常见的一种操作是:从一个 Prefab 实例化出节点,用完之后调用 destroy() 就把节点删了,但是 Prefab 资源本身没有释放。这就相当于你请了一个装修队,装修完只让他们人走,却把人家全队的行李都留在家里。
// 技术栈:Cocos Creator 3.x + JavaScript
// 错误示例:只销毁节点,没有释放 prefab 资源
cc.resources.load('prefabs/EnemyPrefab', cc.Prefab, (err, prefab) => {
if (err) {
console.error('加载敌人失败', err);
return;
}
const enemy = cc.instantiate(prefab);
enemy.parent = this.node;
enemy.on('dead', () => {
// 敌人死了,把节点销毁
enemy.destroy();
// 但是 prefab 这个资源一直留在内存里
// 下次再 load 同一个路径时,还会基于同一个资源增加引用
// 多次循环后,内存越来越大
});
});
在这个例子中,每加载一次 EnemyPrefab,它就会一直占用一份内存。实战里如果一场战斗刷几百个敌人,这股内存累积起来非常恐怖。
3.2 重复加载同一个资源,越积越多
有些同学可能会问:“我 load 同一个路径,它应该会复用同一个缓存吧?”没错,资源本身会走缓存,但前提是你没有把它从缓存里清掉。如果你加载后只增加引用,从不减少引用,缓存表里的这个资源条目就永远存在。
打个比方,一个资源就像一把共享雨伞。你每次需要时去借一把,用完却不还回来。虽然雨伞本身还是同一把,但你的“借用记录”越来越多,保洁阿姨看到都会崩溃。
3.3 场景和界面频繁切换,资源没有跟着走
很多游戏主城、战斗、商城之间反复切换。如果每个场景都加载了自己的 UI 资源和美术资源,却不知道在离开时释放,那每换一次场景,资源表就胖一圈。跑几个来回,内存就透支了。
四、治标:学会正确释放资源
释放资源其实不复杂,关键在于养成习惯。下面给两个最常见的正确姿势。
4.1 用完的预置体,一定要释放
上面的错误例子,改成正确的写法是这样:
// 技术栈:Cocos Creator 3.x + JavaScript
// 正确示例:节点销毁后,同步释放 prefab 资源
cc.resources.load('prefabs/EnemyPrefab', cc.Prefab, (err, prefab) => {
if (err) {
console.error('加载敌人失败', err);
return;
}
const enemy = cc.instantiate(prefab);
enemy.parent = this.node;
enemy.on('dead', () => {
enemy.destroy();
// 关键一步:释放掉这个 prefab 资源
cc.assetManager.releaseAsset(prefab);
// 也可以用 cc.resources.release('prefabs/EnemyPrefab', cc.Prefab);
});
});
注意一点:先 destroy() 节点,再 releaseAsset,这样资源才没有实际使用中的人盯着。如果你释放后又被其他地方引用,那么真正的引用计数会帮你兜着,不会把还在用的资源硬干掉。
4.2 用 bundle 批量加载、批量释放
如果某一个玩法模块有一整批资源,比如“新手引导”包、“战斗关卡”包,更推荐使用 bundle 来管理。加载时从 bundle 里读,离开时直接 releaseAll()。
// 技术栈:Cocos Creator 3.x + JavaScript
// 示例:用 bundle 管理一整组资源
assetManager.loadBundle('battle', (err, bundle) => {
if (err) {
console.error('battle bundle 加载失败', err);
return;
}
// 从 bundle 中加载 UI
bundle.load('UI/BattleUI', cc.Prefab, (err, prefab) => {
// 实例化 UI 并展示
const uiNode = cc.instantiate(prefab);
cc.find('Canvas').addChild(uiNode);
});
// 战斗结束后,切换场景前,把整个 bundle 的资源释放掉
bundle.releaseAll();
});
这种做法的好处是:你不需要一个个记着释放资源,只要记得在合适的时间点调用一次 releaseAll(),整个 bundle 的占用都会降下来。特别适合那种“进来、打完、走人”的模块化设计。
五、治本:做一个资源监控方案
学会了释放,只是治标。因为我们不可能每次操作都盯得死死的,总有漏网之鱼。所以更需要一个监控方案,让游戏自己报警。
5.1 监控思路
监控的核心数据就是 assetManager.assets 里到底装了多少资源。可以定期采样这个集合:
- 看看总共多少个资源;
- 看看每种类型(贴图、音频、Prefab)各占多少;
- 看看单个资源的 refCount 是不是一直居高不下;
- 算一算相邻两次采样的增长量,增长太快就说明有资源在不停地“进”却没有“出”。
这样我们就能在内存真正爆掉之前,从资源数量曲线里看出异常。
5.2 写一个 ResourceMonitor
下面给你一个能直接用的轻量监控器。它不依赖服务器,也不影响游戏运行,只是定时看看“家底”。
// 技术栈:Cocos Creator 3.x + JavaScript
// ResourceMonitor:资源监控器
const { assetManager, log, warn, error } = cc;
export class ResourceMonitor {
/**
* @param {Object} options
* @param {number} options.interval - 采样间隔,单位毫秒,建议 20000~60000
* @param {number} options.maxCount - 资源总数量告警阈值
* @param {number} options.maxGrowth - 单次采样最大允许增长量
* @param {boolean} options.logEachSample - 是否每次采样都打印日志,调试用
*/
constructor(options = {}) {
this.interval = options.interval || 30000;
this.maxCount = options.maxCount || 300;
this.maxGrowth = options.maxGrowth || 30;
this.logEachSample = options.logEachSample ?? true;
this._timerId = null;
this._lastCount = 0;
this._history = []; // 存储采样记录
}
// 启动监控
start() {
this.capture(); // 先采样一次
this._timerId = setInterval(() => {
this.capture();
}, this.interval);
}
// 停止监控,记得在游戏销毁时调用
stop() {
if (this._timerId) {
clearInterval(this._timerId);
this._timerId = null;
}
}
// 采样一次
capture() {
const assets = assetManager.assets;
const totalCount = assets.size;
const typeMap = {};
// 遍历所有已加载资源
assets.forEach((asset, uuid) => {
const typeName = asset.constructor.name;
if (!typeMap[typeName]) {
typeMap[typeName] = 0;
}
typeMap[typeName]++;
// 单个资源引用计数过高,提示可能没释放
if (asset.refCount > 10) {
warn(`[ResourceMonitor] 资源 ${asset.name} 的引用计数为 ${asset.refCount},请确认是否不再使用`);
}
});
// 计算距离上次采样多了多少资源
const growth = this._lastCount === 0 ? 0 : totalCount - this._lastCount;
const snapshot = {
time: Date.now(),
totalCount,
growth,
typeMap,
};
// 保存最近 50 条记录
this._history.push(snapshot);
if (this._history.length > 50) {
this._history.shift();
}
this._lastCount = totalCount;
// 按需打日志
if (this.logEachSample) {
log(`[ResourceMonitor] 资源总数:${totalCount},本次新增:${growth}`);
log(`[ResourceMonitor] 类型分布:${JSON.stringify(typeMap)}`);
}
// 判断是否超阈值
if (totalCount > this.maxCount) {
error(`[ResourceMonitor] 资源总数超过告警线:${totalCount} > ${this.maxCount},请立即检查泄漏点`);
}
if (growth > this.maxGrowth) {
error(`[ResourceMonitor] 单次增长过快:${growth} > ${this.maxGrowth},可能有资源没有被释放`);
}
}
// 生成一份可读的摘要报告
generateReport() {
if (this._history.length === 0) {
return '还没有采样数据,请先调用 start()';
}
const latest = this._history[this._history.length - 1];
const before = this._history.length > 1 ? this._history[this._history.length - 2] : null;
const growTrend = before ? latest.totalCount - before.totalCount : 0;
return [
`当前资源总数:${latest.totalCount}`,
`距上一次采样增长:${growTrend}`,
`类型分布:${JSON.stringify(latest.typeMap)}`,
`最近一次采集时间:${new Date(latest.time).toLocaleString()}`,
].join('\n');
}
}
这个监控器不统计“具体占了多少 KB”,因为它面向的是资源数量。实际使用中,资源数量的异常增长,往往比内存字节数更能提前暴露泄漏。因为资源数量是单调递增的“坏味道”,一旦发现它只增不减,就说明已经有东西没释放了。
5.3 挂到游戏入口
在游戏启动脚本里,把监控器挂上去。
// 技术栈:Cocos Creator 3.x + JavaScript
// main.js 或者入口场景的加载脚本
import { ResourceMonitor } from './ResourceMonitor';
const monitor = new ResourceMonitor({
interval: 20000, // 20秒采样一次
maxCount: 400, // 超过400个资源就报警
maxGrowth: 50, // 单次增长超过50就报警
logEachSample: false, // 正式包不要每次打日志,按需开启
});
monitor.start();
// 游戏切后台时可以暂停监控,避免无意义开销
game.on(Game.EVENT_HIDE, () => {
monitor.stop();
});
如果发现有报警,就把 generateReport() 的结果打印出来,或者弹到调试面板。这样在测试阶段就能抓到很多隐藏的泄漏点。
5.4 配合场景钩子使用
除了定时采样,我们还可以在场景切换时做一次“快照对比”。例如上一个场景卸载后资源数应该下降,如果没降,那就是泄漏。
// 技术栈:Cocos Creator 3.x + JavaScript
// 场景切换钩子示例
import { director } from 'cc';
director.on(Director.EVENT_AFTER_SCENE_LAUNCH, () => {
// 每次新场景启动后,立刻采样一次,和旧场景结束时的数据对比
// 如果新场景启动后资源数比旧场景结束时还要高很多,
// 说明旧场景的时候有资源没释放干净
monitor.capture();
});
这里只是演示一个思路,实际项目中可以根据自己的场景流程来安排采样点。
六、这套方案用在哪些场景
这个资源监控方案特别适合这几类情况:
- 游戏关卡很多,需要频繁切换场景。
- UI 界面复杂,动态弹窗和道具面板一会儿开一会儿关。
- 玩法里大量生成临时对象,比如敌人、弹幕、特效。
- 项目已经进入测试阶段,时不时就闪退,但找不到具体是哪个玩法导致内存爆掉的。
在这些场景下,靠人肉检查代码效率太低了。只要跑上监控器,玩一把,看日志,资源数量一直涨,那就顺着增长的资源类型去查,基本一抓一个准。
七、方案有哪些优点和缺点
先说说优点:
- 实现简单,不需要服务器,也不接第三方工具,纯本地就能跑。
- 数据直观,资源数量、类型分布一眼看懂。
- 能提前预警,不用等闪退发生再去翻日志。
- 可以根据项目情况随时调整阈值。
再说说缺点:
- 它只能看到“资源数量”,看不到“每个资源具体占多少字节”。有时候一个超大图集比一百个小图片更吃内存。
- 采样有间隔,如果泄漏发生在两次采样之间,可能扣不到那么准。
- 需要自己维护阈值。不同设备、不同玩法的游戏,资源量基线不一样,阈值设得太紧会有误报,太松又没用。
好在这些缺点不影响大局。我们可以先用它定位“资源计数异常”,然后结合 Creator 自带的调试工具再看具体内存占用。
八、使用时的注意事项
第一,监控代码要低调。别在正式包里面每一帧都打日志,最好做一个开关,上线时关掉,出问题时再打开。
第二,阈值不要拍脑袋定。建议先跑一轮完整的正常玩法,记录下资源数量的基准线,然后在基准线上再加 20%~30% 作为告警线。
第三,释放资源时要留意“还有没有人在用”。Cocos Creator 的引用计数是可靠的,但我们不能自己手动把 refCount 改来改去。调用 releaseAsset 时,如果这个资源还被其他 Prefab、场景或者其他动态加载的地方引用着,它不会真的被回收。所以释放的时机一定要选在“这个资源最后一个使用者离开”之后。
第四,不要试图在游戏运行时频繁调用垃圾回收。系统自带的内存整理机制就够了。我们做的应该是管好资源引用,而不是靠暴力回收解决问题。
第五,对于 bundle.releaseAll(),要确保这个 bundle 里的资源没有被其他模块共享。如果多个模块同时用同一个 bundle,一听到释放就全清了,反而会让别的模块临时拉资源,界面卡一下。这种情况下,更好的做法是用 bundle.release 精确释放某几个资源。
九、最后再总结几句
闪退问题看起来吓人,说白了就是内存管不住。Cocos Creator 的资源管理,核心就两条线:一是代码习惯,用完必释放;二是监控兜底,让泄漏无处可藏。
上面给的监控器虽然很简单,但真的能帮你免掉不少深夜排查闪退的痛苦。你可以直接复制到项目里用,也可以改成在服务器上收集资源曲线,慢慢沉淀出一套适合自己团队的资源健康检查体系。
游戏开发里很多看似玄学的问题,最后都会落到“该释放的没释放”这几个字上。把资源管理这个基本功练好,把监控方案跑起来,你会发现闪退并没有那么难治。
评论
围绕“Cocos Creator资源管理优化:解决内存泄漏引发的游戏闪退的监控方案”参与讨论