关于 内存管理方式

前言

设备对每个程序都有最大的内存分配限制,如果超过了这个阈值,会被系统强制关闭,造成 crash。

因此在开发的过程中,我们要在保证程序运行效率的前提下,尽量压缩程序运行时所占用的内存。

要讨论内存优化,首先要知道项目中最消耗内存的是什么?

就像 Creator 工程中占用空间最多的是资源,资源包括纹理、声音、数据等等。

这里我们先了解下 Creator 的资源在内存中的管理方式,再介绍其他的优化内容。

Creator 2.4.x 版本 加入了: cc.assetManager api,本文开发环境是 Creator 2.4.3


# 一. 存储形式

资源在加载完成后,会以 { uuid : cc.Asset } 的形式被缓存到 cc.assetManager.assets 中,以避免重复加载。

但是这也会造成内存和显存的持续增长,所以有些资源如果不再需要用到,可以通过 自动释放 或者 手动释放 的方式进行释放。

释放资源将会销毁资源的所有内部属性,比如渲染层的相关数据,并移出缓存,从而释放内存和显存(对纹理而言)。

  • cc.assetManager:管理资源的行为和信息,包括加载,释放等
  • cc.assetManager.assets:已加载资源的集合

# 二. 引用计数

引用计数 是计算机编程语言中的一种内存管理技术,是指将资源(可以是对象、内存或磁盘空间等等)的被引用次数保存起来,当被引用次数变为零时就将其释放的过程。

资源在加载完成后,会返回 cc.Asset 实例, 所有 cc.Asset 实例都拥有成员函数 addRefdecRef,分别用于增加和减少引用计数。


初始化引用计数:

this._ref = 0;

资源的引用计数 +1:

addRef () {
    this._ref++;
    return this;
}

资源的引用计数 -1,并尝试进行自动释放:

decRef (autoRelease) {
    this._ref--;
    //接下来会对代码进行详细的解读
    autoRelease !== false && cc.assetManager._releaseManager.tryRelease(this);
    return this;
}

注意

Asset Manager 只会 『 自动统计 』 资源之间的 『 静态引用 』,并不能真实地反应资源在游戏中被动态引用的情况;

『 动态引用 』 还需要 『 开发者进行控制 』 以保证资源能够被正确释放。


# 三. 资源静态引用

当开发者在编辑器中编辑资源时(例如场景、预制体、材质等),需要在这些资源的属性中配置一些其他的资源,例如在材质中设置贴图,在场景的 Sprite 组件上设置 SpriteFrame。『 那么这些引用关系会被记录在资源的序列化数据中,引擎可以通过这些数据分析出依赖资源列表,像这样的引用关系就是 静态引用』

# 1. 统计方式

引擎对资源的 静态引用 的统计方式为:

  • 动态加载 某个资源时,引擎会在底层加载管线中记录该资源所有 直接依赖资源 的信息,并将所有 直接依赖资源 的引用计数加 1,然后将该资源的引用计数初始化为 0;

  • 在释放资源时,取得该资源之前记录的所有 直接依赖资源 信息,并将所有依赖资源的引用计数减 1;

因为在释放检查时,如果资源的引用计数为 0,才可以被自动释放。所以上述步骤可以保证资源的依赖资源无法先于资源本身被释放,因为依赖资源的引用计数肯定不为 0。也就是说,只要一个资源本身不被释放,其依赖资源就不会被释放,从而保证在复用资源时不会错误地进行释放。


# 2. 例子

  1. 假设现在有一个 A 预制体,其依赖的资源包括 a 材质和 b 材质。a 材质引用了 α 贴图,b 材质引用了 β 贴图。那么在加载 A 预制体之后,a、b 材质的引用计数都为 1,α、β 贴图的引用计数也都为 1:


  1. 假设现在又有一个 B 预制体,其依赖的资源包括 b 材质和 c 材质。则在加载 B 预制体之后,b 材质的引用计数为 2,因为它同时被 A 和 B 预制体所引用。而 c 材质的引用计数为 1,α、β 贴图的引用计数也仍为 1:


  1. 此时释放 A 预制体,则 a,b 材质的引用计数会各减 1:
  • 材质的引用计数变为 0,被释放,所以贴图 α 的引用计数减 1 变为了 0,也被释放
  • 材质的引用计数变为 1,被保留,所以贴图 β 的引用计数仍为 1,也被保留
  • 因为 B 预制体没有被释放,所以 c 材质的引用计数仍为 1,被保留

