Tutorial: Cocos Creator and resource management!

Cocos Creator and resource management!

Using Cocos Creator to make larger-scale games presents a resource management problem. As the game progresses, you may find that the memory consumption of the game only rises and does not fall, even if minimal resources are being used. You can use cc.loader.release() to release previously loaded resources, but most of the resources used will remain in memory! Why is this so?

Problems with resource management

Resource management mainly solves three problems, resource loading, resource search (use), and resource releasing. The main issue to be discussed here is the problems of resource releasing. This problem looks very simple. However, this is a more complicated problem because it is difficult to track whether a resource can be released in JavaScript.

In Cocos2d-x, reference counting is the way resources are tracked. When the reference count is 0, we release the resource and maintain the reference count. In Cocos2d-x, resource management is more decentralized. The engine uses Singletons to manage certain resources. Most of the resources need to be managed by the developers themselves. In Cocos Creator, our resources are managed by cc.loader().

Resource dependencies

Resource A may depend on resources B, C, D, and resource D depends on resource E. This is a very common resource dependency situation. If we use cc.loader.loadRes("A") to load resources A, B ~ E will be loaded, but if we call cc.loader.release("A"), only resource A will be released.

Every loaded resource will be put into cc.loader's cache, but cc.loader.release() just releases the incoming resources without considering the resource dependencies.

If you are interested in the resource loading process behind cc.loader, please refer to: this blog.

If we want to release the dependent resources together, Cocos Creator provides a clumsy method, cc.loader.getDependsRecursively;, to recursively get all the resources that the specified resource depends on, put it into an array and return, then in cc.loader the array is passed in .release(), cc.loader will traverse them and release them one by one.

Although this method can release resources, it is possible to release resources that should not be released. If a resource F depends on D, it will cause resource F to not work properly. Because Cocos Creator does not maintain good resource dependency, we do not know that F depends on us when we release D. Even if there is no F dependency, we are not sure whether D can be released. For example, we call cc.loader to load D, and then load A. At this time, D has been loaded and A can be used directly. But, if A is released, D is also released, which does not meet our expectations. What we expect is that when we do not explicitly release D, D should not be automatically released with the release of other resources.

A simple test: open the developer mode of Chrome, enter into the Console panel. Old versions of Cocos Creator can dump all the textures in cc.textureCache, while newer versions remove textureCache, but we can use cc.loader._cache to view all resources. If there are too many resources and only care about the quantity, you can enter Object.keys(cc.loader.cache).length to view the total number of resources. We can dump once before the resource is loaded, dump once after the load, and dump once after the release, to compare the cache status in cc.loader. Of course, you can also write some convenient methods, such as dumping pictures only, or the difference between the dump and the last dump.

Resource usage

In addition to the problem of resource dependency, we also need to solve the problem of resource usage. The former is the resource organization problem within cc.loader, and the latter is the resource usage problem of application layer logic. For example, we need to release a resource when an interface is closed. We also face a problem that it should not be released, such as whether another unclosed interface uses this resource? If the resource is used elsewhere, it should not be released!

ResLoader

ResLoader can help solve the problems that cc.loader did not solve. The key is to create a CacheInfo for each resource to record the resource’s dependency and use information, so as to judge whether the resource can be released, use ResLoader.getInstance().loadRes() replacing cc.loader.loadRes() and ResLoader.getInstance().releaseRes() to replace cc.loader.releaseRes().

For dependencies, ResLoader will automatically establish the mapping when the resource is loaded, will automatically cancel the mapping when the resource is released, and detect whether the unmapped resource can be released. This is the logic to release.

For use, a use parameter is provided to distinguish where the resource is used and whether it is used elsewhere. When a resource is not dependent on other resources or used by other logic, then The resources can be released.

Example:

/**
 * Resource loading class
  * 1. The reference relationship is automatically recorded after loading is completed, and the reverse dependency is recorded according to DependKeys
  * 2. Support resource usage. If an open UI uses resource A, resource B is released elsewhere. Resource B references resource A. If there is no other resource that references resource A, the release of resource A will be triggered
  * 3. Ability to safely release dependent resources (a resource is referenced by multiple resources at the same time, and the resource will only be released when other resources are released)
 * 
 * 2018-7-17 by 宝爷
 */

