天天看點

Cocos Creator 通用架構設計 —— 資源管理優化

接着《Cocos Creator 通用架構設計 —— 資源管理》聊聊資源管理架構後續的一些優化:

通過論壇和github的issue,收到了很多優化或bug的回報,基本上抽空全部處理了,大概做了這麼一些事情。

https://github.com/wyb10a10/cocos_creator_framework

  • 修複重複引用洩露bug
  • 修複md5建構洩露bug
  • 修複龍骨動畫依賴資源釋放bug
  • 修複微信下的依賴建構bug
  • 修複持久節點釋放bug
  • 優化了資源依賴結構
  • 支援了資源目錄和數組的批量加載和釋放
  • 支援了遠端資源的管理
  • 新增場景資源的管理
  • 新增ResKeeper統一自動化管理資源
  • 新增記憶體洩露檢測工具
  • 新增資源池和對象池

這篇文章簡單分享一下幾個重要的優化點

資源依賴結構優化

在資源管理架構中,每個資源都由2部分引用組成,ref和use,ref表示依賴引用,use表示資源的使用,這個優化主要針對ref引用的,當出現下面這樣的資源依賴時,B、C、D的ref中會插入A,而E中會插入A和D,這是當時為了fix一些洩露bug的愚蠢實作,正确的實作中E的ref應該隻有D,這樣既節省空間又節省時間,當一個資源不應該被釋放時,沒有必要去周遊它的依賴項。在建構依賴項時,如果這個資源的依賴樹已經建立,也可以直接使用,不需要再去周遊。

Cocos Creator 通用架構設計 —— 資源管理優化

當我們loadRes(A),然後再loadRes(D),此時如果releaseRes(A),D和E是不應該被釋放的。這裡通過在loadRes時添加自身的ref來控制。

場景資源的管理

當時《Cocos Creator 通用架構設計 —— 資源管理》釋出時,并沒有處理場景自動加載的資源,這也導緻了一些bug,比如誤釋放場景的資源後會導緻報錯。

因為ResLoader可以管理它加載的所有資源,而場景的資源是Cocos引擎底層加載的,是以不在ResLoader的管理範圍之内,如果說使用ResLoader需要去修改引擎的話,其實不是很友好,當時在論壇中有很多讨論,最終在不修改引擎的前提下實作了一個相對優雅的方案,可以正确的管理場景資源,而且ResLoader的使用者不需要做任何額外處理,對于使用者而言是完全透明的。

一開始的思路是簡單判斷要釋放的資源是不是場景資源,是則直接跳過釋放,這樣就不會誤删場景資源了,但這個操作不友善判斷預加載場景的資源,也不能把該釋放的資源清幹淨,是以重新梳理整個場景切換流程後(詳情檢視這裡),有了一個新思路。

在ResLoader初始化時以及場景切換時,對場景資源進行緩存,由于引擎對場景資源有一個自動釋放的處理,是以納入ResLoader管理的場景資源,并不能完全由ResLoader釋放,一個場景依賴的資源可能有以下3種:自動釋放資源、不自動釋放資源、常駐節點依賴的資源。

對待這些資源我們需要差別處理,自動釋放資源由于場景切換流程會自動釋放它,是以我們隻需要簡單地移除ResLoader對它的引用即可,無需去釋放它。可能有人會問,如果一個自動釋放的資源,我在ResLoader中用loadRes加載依賴了它,場景切換時把它自動釋放了怎麼辦?并不會有問題,當我們去load一個資源時,它和它依賴的資源會自動從場景的autoRelease表中移除。

常駐節點的資源不能被自動釋放,因為它們在下一個場景中也需要使用。剩下的就是不自動釋放的資源,我們需要去釋放它。

對于不自動釋放的資源,可能是沒有勾選autoRelease,也可能是我們在遊戲中load了它,我們希望這種資源在場景切換的時候ResLoader能夠根據資源實際的引用情況自動釋放,如果我有一些公共資源就是不想釋放,想留到下一個場景用怎麼辦?可以用ResLoader進行引用。