# 3. 通过 creator 来了解 assets

新建一个场景,不放入任何资源:


打印 assets:

console.log(cc.assetManager.assets)

可以看到内存中的资源均为 cocos 的内置资源:


在场景中放入 HelloWorld:


启动游戏后,引擎在底层加载管线中调用 assets 的成员方法 addRef:


再次打印 assets 及资源的引用计数:

console.log(cc.assetManager.assets);
console.log("spriteFrame.refCount : " + this.sprite.spriteFrame.refCount);


会发现 assets 多了两项,uuid 分别是:

6aa0aa6a-ebee-4155-a088-a687a6aadec4 
31bc895a-c003-4566-a9f3-2e54ae1c17dc

在编辑器中显示 HelloWorld 的 Texture2D 和 SpriteFrame 的 uuid,和上述的两个 uuid 完全匹配:


图片的引用计数也增加为 1:


如果存在两份 HelloWorld,但他们的 spriteFrame 是同一份:


那么 cc.assetManager.assets 依然保持原样,但 spriteFramerefCount 会变成 2

对于更复杂的资源引用情况,可以自己测试下 assets 及引用计数。

# 4. Texture 和 SpriteFrame 资源类型

资源管理器 中,图像资源的左边会显示一个和文件夹类似的三角图标,点击就可以展开看到它的子资源(sub asset),每个图像资源导入后编辑器会自动在它下面创建同名的 SpriteFrame 资源。

SpriteFrame 是核心渲染组件 Sprite 所使用的资源,设置或替换 Sprite 组件中的 spriteFrame属性,就可以切换显示的图像。

为什么会有 SpriteFrame 这种资源?

Texture 是保存在 GPU 缓冲中的一张纹理,是原始的图像资源。

SpriteFrame 包含两部分内容:记录了 Texture 及其相关属性的 Texture2D 对象和纹理的矩形区域,对于相同的 Texture 可以进行不同的纹理矩形区域设置,然后根据 Sprite 的填充类型,如 SIMPLE、SLICED、TILED 等进行不同的顶点数据填充,从而满足 Texture 填充图像精灵的多样化需求。而 SpriteFrame 记录的纹理矩形区域数据又可以在资源的属性检查器中根据需求自由定义,这样的设置让资源的开发更为高效和便利。除了每个文件会产生一个 SpriteFrame 的图像资源(Texture)之外,我们还有包含多个 SpriteFrame 的图集资源(Atlas)类型。


# 四. 资源动态引用

当开发者在编辑器中没有对资源做任何设置,而是通过代码动态加载资源并设置到场景的组件上,则资源的引用关系不会记录在序列化数据中,引擎无法统计到这部分的引用关系,这些引用关系就是 动态引用

# 1. 动态加载

使用 动态加载 资源来进行动态引用:

# a. 动态加载 resources 目录中的资源

cc.resources.load("HelloWorld", cc.SpriteFrame, (err, assets: cc.SpriteFrame) => {
    this.sprite.spriteFrame = assets;
    console.log(cc.assetManager.assets);
    console.log("spriteFrame.refCount : " + this.sprite.spriteFrame.refCount);
});

# b. 动态加载 bundle 目录中的资源

cc.assetManager.loadBundle("bundle", (err: Error, bundle: cc.AssetManager.Bundle) => {
    bundle.load("HelloWorld", cc.SpriteFrame, (err, assets: cc.SpriteFrame) => {
        this.sprite.spriteFrame = assets;
        console.log(cc.assetManager.assets);
        console.log("spriteFrame.refCount : " + this.sprite.spriteFrame.refCount);
    });
});

在资源加载完成后打印下 assets 及资源的引用计数:

可以看到,资源加载完成后会将 SpriteFrame 资源设置到 Sprite 组件上,但引擎不会做特殊处理,SpriteFrame 的引用计数仍保持 0,此时需要我们手动来管理引用计数。

