一、先聊聊闪退这件事

玩游戏最怕什么?不是怪难打,也不是猪队友,而是打到一半“啪”一下回到桌面。尤其是手机游戏,玩家好不容易打了三关,一闪退,前面全白费,内心直接崩溃。

为什么闪退会这么没预兆?很多时候不是游戏逻辑崩了,而是手机内存被我们“玩爆”了。手机系统为了保证整体流畅,会盯住每个 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 的资源管理,核心就两条线:一是代码习惯,用完必释放;二是监控兜底,让泄漏无处可藏。

上面给的监控器虽然很简单,但真的能帮你免掉不少深夜排查闪退的痛苦。你可以直接复制到项目里用,也可以改成在服务器上收集资源曲线,慢慢沉淀出一套适合自己团队的资源健康检查体系。

游戏开发里很多看似玄学的问题,最后都会落到“该释放的没释放”这几个字上。把资源管理这个基本功练好,把监控方案跑起来,你会发现闪退并没有那么难治。