CocosCreator3.x引擎的动态资源加载和释放方案

本文讲解CocosCreator3.x引擎的资源管理机制,提供一种动态资源的加载和释放方案,在3.3.2版本测试通过。

1 简介

Creator3.x引擎提供了一套基于引用计数的资源释放机制,简单说就是使用时计数加1,不用时计数减1,当计数为0时自动释放。通过引擎加载的资源,都会被临时缓存在 assetManager.assets,以便下次复用。不过这也会造成内存的占用和持续增长,所以有些资源如果不再使用,就可以进行释放。

对于静态引用资源(通过编辑器绑定的资源),引擎会自动统计引用计数并在计数为0时自动释放。

对于动态引用资源(通过代码加载的资源,例如:resources目录和远程资源),受限于JavaScript的动态语言特性,引擎很难跟踪资源的使用情况,所以需要开发者自己进行管理并正确释放。

2 资源释放机制

以使用最广泛的资源图片为例,引擎每加载一张新的图片,都会在内存中为3种对象各创建一个实例,分别是 SpriteFrame、Texture2D、ImageAsset。在动态合图开启的情况下,这3个实例自动记录到缓存 assetManager.assets._map 中;如果动态合图关闭,则只添加 SpriteFrame 和 Texture2D,但 ImageAsset 实际上仍然存在内存中,只是不在缓存列表中而已。其中,SpriteFrame 依赖于 Texture2D,Texture2D 依赖于 ImageAsset,它们之间关系如下图所示:

我们要做的资源释放,主要就是释放 SpriteFrame、Texture2D、ImageAsset 这三种对象。其中 Texture2D 保存着真正的纹理信息,释放 Texture2D 会把纹理从GPU中删除,也就是真正释放内存。

本文主要讲解动态引用资源,即resources远程资源的释放。在此之前,先看下引擎是怎么管理和释放静态资源的。

2.1 静态资源的计数和释放

假设a、b、c三个节点都绑定了 x 图片资源,则 x 资源的引用计数情况如下:

节点 SpriteFrame Texture2D ImageAsset
a加载后 1 1 1
b加载后 2 1 1
c加载后 3 1 1

可以看到,虽然有多个节点,但 SpriteFrame、Texture2D 和 ImageAsset 实例始终只有一份,只是递增了SpriteFrame的引用计数,而Texture2DImageAsset的引用计数则始终是1,因为它们只被一个SpriteFrame所引用。

如果设置了场景的“自动释放”选项,则场景切换时,如果a、b、c三个节点都被释放,则 SpriteFrame 中的计数依次减1,当减到0时,就会走真正的资源释放了,分以下几步:
– 1)首先把自己从缓存列表中删除;
– 2)遍历其依赖资源,并把引用计数减1。如果计数为0则依赖资源也释放;
– 3)把自己从依赖列表中删除;
– 4)调用自身的destroy;

这部分逻辑可参见引擎源码 release-manager.ts::_free 方法。

总之,对于静态资源,加载和释放都不需开发者关心,引擎会自动处理好。

2.2 resources资源的释放

加载resources资源的方法是resources.load,引擎很贴心地返回了SpriteFrame对象,正是上层UI组件期待的类型。和静态资源类似,引擎会自动创建其依赖的Texture2DImageAsset对象,把引用计数设为1,并同时在缓存列表中记录它们的依赖关系。唯一要做的是,返回的SpriteFrame引用计数为0,所以我们在返回后首先把计数加1,之后就可以正常使用了。示例代码如下:

resources.load(path + '/spriteFrame', SpriteFrame, (err: any, spFrame: SpriteFrame) => {
    if (!err && spFrame) {
        spFrame.addRef(); // 计数加1
        ....
    }
});                

假设 x 是一个 resources 目录下的图片资源,a、b、c三个节点依次加载它,则 x 资源的引用计数情况如下:

节点 SpriteFrame Texture2D ImageAsset
a加载后 1 1 1
b加载后 2 1 1
c加载后 3 1 1

这样,如果多次加载相同资源,引擎不会重新创建,而是直接返回缓存列表中已有对象,达到了复用的效果。

对于释放,我们也不用关心太多,只把引用计数减1就可以了,示例代码如下:

releaseSprite(node: Node) {
    if (!isValid(node)) {
        return;
    }
    const sp = node.getComponent(Sprite) as Sprite;
    if (sp && sp.spriteFrame) {
        sp.spriteFrame.decRef();
        sp.spriteFrame = null;
    }
}

