CocosCreator资源管理心得

CocosCreator资源管理心得

最近在做项目的资源管理优化,拜读了论坛里面很多优秀的文章:

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

  2. 官方手册

我写这篇文章主要是探讨工程里面如何做到资源管理自动化资源管理简单化 并不是我有什么新的发现。我资源管理的基本原理是基于CocosCreator推荐的方案:

  • 自动释放。场景需要勾选自动释放
  • 资源引用计数

一、资源管理概述

在CocosCreator体系中,资源分为2个大类

  • 静态资源,通俗的讲就是prefab中直接引用的资源。官方的解释如下
当开发者在编辑器中编辑资源时(例如场景、预制件、材质等),
需要在这些资源的属性中配置一些其他的资源,
例如在材质中设置贴图,在场景的 Sprite 组件上设置 SpriteFrame。
那么这些引用关系会被记录在资源的序列化数据中,引擎可以通过这些数据分析出依赖资源列表,
像这样的引用关系就是静态引用。
  • 动态资源,就是开发者在运行时动态从AssetBundle中或者远程加载的资源。
    这部分资源需要开发者自己管理

二、统一资源管理

在思考项目中应该如何统一资源管理时候,我在想有没有更简单的方式,可以做到:

  • 无需项目中每个开发者加载资源时候都要关注到引用计数
  • 资源可以被追踪,可以定位到时哪里加载的未释放。

我想到的实现一个统一的资源加载器和追踪器,我们直接看代码



/**
 * 资源加载器
 */
export class ResLoader {
    public name: string
    protected loadResList: Array<Asset>
    protected loadResMap: Map<string, Asset> = new Map<string, Asset>()
    protected remoteResMap?: Map<string, Asset> = null


    constructor(name: string) {
        this.name = name
        this.loadResList = new Array<Asset>()
    }

    /**
     * 设置图片
     */
    public setSpriteFrame(resPath: string, sprite: Sprite, bundleName: string, changeActive: boolean = true, callback?: Function, rootNode?: Node) {
        if (rootNode && !isValid(rootNode)) {
            return
        }
        if (!sprite) {
            return
        }

        // 缓存中读取
        let key = `${bundleName}::${resPath}`
        let asset = this.loadResMap.get(key) as SpriteFrame
        if (asset) {
            sprite.spriteFrame = asset
            if (callback) {
                callback()
            }
            return
        }

        if (changeActive) {
            sprite.node.active = false
        }
        core.loader.load({
            path: resPath,
            bundle: bundleName,
            type: SpriteFrame,
            onComplete: (data) => {
                if (!data) {
                    return
                }

                data.addRef()
                AssetTrace.logAddAssetRef(data, this.name)

                if (!isValid(sprite) || (rootNode && !isValid(rootNode))) {
                    data.decRef()
                    AssetTrace.logDecAssetRef(data, this.name)
                    return
                }

                if (changeActive) {
                    sprite.node.active = true
                }
                sprite.spriteFrame = data
                this.loadResMap.set(key, data)
                this.loadResList.push(data)
                if (callback) {
                    callback()
                }
            }
        })
    }

    /**
     * 加载其他类型资源
     */
    public loadAsset<T extends typeof Asset>(resPath: string, bundleName: string, type: T, callback: Function, rootNode?: Node) {
        if (rootNode && !isValid(rootNode)) {
            return
        }

        // 缓存中读取
        let key = `${bundleName}::${resPath}`
        let asset = this.loadResMap.get(key)
        if (asset) {
            callback(asset)
            return
        }

        core.loader.load({
            path: resPath,
            bundle: bundleName,
            type: type,
            onComplete: (asset) => {
                if (!asset) {
                    return
                }

                asset.addRef()
                AssetTrace.logAddAssetRef(asset, this.name)

                if ((rootNode && !isValid(rootNode))) {
                    asset.decRef()
                    AssetTrace.logDecAssetRef(asset, this.name)
                    return
                }

                this.loadResList.push(asset)
                this.loadResMap.set(key, asset)
                callback(asset)
            }
        })
    }