# 2. 增加引用计数

通过 addRef() 方法添加引用计数:

cc.resources.load("HelloWorld", cc.SpriteFrame, (err, assets: cc.SpriteFrame) => {
    this.sprite.spriteFrame = assets;
    this.sprite.spriteFrame.addRef();
    console.log(cc.assetManager.assets);
    console.log("spriteFrame.refCount : " + this.sprite.spriteFrame.refCount);
});

# 3. 减少引用计数

通过 decRef() 方法减少引用计数:

(为了避免过多的资源干扰视线,我们在触摸结束时减少引用计数)

onTouchEnd(event: cc.Event.EventTouch) {
    console.log("###");

    this.sprite.node.destroy();

    this.sprite.spriteFrame.decRef();
    console.log("spriteFrame.refCount : " + this.sprite.spriteFrame.refCount);
    this.sprite.spriteFrame = null;
    //在下一帧打印 assets
    this.scheduleOnce(()=>{
        console.log(cc.assetManager.assets);
    });
}

运行后的 log:

从 log 中可以看到,addRef 后,资源的引用计数变为 1,decRef 之后资源的引用计数在当前帧为 0,在下一帧,资源也从 assets 中被清除了。

# 4. 注意

『 动态加载 的资源必须 手动卸载 』,卸载方式:

  • 通过引用计数:addRefdecRef
  • 直接释放:releaseAsset

在资源加载完成后,会被临时缓存到 cc.assetManager.assets 中,以便下次复用。但是这也会造成内存和显存的持续增长,所以有些资源如果不需要用到,可以通过 自动释放 或者 手动释放 的方式进行释放。释放资源将会销毁资源的所有内部属性,比如渲染层的相关数据,并移出缓存,从而释放内存和显存(对纹理而言)。


# 五. 资源自动释放

# 1. 场景自动释放

在 资源管理器 选中场景后,属性检查器 中会出现 自动释放资源 选项 :

勾选后,点击右上方的 应用 按钮,之后在切换该场景时便会自动释放该场景所有 静态引用 的依赖资源。建议场景尽量都勾选自动释放选项,以确保内存占用较低,除了部分高频使用的场景(例如主场景)。

# 2. 资源自动释放

所有 cc.Asset 实例都拥有成员函数 addRefdecRef,分别用于增加和减少引用计数。一旦引用计数为零,Creator 会对资源进行自动释放(需要先通过释放检查,具体可参考下部分内容的介绍)。

start () {
    cc.resources.load('images/background', cc.Texture2D, (err, texture) => {
        this.texture = texture;
        // 当需要使用资源时,增加其引用
        texture.addRef();
    });
}

onDestroy () {
    // 当不需要使用资源时,减少引用
    // Creator 会在调用 decRef 后尝试对其进行自动释放
    this.texture.decRef();
}

自动释放的优势在于 不用显式地调用释放接口,开发者只需要维护好资源的引用计数,Creator 会根据引用计数自动进行释放 。这大大降低了错误释放资源的可能性,并且开发者不需要了解资源之间复杂的引用关系。对于没有特殊需求的项目,建议尽量使用自动释放的方式来释放资源。


# 六. 资源手动释放

当项目中使用了更复杂的资源释放机制时,可以调用 Asset Manager 的相关接口来手动释放资源。

cc.assetManager.releaseAsset(texture);