public constructor() {
        // 1. 構造目前場景依賴
        let scene = cc.director.getScene();
        if (scene) {
            this._cacheScene(scene);
        }
        // 2. 監聽場景切換
        cc.director.on(cc.Director.EVENT_BEFORE_SCENE_LAUNCH, (scene) => {
            this._cacheScene(scene);
        });
    }
    
    /**
     * 緩存場景
     * @param scene 
     */
    private _cacheScene(scene: cc.Scene) {
        // 切換的場景名相同,無需清理資源
        if (scene.name == this._lastScene) {
            return;
        }

        let refKey = ccloader._getReferenceKey(scene.uuid);
        let item = ccloader._cache[refKey];
        let newUseKey = `@Scene${this.nextUseKey()}`;
        let depends: string[] = null;
        if (item) {
            depends = this._cacheSceneDepend(item.dependKeys, newUseKey);
        } else if(scene["dependAssets"]) {
            depends = this._cacheSceneDepend(scene["dependAssets"], newUseKey);
        } else {
            console.error(`cache scene faile ${scene}`);
            return;
        }
        this._releaseSceneDepend();
        this._lastScene = scene.name;
        ResLoader._sceneUseKey = newUseKey;
        this._sceneDepends = depends;
    }
    
   /**
     * 獲得持久節點清單
     */
    private _getPersistNodeList() {
        let game:any = cc.game;
        var persistNodeList = Object.keys(game._persistRootNodes).map(function (x) {
            return game._persistRootNodes[x];
        });
        return persistNodeList;
    }

    private _releaseSceneDepend() {
        if (this._sceneDepends) {
            let persistDepends : Set<string> = ResUtil.getNodesDepends(this._getPersistNodeList());
            for (let i = 0; i < this._sceneDepends.length; ++i) {
                // 判斷是不是已經被場景切換自動釋放的資源,是則直接移除緩存Item(失效項)
                let item = this._getResItem(this._sceneDepends[i], undefined);
                if (!item) {
                    this._resMap.delete(this._sceneDepends[i]);
                    cc.log(`delete untrack res ${this._sceneDepends[i]}`);
                }
                // 判斷是不是持久節點依賴的資源
                else if (!persistDepends.has(this._sceneDepends[i])) {
                    this.releaseRes(this._sceneDepends[i], ResLoader._sceneUseKey);
                }
            }
            this._sceneDepends = null;
        }
    }

    private _cacheSceneDepend(depends :string[], useKey: string): string[] {
        for (let i = 0; i < depends.length; ++i) {
            let item = ccloader._cache[depends[i]];
            this._cacheItem(item, useKey);
        }
        return depends;
    }
           

上面的代碼非常簡單,在ResLoader的構造函數中我們對目前的場景資源進行了緩存,然後監聽cc.Director.EVENT_BEFORE_SCENE_LAUNCH事件,當事件觸發的時候,我們緩存新場景的資源,并釋放舊場景的資源。

ResKeeper統一自動化管理資源

ResKeeper是一個用于自動化釋放資源的元件,如果我們希望很好地控制資源,那麼就需要用到use參數,我們是通過use參數來差別各個地方對同一個資源的加載和釋放。

ResLoader隻是提供了一套簡單的機制來保證在使用正确的情況下,能夠管理好資源,但直接使用ResLoader确實挺煩人的,因為我們需要手動地去loadRes和releaseRes,如果沒有成對地操作,就可能導緻資源洩露,更煩人的是,我們還要在加載的時候傳不同的use參數,并且在釋放的時候把use參數也傳進去,從ResLoader本身的角度是合理的,但這樣的接口就很不友好了,是以我實作了一個簡單的ResKeeper來解決這種煩人的問題。

ResKeeper可以讓我們忘掉use參數和releaseRes,隻關心要加載什麼資源。該元件會自動生成use參數,并記錄起來,在節點銷毀的時候自動釋放資源。