    /**
     * 释放资源
     */
    public destroy() {
        // 延迟0.5s执行
        core.scheduler.delayTask(() => {
            for (let asset of this.loadResList) {
                asset.decRef()
                AssetTrace.logDecAssetRef(asset, this.name)
            }
            for (let asset of this.loadResList) {
                if (asset.refCount > 0) {
                    AssetTrace.addMonitorAsset(asset)
                }
            }
            this.loadResList = []
            this.loadResMap.clear()

            if (this.remoteResMap != null) {
                for (let key of this.remoteResMap.keys()) {
                    let asset = this.remoteResMap.get(key)
                    asset.decRef(false)
                    if (asset.refCount <= 0) {
                        let spriteFrame = asset as SpriteFrame
                        if (spriteFrame) {
                            let texture = spriteFrame.texture as Texture2D;
                            // 如果已加入动态合图,必须取原始的Texture2D
                            if (spriteFrame.packable) {
                                texture = spriteFrame.original?._texture as Texture2D;
                            }
                            if (texture) {
                                texture.image?.decRef();
                                texture.destroy();
                            }
                            spriteFrame.destroy();
                        } else {
                            asset.destroy()
                        }
                    } else {
                        AssetTrace.addMonitorAsset(asset)
                    }
                }
                this.remoteResMap.clear()
            }
        }, 0.5)
    }
}


以上是核心代码,可以看到我们加载一个资源的过程正是遵从论坛大神推荐的最佳实践。我们的核心思想是:

  • 每个界面或者可以统一被管理的资源,定义一个这个加载器
  • 所有界面中的资源加载都通过这个加载器加载
  • 加载器会记录下每次加载的资源,加载时候资源计数器+1
  • 界面销毁时候调用一下资源加载的destroy
  • 资源加载器destroy时候会负责将自己管理的资源计数器-1.并且是延迟卸载,避免一些bug产生
  • 资源加载器和监视器同时工作,对于计数器-1 之后,计数器>0的资源添加到监视器进行监视
  • 资源加载计数器+1和资源卸载计数器-1都进行日志记录,方便追踪资源的管理过程

我们再来看看资源监视器

interface AssetMonitorInfo {
    tickTimes: number
    nextTickTime: number
}

export class AssetTrace {
    public static traceSwitch: boolean = false
    private static assetSet: Set<Asset> = new Set<Asset>()
    private static monitorAsset: Map<Asset, AssetMonitorInfo> = new Map<Asset, AssetMonitorInfo>()
    private static initFlag: boolean = false

    public static init() {
        if (!AssetTrace.traceSwitch) {
            return
        }
        if (AssetTrace.initFlag) {
            return
        }
        AssetTrace.initFlag = true
        core.scheduler.intervalTask(AssetTrace.checkAsset, 1)
    }

    public static enableTrace(enable: boolean) {
        AssetTrace.traceSwitch = enable
        this.init()
    }

    public static logAddAssetRef(asset: Asset, loaderName: string) {
        if (!AssetTrace.traceSwitch) {
            return
        }
        core.log.info("asset#addRef#{}#{}#{}#{}", loaderName, asset.uuid, asset.name, asset.refCount)
    }

    public static logDecAssetRef(asset: Asset, loaderName: string) {
        if (!AssetTrace.traceSwitch) {
            return
        }
        core.log.info("asset#decRef#{}#{}#{}#{}", loaderName, asset.uuid, asset.name, asset.refCount)
    }

    public static addMonitorAsset(asset: Asset) {
        if (!AssetTrace.traceSwitch) {
            return
        }
        if (AssetTrace.monitorAsset.has(asset)) {
            return
        }
        AssetTrace.monitorAsset.set(asset, {nextTickTime: 0, tickTimes: 0})
    }

    private static checkAsset() {
        if (!AssetTrace.traceSwitch) {
            return
        }
        if (AssetTrace.monitorAsset.size > 0) {
            let keys = AssetTrace.monitorAsset.keys()
            for (let key of keys) {
                let item = AssetTrace.monitorAsset.get(key)
                let asset = key
                let time = item
                if (time.nextTickTime > 0) {
                    time.nextTickTime -= 1
                    AssetTrace.monitorAsset.set(asset, time)
                    continue
                }
                core.log.info("asset#monitor#{}#{}#{}#{}", "system", asset.uuid, asset.name, asset.refCount)
                if (asset.refCount <= 0 || time.tickTimes >= 10) {
                    AssetTrace.monitorAsset.delete(asset)
                    continue
                } else {
                    time.tickTimes += 1
                    time.nextTickTime = time.tickTimes * 2
                    AssetTrace.monitorAsset.set(asset, time)
                }
            }
        }
    }
}

以上是监视器的核心代码

  • 监视器有开关控制,可以启动也可以不启动
  • 对于需要监视的资源,固定进行1s一次的监视,最多监视10次