// Processing callback for resource loading
export type ProcessCallback = (completedCount: number, totalCount: number, item: any) => void;
// completion callback for resource loading
export type CompletedCallback = (error: Error, resource: any) => void;

// The parameter structure of the LoadRes method
interface CacheInfo {
    refs: Set<string>,
    uses: Set<string>
}

// LoadRes
interface LoadResArgs {
    url: string,
    type?: typeof cc.Asset,
    onCompleted?: CompletedCallback,
    onProgess?: ProcessCallback,
    use?: string,
}

// ReleaseRes
interface ReleaseResArgs {
    url: string,
    type?: typeof cc.Asset,
    use?: string,
}

// Compatibility handling
let isChildClassOf = cc.js["isChildClassOf"]
if (!isChildClassOf) {
    isChildClassOf = cc["isChildClassOf"];
}

export default class ResLoader {

    private _resMap: Map<string, CacheInfo> = new Map<string, CacheInfo>();
    private static _resLoader: ResLoader = null;
    public static getInstance(): ResLoader {
        if (!this._resLoader) {
            this._resLoader = new ResLoader();
        }
        return this._resLoader;
    }

    public static destroy(): void {
        if (this._resLoader) {
            this._resLoader = null;
        }
    }

    private constructor() {

    }

    /**
     * 从cc.loader中获取一个资源的item
     * @param url 查询的url
     * @param type 查询的资源类型
     */
    private _getResItem(url: string, type: typeof cc.Asset): any {
        let ccloader: any = cc.loader;
        let item = ccloader._cache[url];
        if (!item) {
            let uuid = ccloader._getResUuid(url, type, false);
            if (uuid) {
                let ref = ccloader._getReferenceKey(uuid);
                item = ccloader._cache[ref];
            }
        }
        return item;
    }

    /**
     * loadRes
     */
    private _makeLoadResArgs(): LoadResArgs {
        if (arguments.length < 1 || typeof arguments[0] != "string") {
            console.error(`_makeLoadResArgs error ${arguments}`);
            return null;
        }
        let ret: LoadResArgs = { url: arguments[0] };
        for (let i = 1; i < arguments.length; ++i) {
            if (i == 1 && isChildClassOf(arguments[i], cc.RawAsset)) {
                // 判断是不是第一个参数type
                ret.type = arguments[i];
            } else if (i == arguments.length - 1 && typeof arguments[i] == "string") {
                // 判断是不是最后一个参数use
                ret.use = arguments[i];
            } else if (typeof arguments[i] == "function") {
                // 其他情况为函数
                if (arguments.length > i + 1 && typeof arguments[i + 1] == "function") {
                    ret.onProgess = arguments[i];
                } else {
                    ret.onCompleted = arguments[i];
                }
            }
        }
        return ret;
    }

    /**
     * releaseRes
     */
    private _makeReleaseResArgs(): ReleaseResArgs {
        if (arguments.length < 1 || typeof arguments[0] != "string") {
            console.error(`_makeReleaseResArgs error ${arguments}`);
            return null;
        }
        let ret: ReleaseResArgs = { url: arguments[0] };
        for (let i = 1; i < arguments.length; ++i) {
            if (typeof arguments[i] == "string") {
                ret.use = arguments[i];
            } else {
                ret.type = arguments[i];
            }
        }
        return ret;
    }

    /**
      * Generate a resource using Key
      * @param where Where to use, such as Scene, UI, Pool
      * @param who users, such as Login, UIHelp ...
      * @param why use reason, customize ...
      */
    public static makeUseKey(where: string, who: string = "none", why: string = ""): string {
        return `use_${where}_by_${who}_for_${why}`;
    }

    /**
      * Get resource cache information
      * @param key resource url to get
      */
    public getCacheInfo(key: string): CacheInfo {
        if (!this._resMap.has(key)) {
            this._resMap.set(key, {
                refs: new Set<string>(),
                uses: new Set<string>()
            });
        }
        return this._resMap.get(key);
    }