我們可以在任何Node上挂載ResKeeper元件,最合适的挂載點就是UI Node,在接下來的UI架構設計中,我會為每個UIView自動挂載這個元件。在這個UI中我們加載的任何資源都可以在該UI的ResKeeper元件中進行管理,在UI銷毀的時候釋放這些資源。另外舉一個例子比如每個角色、敵人身上可能會加載各種資源,如果我們希望在角色身上自動管理這些資源,也可以在角色身上挂載ResKeeper,在角色銷毀的時候自動釋放這些資源。

大多數情況下,資源的釋放總是跟随在某個節點的銷毀之後,一個場景、一個UI、一個層或者一個角色,最新的Cocos Creator中場景無法挂載元件,是以如果想要讓資源伴随着場景切換而自動釋放,我們需要一點特殊處理(這個處理稍後加上)
@ccclass
export default class ResKeeper extends cc.Component {

    private autoRes: autoResInfo[] = [];
    /**
     * 加載資源,通過此接口加載的資源會在界面被銷毀時自動釋放
     * 如果同時有其他地方引用的資源,會解除目前界面對該資源的占用
     * @param url 要加載的url
     * @param type 類型,如cc.Prefab,cc.SpriteFrame,cc.Texture2D
     * @param onCompleted 
     */
    public loadRes(url: string, type: typeof cc.Asset, onCompleted: CompletedCallback) {
        let use = resLoader.nextUseKey();
        resLoader.loadRes(url, type, (error: Error, res) => {
            if (!error) {
                this.autoRes.push({ url, use, type });
            }
            onCompleted && onCompleted(error, res);
        }, use);
    }

    /**
     * 元件銷毀時自動釋放所有keep的資源
     */
    public onDestroy() {
        this.releaseAutoRes();
    }

    /**
     * 釋放資源,元件銷毀時自動調用
     */
    public releaseAutoRes() {
        for (let index = 0; index < this.autoRes.length; index++) {
            const element = this.autoRes[index];
            resLoader.releaseRes(element.url, element.type, element.use);
        }
        this.autoRes.length = 0;
    }

    /**
     * 加入一個自動釋放的資源
     * @param resConf 資源url和類型 [ useKey ]
     */
    public autoReleaseRes(resConf: autoResInfo) {
        if(resLoader.addUse(resConf.url, resConf.use)) {
            this.autoRes.push(resConf);
        }
    }
}
           

為了友善對ResLoader的使用,我提供了一個ResUtil,它支援自動擷取ResKeeper,比如我們目前的邏輯在UI下的某個節點上的邏輯元件中,我可以通過ResUtil.getResKeeper(this).loadRes(xxx)來加載資源,getResKeeper會向上查找到最近的一個ResKeeper,然後傳回給我們。

更友善的接口應該是ResUtil.loadRes,好吧,這個稍後處理!但看到這裡你應該能夠了解這種自動化管理資源的思路了。我們隻需要調用這一個接口來加載資源,資源就會在合适的時候自動釋放,現在看起來舒服多了。
export class ResUtil {
    /**
     * 從目标節點或其父節點遞歸查找一個資源挂載元件
     * @param attachNode 目标節點
     * @param autoCreate 當目标節點找不到ResKeeper時是否自動建立一個
     */
    public static getResKeeper(attachNode: cc.Node, autoCreate?: boolean): ResKeeper {
        if (attachNode) {
            let ret = attachNode.getComponent(ResKeeper);
            if (!ret) {
                if (autoCreate) {
                    return attachNode.addComponent(ResKeeper);
                } else {
                    return ResUtil.getResKeeper(attachNode.parent, autoCreate);
                }
            }
            return ret;
        }
        console.error(`can't get ResKeeper for ${attachNode}`);
        return null;
    }