这里大家可能有疑问,为什么资源需要进行监视了。这是因为在实践中,对于同一份资源,有可能是被静态引用,也有可能被动态加载。对于动态加载我们是进行计数器管理,可以通过追踪我们自己的计数器监视日志可以追踪,但是如果这个资源又被静态引用,我们是无法监视他的加载的。这个时候我们会发现日志中,该资源引用计数器不是我们预期的变化。会出现类似如下的日志

2025-01-12 10:38:39,359#INFO#asset#addRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#8
2025-01-12 10:38:39,360#INFO#asset#addRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#8
2025-01-12 10:38:39,360#INFO#asset#addRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#8
2025-01-12 10:38:39,360#INFO#asset#addRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#8
2025-01-12 10:38:39,360#INFO#asset#addRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#8
2025-01-12 10:38:39,361#INFO#asset#addRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#8
2025-01-12 10:38:39,361#INFO#asset#addRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#8
2025-01-12 10:38:40,406#INFO#asset#decRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#7
2025-01-12 10:38:40,406#INFO#asset#decRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#6
2025-01-12 10:38:40,406#INFO#asset#decRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#5
2025-01-12 10:38:40,406#INFO#asset#decRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#4
2025-01-12 10:38:40,406#INFO#asset#decRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#3
2025-01-12 10:38:40,406#INFO#asset#decRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#2
2025-01-12 10:38:40,406#INFO#asset#decRef#RoleView#94936f61-fcda-4956-8042-05904f141b8e@f9941#sprite_roleNode_bg_2#1
2025-01-12 10:39:17,158#INFO#asset#monitor#system#94936f61-fcda-4956-8042-05904f141b8e@f9941##0
  • 第一行刚打印时候资源计数器直接8起步,因为静态资源是引擎自动加载,自动进行计数器+1的
  • 最后通过计数器-1释放资源,最多只能释放到1,不能到0。
  • 静态资源的释放是要等到相关prefab释放之后的下一帧才会释放
  • 通过监视器发现最终能够变成0,该资源并未泄露

以上就是我们项目中目前采用的资源管理方式和最终方式。希望能够坛友一起讨论探索!

7赞

写得不错。和我的资源管理核心有点像。但是有几个点不太一样:
1、 资源加载可以更纯粹。ResLoader 理论上应该只关心加载,并引用住资源,它除了提供一个释放接口外,最终要的是提供收集资源依赖并管理加载顺序。而不要去关心节点是否可用,以及不关心精灵对象设置 spriteFrame

2、具体的对象比如 ResSprit 去创建 ResLoader 加载资源,然后自己利用 ResLoader 的 API 进二次管理(比如说 setSpriteFrame 后调用,却因有缓存缓存,而先被回调的问题)
其他的大差不差

嗯嗯,看来大家思路差不多 :smiley:

setSpriteFrame,主要是这个是最常见的加载资源。封装了主要是为了减少重复代码,并且健壮性更高一些

不是这个意思,我的意思是可以封装到另一个组件,比如 ResSprite 组件。由它来 setSpriteFrame

然后你的 setSpriteFrame 如果同一帧切换多次 sprite Frame ,有几率会出现显示的不是最后一次调用的精灵图

1赞

什么情况下会啊,目前我们使用中还没遇到。我看代码感觉不会啊

会出现的,比如调用setSpriteFrame(资源a) setSpriteFrame(资源b),可能出现资源b先加载好,当a加载好的时候,设置的图片为资源a。这个就有问题了。

:+1: :+1:
写的挺好的,关键要点是:
问题:无需项目中每个开发者加载资源时候都要关注到 引用计数
方案:每个界面或者可以统一被管理的资源,定义一个加载器(缓存),统一加载释放。
扩展:加入资源监视器,输出引用计数不正常的资源,进行优化。

:joy:是的,我项目现在就有这个问题,怎么解决呢大佬

只能自己封装一下在setSpriteFrame时用队列的方式保证顺序。

一个虚拟列表快速滑动的时候,如果不记录下最后一次调用的精灵图片,因为网络请求返回的时间的不确定性 会出现图片错误

我是这样写的,给精灵节点里面去设置一个图片路径

嗯,你还没遇到过,通常是列表项快速滑动,然后节点复用导致的。

我的方案是标记加载任务的序号,如果加载成功回调后,当前精灵的ID出现变化了,就不设置。

// ResSprite.ts
import { Component, Sprite, SpriteAtlas, SpriteFrame, _decorator, isValid } from 'cc';
import { DestroyHook } from './DestroyHook';