    /**
      * Start loading resources
      * @param url resource url
      * @param type resource type, default is null
      * @param onProgess loading progress callback
      * @param onCompleted loading completion callback
      * @param use resource use key, generated according to makeUseKey method
      */ 
    public loadRes(url: string, use?: string);
    public loadRes(url: string, onCompleted: CompletedCallback, use?: string);
    public loadRes(url: string, onProgess: ProcessCallback, onCompleted: CompletedCallback, use?: string);
    public loadRes(url: string, type: typeof cc.Asset, use?: string);
    public loadRes(url: string, type: typeof cc.Asset, onCompleted: CompletedCallback, use?: string);
    public loadRes(url: string, type: typeof cc.Asset, onProgess: ProcessCallback, onCompleted: CompletedCallback, use?: string);
    public loadRes() {
        let resArgs: LoadResArgs = this._makeLoadResArgs.apply(this, arguments);
        console.time("loadRes|"+resArgs.url);
        let finishCallback = (error: Error, resource: any) => {
            // reverse association reference 
            // (mark all resources referenced by this resource reference)
            let addDependKey = (item, refKey) => {
                if (item && item.dependKeys && Array.isArray(item.dependKeys)) {
                    for (let depKey of item.dependKeys) {
                        // Record that the resource is cited by me
                        this.getCacheInfo(depKey).refs.add(refKey);
                        // cc.log(`${depKey} ref by ${refKey}`);
                        let ccloader: any = cc.loader;
                        let depItem = ccloader._cache[depKey]
                        addDependKey(depItem, refKey)
                    }
                }
            }

            let item = this._getResItem(resArgs.url, resArgs.type);
            if (item && item.url) {
                addDependKey(item, item.url);
            } else {
                cc.warn(`addDependKey item error1! for ${resArgs.url}`);
            }

            // add a resource
            if (item) {
                let info = this.getCacheInfo(item.url);
                info.refs.add(item.url);
                // Update resource usage
                if (resArgs.use) {
                    info.uses.add(resArgs.use);
                }
            }

            // execute completion callback
            if (resArgs.onCompleted) {
                resArgs.onCompleted(error, resource);
            }
            console.timeEnd("loadRes|"+resArgs.url);
        };

        // Predict whether the resource has been loaded
        let res = cc.loader.getRes(resArgs.url, resArgs.type);
        if (res) {
            finishCallback(null, res);
        } else {
            cc.loader.loadRes(resArgs.url, resArgs.type, resArgs.onProgess, finishCallback);
        }
    }

    /**
      * Free resources
      * @param url The URL to release
      * @param type resource type
      * @param use The resource use key to be released, generated according to the makeUseKey method
      */
    public releaseRes(url: string, use?: string);
    public releaseRes(url: string, type: typeof cc.Asset, use?: string)
    public releaseRes() {
        /** temporarily commented out */
        // return;

        let resArgs: ReleaseResArgs = this._makeReleaseResArgs.apply(this, arguments);
        let item = this._getResItem(resArgs.url, resArgs.type);
        if (!item) {
            console.warn(`releaseRes item is null ${resArgs.url} ${resArgs.type}`);
            return;
        }
        cc.log("resloader release item");
        // cc.log(arguments);
        let cacheInfo = this.getCacheInfo(item.url);
        if (resArgs.use) {
            cacheInfo.uses.delete(resArgs.use)
        }
        this._release(item, item.url);
    }

    // free a resource
    private _release(item, itemUrl) {
        if (!item) {
            return;
        }
        let cacheInfo = this.getCacheInfo(item.url);
        // de-reference
        cacheInfo.refs.delete(itemUrl);

        if (cacheInfo.uses.size == 0 && cacheInfo.refs.size == 0) {
            // de-reference
            let delDependKey = (item, refKey) => {
                if (item && item.dependKeys && Array.isArray(item.dependKeys)) {
                    for (let depKey of item.dependKeys) {
                        let ccloader: any = cc.loader;
                        let depItem = ccloader._cache[depKey]
                        this._release(depItem, refKey);
                    }
                }
            }
            delDependKey(item, itemUrl);
            // If there is no uuid, directly release the url
            if (item.uuid) {
                cc.loader.release(item.uuid);
                cc.log("resloader release item by uuid :" + item.url);
            } else {
                cc.loader.release(item.url);
                cc.log("resloader release item by url:" + item.url);
            }
        }
    }