# 1. 说明:

  1. cc.assetManager.releaseAsset 接口仅能释放单个资源,且为了统一,接口只能通过资源本身来释放资源,不能通过资源 uuid、资源 url 等属性进行释放
  2. 在释放资源时,开发者只需要关注资源本身,引擎会 自动释放 其依赖资源(getDeps

# 2. 注意:

release 系列接口(例如 releasereleaseAssetreleaseAll)会直接释放资源,而不会进行释放检查,只有其依赖资源会进行释放检查。所以当显式调用 release 系列接口时,可以确保资源本身一定会被释放。


# 七. 释放检查

为了避免错误释放正在使用的资源造成渲染或其他问题,Creator 会在自动释放资源之前进行一系列的检查,只有检查通过了,才会进行自动释放。

  1. 如果资源的引用计数为 0,即没有其他地方引用到该资源,则无需做后续检查,直接摧毁该资源,移除缓存

  2. 资源一旦被移除,会同步触发其依赖资源的释放检查,将移除缓存后的资源的 直接 依赖资源(不包含后代)的引用都减 1,并同步触发释放检查

  3. 如果资源的引用计数不为 0,即存在其他地方引用到该资源,此时需要进行循环引用检查,避免出现自己的后代引用自己的情况。如果循环引用检查完成之后引用计数仍不为 0,则终止释放,否则直接摧毁该资源,移除缓存,并触发其依赖资源的释放检查(同步骤 2)


# 八. 释放过程

releaseAsset 究竟做了什么? 查阅 assets 相关的源码:

大致流程:


下面是对源码的一些注释,配合流程图服用,效果更佳

手动释放:

releaseAsset (asset) {
    //强制释放
    releaseManager.tryRelease(asset, true);
}

减少资源的引用并尝试进行自动释放:

decRef (autoRelease) {
    this._ref--;
    autoRelease !== false && cc.assetManager._releaseManager.tryRelease(this);
    return this;
}

尝试进行释放:

tryRelease (asset, force) {
    if (!(asset instanceof cc.Asset)) return;
    if (force) {
        //强制释放
        releaseManager._free(asset, force);
    }
    else {
        //非强制释放则添加到待删除队列
        _toDelete.add(asset._uuid, asset);
        if (!eventListener) {
            //已监听渲染过程之后所触发的事件
            eventListener = true;
            //渲染过程之后执行释放
            cc.director.once(cc.Director.EVENT_AFTER_DRAW, freeAssets);
        }
    }
}

尝试自动去释放依赖资源并释放该资源:

_free (asset, force) {
    //从待删除队列中移除
    _toDelete.remove(asset._uuid);

    if (!cc.isValid(asset, true)) return;

    if (!force) {
        //非强制释放则判断引用计数
        if (asset.refCount > 0) {
            //检查该资源的循环引用,返回其引用计数
            if (checkCircularReference(asset) > 0) return; 
        }
    }

    // remove from cache
    assets.remove(asset._uuid);
    //获取资源直接引用的非原生依赖列表,例如,材质的非原生依赖是 Texture
    var depends = dependUtil.getDeps(asset._uuid);
    for (let i = 0, l = depends.length; i < l; i++) {
        var dependAsset = assets.get(depends[i]);
        if (dependAsset) {
            //减少资源的引用计数
            dependAsset.decRef(false);
            releaseManager._free(dependAsset, false);
        }
    }
    asset.destroy();
    dependUtil.remove(asset._uuid);
}

释放待删除队列中的资源:

function freeAssets () {
    eventListener = false;
    _toDelete.forEach(function (asset) {
        releaseManager._free(asset);
    });
    _toDelete.clear();
}

# 九. 注意点

『 JavaScript 的垃圾回收是延迟的 』

在 C 与 C++ 等语言中,开发人员可以直接控制内存的申请和回收,而 JavaScript 所有对象的内存都由垃圾回收机制来管理,会周期性对那些我们不再使用的变量、对象所占用的内存进行释放,这就导致 JS 层逻辑永远不知道一个对象会在什么时候被释放。

想象一种情况,当你释放了 AssetManager 对某个资源的引用之后,由于考虑不周的原因,游戏逻辑再次请求了这个资源,这时垃圾回收还没有开始(垃圾回收的时机不可控)。

当出现这个情况时,意味着这个资源还存在内存中,但是 AssetManager 已经访问不到了,所以会重新加载它,就会造成 这个资源在内存中有两份同样的拷贝,一份为刚刚请求的,另一份为已经释放但未被回收的,形成资源在内存中 暂时性冗余

之所以说暂时性,是因为在下个 GC 周期时,该资源依然会被回收,释放对应的内存。

如果只是一个资源还好,但是如果类似的资源很多,甚至不止一次被重复加载,就会造成当前时间内存飙升,而且频繁GC也会影响游戏的流畅性。

因此我们释放资源时,应该 『 避免频繁释放 』,同时 『 避免释放近期内将要复用的资源 』