SpriteFrame.decRef中,首先把计数减1,当计数大于0时,什么都不会发生;一旦为0,引擎就会调用_free方法,进行资源销毁,检查依赖关系,同时释放 Texture2D 和 ImageAsset 对象。

2.3 远程资源的释放

加载远程资源使用引擎提供的assetManager.loadRemote,它的调用方法如下:

assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
    if (!err && imageAsset) {
        ??
    }
});

这个接口对使用者来说不是很友好,因为返回的是ImageAsset对象,并且引用计数为0,而上层UI组件其实需要的是SpriteFrame对象,所以我们得自己根据ImageAsset手动创建出SpriteFrame,并管理引用计数。唯一好处是,引擎会把这个ImageAsset对象自动添加到缓存列表,这样如果多次加载相同资源,不会重新创建,而是直接返回缓存中的ImageAsset对象。

所以我们面对的第一个问题是,如何根据ImageAsset创建出SpriteFrame?看到上面代码中的??了吗?这代表写法可以有多种,引擎源码中sprite-frame.ts提供了一个方法,如下:

/**
 * @zh 通过 Image 资源或者平台相关 Image 对象创建一个 SpriteFrame 对象
 */
public static createWithImage (imageSourceOrImageAsset: ImageSource | ImageAsset) {
    const img = imageSourceOrImageAsset instanceof ImageAsset ? imageSourceOrImageAsset : new ImageAsset(imageSourceOrImageAsset);
    const tex = new Texture2D();
    tex.image = img;
    const spf = new SpriteFrame();
    spf.texture = tex;
    return spf;
}

上面代码根据ImageAsset各创建一份Texture2DSpriteFrame实例,看起来不错,但直接使用有点费内存。因为前面说过,真正占用内存的是Texture2D对象,如果不管ImageAsset是否相同,总是重新创建Texture2D,必然导致相同资源在内存中存在多份。

解决办法是自己维护一个ImageAssetSpriteFrame的映射表,在创建SpriteFrame之前判断一下,如果是已有ImageAsset,直接返回对应的SpriteFrame就好了,记得把引用计数加1。

现在创建逻辑好了,那怎么释放呢?先试试decRef,结果SpriteFrameTexture2D 只有一个被释放了,跟踪看看,发现自己 new 出来对象的_uuid属性都是空值,导致走到release-manager.ts::tryRelease方法时被互相覆盖了,因为加入删除列表的key值就是对象的_uuid_uuid为空带来的另一个问题是该资源没有依赖关系,因为 assetManager 中的依赖关系列表是靠_uuid来记录的,因此释放不是简单调用decRef就可以。 本想模拟静态资源的流程,手动给对象增加_uuid的,结果发现流程太复杂,还跟文件序列化有关,先忍痛放弃吧。

既然SpriteFrameTexture2D只能有一个调用decRef,那就让Texture2D的引用计数保持0好了,需要时直接调 destroy,反正decRef的最终目的也是调destroy,关键是自己要清楚什么时候能调。ImageAsset的计数则保持为1,事实上它确实是只被一个 Texture2D 所引用。而对于SpriteFrame,我们仍然维护引用计数,每调一次loadRemote加1,释放时减1。当计数为0时,把 SpriteFrame、Texture2D 和 ImageAsset 都一起释放。

最终整理代码如下:

  • 1)自定义一个cache对象,用来存放 ImageAsset 和 SpriteFrame 的映射关系;
cache: { [name: string]: SpriteFrame } = {};
  • 2)loadRemote 回调中,首先检查该 ImageAsset 是否已有对应的 SpriteFrame,有则直接用,没有则创建一个新的 SpriteFrame 和 Texture2D,然后计数加1;
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
    if (!err && imageAsset) {
        let spFrame = this.cache[imageAsset._uuid];
        if (!spFrame) {
            const texture = new Texture2D();
            texture.image = imageAsset;
            spFrame = new SpriteFrame();
            spFrame.texture = texture;
            imageAsset.addRef();
            this.cache[imageAsset._uuid] = spFrame; // 添加映射表记录
        }
        spFrame.addRef(); // 计数加1
    }
});

这样,对于N个相同资源,只会创建一份 ImageAsset、Texture2D 和 SpriteFrame,其中 SpriteFrame 的引用计数为N

  • 3)释放时,手动调用 SpriteFrame.decRef,然后判断引用计数,如果为0,则同时释放 ImageAsset 和 Texture2D,并从映射表里删除该 SpriteFrame 的记录。