const { ccclass, disallowMultiple } = _decorator;


/**
 * 精灵组件,自动管理资源的引用计数
 */
@ccclass("ResSprite")
@disallowMultiple
export class ResSprite extends Component {
    private $sp: Sprite = null;
    get sprite() { return this.$sp || (this.$sp = this.node.getComponent(Sprite) || this.node.addComponent(Sprite)); }

    set grayscale(isGrayScale: boolean) {
        this.sprite.grayscale = isGrayScale;
    }

    /** 全局的加载 Id */
    private static $slid: number = 0;
    /** 正在加载的项 */
    private $lid: number = 0;
    // 动态加载的资源
    private $sa: SpriteAtlas = null;
    private $sf: SpriteFrame = null;

    // SpriteAtlas
    /**
     * 加载 url 资源,只会回调最后一次加载
     * @param bUrl 
     * @param typ 
     * @param cb 
     * @returns 
     */
    async setSpriteFrame(bUrl: IBundleUrl, cb?: (asset: SpriteFrame) => void) {
        const that = this;
        const isSpriteAtlas = typeof bUrl.k === "string";
        // 标记加载顺序
        const loadingId = that.$lid = ++ResSprite.$slid;
        // 加载
        if (isSpriteAtlas) {
            const a = await fw.res.Load(bUrl, SpriteAtlas);
            if (!a) {
                cb && cb(null);
                return;
            }

            if (isValid(that) && loadingId === that.$lid) {
                a.addRef();
                that.$sa && that.$sa.decRef(true);
                that.$sa = a;
                const sp = a.getSpriteFrame(bUrl.k);
                sp.addRef();
                that.$sf && that.$sf.decRef(true);
                that.$sf = sp;
                that.sprite.spriteFrame = that.$sf;
                cb && cb(that.$sf)
            } else {
                a.addRef();
                a.decRef(true);
                cb && cb(null);
            }
        } else {
            const a = await fw.res.Load(bUrl, SpriteFrame);
            if (!a) {
                cb && cb(null);
                return;
            }

            if (isValid(that) && loadingId === that.$lid) {
                a.addRef();
                that.$sf && that.$sf.decRef(true);
                that.$sf = a;
                if (that.$sa) {
                    that.$sa.decRef(true);
                    that.$sa = null;
                }
                that.sprite.spriteFrame = that.$sf;
                cb && cb(that.$sf)
            } else {
                a.addRef();
                a.decRef(true);
                cb && cb(null);
            }
        }
    }

    /**  重置资源 */
    resetRes(): void {
        if (this.$lid > 128) {
            this.$lid = 0;
        } else {
            ++this.$lid;
        }

        if (this.$sf) {
            this.$sf.decRef(true);
            this.$sf = null;
        }

        if (this.$sa) {
            this.$sa.decRef(true);
            this.$sa = null;
        }

        this.sprite.spriteFrame = null;
    }

    protected onDestroy(): void {
        this.resetRes();
    }

    // -------- 省略的接口 --------
}

理解了,是有可能发生这种现象,感谢感谢 :+1::+1::+1:

有个小疑问请教一下,如果是通用模块或者界面上的子模块,怎么获取到当前界面上的加载器。

界面上的加载器是自己加载自己的资源,为什么要被外部获取

比如有个通用的头像组件,好几个界面都用到了通用头像。那这个头像组件上需要加载某个头像的时候,就需要知道用哪个界面的加载器。

通用头像组件提供 API 就好了啊,加载器写在头像组件里不就行了。实在无法理解啊

不好描述 :joy:

就是每个界面的代码不可能全都放到一个文件里面嘛,界面上其他文件想使用这个界面的加载器的时候,怎么传递过去。不知道这样表述的清不清楚。

或者就是每个模块或者组件都用自己的加载器。

需要分主要的界面以及界面组件。
主要的界面:有自己的资源引用计数管理器。
界面组件:构造的时候,传入对应的主界面的资源引用计数管理器。

如果更粗的粒度,一个模块(可能多个界面),可以只用一个资源引用计数管理器,对应于模块加载卸载。所有该模块的界面,都通过模块model来获取这个模块的资源引用计数管理器。

我用“资源引用计数管理器” 这个名词,是因为实际上分模块的资源管理器,是管理模块内资源引用计数正确使用的,真正的资源管理还是在下层的封装assetsManager。所以名词上做点区分。