    /**
      * Determine whether a resource can be released
      * @param url resource url
      * @param type resource type
      * @param use The resource use key to be released, generated according to the makeUseKey method
      */
    public checkReleaseUse(url: string, use?: string): boolean;
    public checkReleaseUse(url: string, type: typeof cc.Asset, use?: string): boolean
    public checkReleaseUse() {
        let resArgs: ReleaseResArgs = this._makeReleaseResArgs.apply(this, arguments);
        let item = this._getResItem(resArgs.url, resArgs.type);
        if (!item) {
            console.log(`cant release,item is null ${resArgs.url} ${resArgs.type}`);
            return true;
        }

        let cacheInfo = this.getCacheInfo(item.url);
        let checkUse = false;
        let checkRef = false;

        if (resArgs.use && cacheInfo.uses.size > 0) {
            if (cacheInfo.uses.size == 1 && cacheInfo.uses.has(resArgs.use)) {
                checkUse = true;
            } else {
                checkUse = false;
            }
        } else {
            checkUse = true;
        }

        if ((cacheInfo.refs.size == 1 && cacheInfo.refs.has(item.url)) || cacheInfo.refs.size == 0) {
            checkRef = true;
        } else {
            checkRef = false;
        }

        return checkUse && checkRef;
    }
}

Using ResLoader

Using ResLoader is very simple. The following is a simple example. Click the dump button to view the current total number of resources. After clicking cc.load and cc.release, dump them separately. There are 40 resources, and after the release, there are 39 resources, only one resource is released.

If you use ResLoader for testing, only 34 resources are found after the release. This is because the resources of the previously loaded scene are also dependent on the test resources, so these resources are also released. There will be no resource leaks.

Example:

@ccclass
export default class NetExample extends cc.Component {
    @property(cc.Node)
    attachNode: cc.Node = null;
    @property(cc.Label)
    dumpLabel: cc.Label = null;

    onLoadRes() {
        cc.loader.loadRes("Prefab/HelloWorld", cc.Prefab, (error: Error, prefab: cc.Prefab) => {
            if (!error) {
                cc.instantiate(prefab).parent = this.attachNode;
            }
        });
    }

    onUnloadRes() {
        this.attachNode.removeAllChildren(true);
        cc.loader.releaseRes("Prefab/HelloWorld");
    }

    onMyLoadRes() {
        ResLoader.getInstance().loadRes("Prefab/HelloWorld", cc.Prefab, (error: Error, prefab: cc.Prefab) => {
            if (!error) {
                cc.instantiate(prefab).parent = this.attachNode;
            }
        });
    }

    onMyUnloadRes() {
        this.attachNode.removeAllChildren(true);
        ResLoader.getInstance().releaseRes("Prefab/HelloWorld");
    }

    onDump() {
        let Loader:any = cc.loader;
        this.dumpLabel.string = `当前资源总数:${Object.keys(Loader._cache).length}`;
    }
}

The above example shows removing the node first and then releasing it. This is the correct way to use it. What if a texture is released without removing it? Because the texture is released, Cocos Creator will continue to report errors in the next rendering.

ResLoader is just the foundation. We don’t need to care about resource dependency when using ResLoader directly, but we still need to care about resource usage. In actual use, we may hope that the life cycle of resources is as follows:

  • Following the life cycle of an object, resources are released when the object is destroyed
  • Following the life cycle of an interface, resources are released when the interface is closed
  • Follow the life cycle of a scene, release resources when the scene changes

We can implement a component hanging on the object. When we write logic in the object or other components of the object and load resources, use this resource management component to load, and the component maintains the release of resources. The interface and scene are similar.

The code for this project is located onGitHub. Review the ResExample scene in the Scene directory.

2 Likes