releaseRemoteSprite(node: Node) {
    if (!isValid(node)) {
        return;
    }
    const sp = node.getComponent(Sprite) as Sprite;
    if (sp && sp.spriteFrame) {
        const spFrame = sp.spriteFrame;
        sp.spriteFrame.decRef(false); // 只把计数减1
        sp.spriteFrame = null;
        if (spFrame.refCount <= 0) {
            let texture = spFrame.texture as Texture2D;
            // 如果已加入动态合图,必须取原始的Texture2D
            if (spFrame.packable) {
                texture = spFrame.original?._texture as Texture2D;
            }
            if (texture) {
                delete this.cache[texture.image!._uuid]; // 删除映射表记录
                texture.image?.decRef();
                texture.destroy();
            }
            spFrame.destroy();
        }
    }
}

假设 x 是一个远程图片资源,a、b、c三个节点依次加载它,则 x 资源的引用计数情况如下:

节点 SpriteFrame Texture2D ImageAsset
a加载后 1 0 1
b加载后 2 0 1
c加载后 3 0 1

因此,释放时根据引用计数情况,依次释放即可。

上面代码还有一个重要细节,就是需要考虑动态合图的情况,如果该 SpriteFrame 已加入动态合图,需要取原始的 Texture2D,否则误把合图对象释放了,会导致其它组件报错。

3 总结

对于静态引用资源,开发者什么都不用操心,引擎自动做了所有事情;

对于动态加载的 resources 资源,开发者只要加载时调 SpriteFrame.addRef(),释放时调 SpriteFrame.decRef() 就好了,引擎做了剩下所有事情;

对于动态加载的远程资源,需要自己决定什么时候对哪种对象计数加1和减1,什么时候可以释放哪种对象,释放用destroy还是decRef,所有事情都由开发者自己控制,最灵活但是也相对复杂一些。

当然,如果项目资源量小,内存足够,那什么都不用管,不做任何释放,也是完全没问题的。

4 相关知识点

在跟踪远程资源释放的过程中,做了一些记录,供有需要的参考。

4.1 关于SpriteFrame、ImageAsset、Texture2D

  • 动态合图开启情况下且该 SpriteFrame 满足合图条件,则它的_texture指向一个 DynamicAtlasTexture 对象,_original指向一个 Texture2D 对象,代表原始纹理资源。
  • 真正占用内存的对象是 Texture2D,释放 Texture2D 会把纹理资源从GPU中删除。它的 _mipmaps 数组元素为 ImageAsset 类型。
  • 压缩纹理不会参与动态合图。
  • 直接调 destroy 不会检查依赖关系,只会释放自身。

4.2 缓存列表的增加和删除流程

  • 缓存列表增加1项。asset-manager.ts:loadRemote -> factory.create -> utilities.ts:cache -> assets.add
  • 从缓存列表删除1项。asset.ts:decRef -> release-manager.ts:_free -> assets.remove

4.3 资源管理相关属性

  • assetManager.dependUtil._depends 用uuid做key,存放该资源的所有依赖资源。
  • assetManager.assets 存放所有已加载资源,如果资源被释放则移除。
  • assetManager.bundles 存放的是构建时就知道的各个bundle资源列表,跟是否加载无关,不管有没有使用,都在那里。res目录资源不在bundle,直接挂在assetManager.assets下。
  • resources._config.paths 存放resources下所有资源名字和uuid的对应关系。
  • asset-manager.ts:loadRemote 会首先在cache中查找该url,如果找到说明是已下载资源,则计数加1,然后直接返回对应Asset对象;如果未找到才会往下执行 load 流程。

4.4 资源释放相关接口

  • assetManager.releaseAsset 释放资源及其依赖资源。(不看自身引用计数,直接释放。但删除依赖资源会先检查计数)
  • assetManager.releaseUnusedAssets 释放所有没用到的资源(对缓存中所有资源调用 tryRelease 方法)
  • assetManager.releaseAll 释放所有资源
  • release(path)、releaseAsset 和 decRef 的区别在于调 tryRelease 的参数不同,前两者为true,后者为 false。
  • tryRelease 的 force 参数为true和false的区别:true是立即调用 _free,false是放到队列,在下一帧调用_free。
  • _free 的逻辑:如果参数为false且引用计数大于0,则不做操作;否则首先把自己从缓存列表中删除,然后检查其依赖资源并把引用计数都减1,如果为0则释放依赖资源;最后释放自己,并把自己从依赖资源列表中删除。
  • Bundle.release(path) 释放bundle内资源
  • Bundle.releaseUnusedAssets 释放Bundle内所有没用到的资源
  • Bundle.releaseAll 释放Bundle内所有资源

欢迎关注微信公众号“楚游香”,获取更多文章和交流。

标签:

发表回复

您的电子邮箱地址不会被公开。