    /**
     * 指派srcAsset,并使其跟随targetNode自動釋放,用法如下
     * mySprite.spriteFrame = AssignWith(otherSpriteFrame, mySpriteNode);
     * @param srcAsset 用于指派的資源,如cc.SpriteFrame、cc.Texture等等
     * @param targetNode 
     * @param autoCreate 
     */
    public static assignWith(srcAsset: cc.Asset, targetNode: cc.Node, autoCreate?: boolean): any {
        let keeper = ResUtil.getResKeeper(targetNode, autoCreate);
        if (keeper && srcAsset) {
            let url = resLoader.getUrlByAsset(srcAsset);
            if (url) {
                keeper.autoReleaseRes({ url, use: resLoader.nextUseKey() });
                return srcAsset;
            }
        }
        console.error(`AssignWith ${srcAsset} to ${targetNode} faile`);
        return null;
    }

    /**
     * 執行個體化一個prefab,并帶自動釋放功能
     * @param prefab 要執行個體化的預制
     */
    public static instantiate(prefab: cc.Prefab): cc.Node {
        let node = cc.instantiate(prefab);
        let keeper = ResUtil.getResKeeper(node, true);
        if (keeper) {
            let url = resLoader.getUrlByAsset(prefab);
            if (url) {
                keeper.autoReleaseRes({ url, type: cc.Prefab, use: resLoader.nextUseKey() });
                return node;
            }
        }
        console.warn(`instantiate ${prefab}, autoRelease faile`);
        return node;
    }
}
           

基于ResKeeper,我們還可以解決另外一種問題,就是spriteFrame1 = spriteFrame2 這種指派無法被跟蹤的問題,原先,當我們加載了一個資源,如果希望在其他地方使用這個資源,那麼需要在另外一個地方進行loadRes,而我們想要的操作可能是直接将這個資源指派給它,但這樣ResLoader無法知道被指派的資源,是以會出現我們還在使用的資源卻被ResLoader釋放了的問題。

ResUtil.assignWith可以很好地解決這種問題,它不加載資源,隻是簡單地在ResLoader中進行注冊登記,然後将該資源丢給ResKeeper進行管理。

整體的思路比較清晰,但懶惰的我遲遲沒有将接口進行優化完善,讓它變得更好用

記憶體洩露檢測工具

這是一個用來輔助檢查記憶體洩露的小工具,使用起來非常簡單,可以在項目的ResExample場景中找到它的使用方法。

當我們要開始檢查記憶體洩露的時候,需要把它綁定到ResLoader中,并調用startCheck開始記錄接下來的所有資源加載,在合适的時候調用resLoader.resLeakChecker.dump();可以檢查目前記憶體中未釋放的資源,以及這些資源時在哪裡加載的。

舉一個使用的例子,比如我希望檢測戰鬥場景的資源洩露情況,可以在開始加載戰鬥場景前startCheck,在戰鬥結束後,将遊戲跳轉到一個什麼都沒有的空場景,然後dump檢視是否有未釋放的資源?

start() {
        let checker = new ResLeakChecker();
        checker.startCheck();
        resLoader.resLeakChecker = checker;
    }
           

除了startCheck、stopCheck、dump等簡單接口外,有時候我們希望過濾一些公共資源的檢測,因為我們本來就希望它常駐記憶體不要釋放,ResLeakChecker支援設定一個FilterCallback回調,來幫我們過濾這些資源。

export type FilterCallback = (url: string) => boolean;

export class ResLeakChecker {
    public resFilter: FilterCallback = null;
    
    public checkFilter(url: string): boolean {
        if (!this._checking) {
            return false;
        }
        if (this.resFilter) {
            return this.resFilter(url);
        }
        return true;
    }
}
           
前段時間研究了UE4的實時同步,非常棒的設計以及極其臃腫的實作,6月份會實作一個精簡版的網絡同步模型,包括用寫單機遊戲的方式寫網絡同步遊戲,單機模式調通後可以将部分代碼部署到nodeJs服務端,實作網絡版本,屬性同步和RPC支援,這個架構可以快速開發簡單的MMO、FPS、ARPG等類型的多人遊戲,歡迎star https://github.com/wyb10a10/cocos_creator_framework 。

繼續閱讀