Cocos Creator 通用框架設計 —— 資源管理

  • 2019 年 10 月 21 日
  • 筆記

如果你想使用Cocos Creator製作一些規模稍大的遊戲,那麼資源管理是必須解決的問題,隨著遊戲的進行,你可能會發現遊戲的記憶體佔用只升不降,哪怕你當前只用到了極少的資源,並且有使用cc.loader.release來釋放之前載入的資源,但之前使用過的大部分資源都會留在記憶體中!為什麼會這樣呢?

cocos creator 資源管理存在的問題

資源管理主要解決3個問題,資源載入,資源查找(使用),資源釋放。這裡要討論的主要是資源釋放的問題,這個問題看上去非常簡單,在Cocos2d-x中確實也很簡單,但在js中變得複雜了起來,因為難以跟蹤一個資源是否可以被釋放。

在Cocos2d-x中我們使用引用計數,在引用計數為0的時候釋放資源,維護好引用計數即可,而且在Cocos2d-x中我們對資源的管理是比較分散的,引擎層面只提供如TextureCache、AudioManager之類的單例來管理某種特定的資源,大多數的資源都需要我們自己去管理,而在cocos creator中,我們的資源統一由cc.loader來管理,大量使用prefab,prefab與各種資源複雜的引用關係增加了資源管理的難度。

資源依賴

資源A可能依賴資源B、C、D,而資源D又依賴資源E,這是非常常見的一種資源依賴情況,如果我們使用cc.loader.loadRes("A")載入資源A,B~E都會被載入進來,但如果我們調用cc.loader.release("A")則只有資源A被釋放。

image

每一個載入的資源都會放到cc.loader的_cache中,但cc.loader.release只是將傳入的資源進行釋放,而沒有考慮資源依賴的情況。

如果對cc.loader背後的資源載入流程感興趣可以參考: https://www.cnblogs.com/ybgame/p/10576884.html

如果我們希望將依賴的資源也一起釋放,cocos creator提供了一個笨拙的方法,cc.loader.getDependsRecursively;,遞歸獲取指定資源依賴的所有資源,放入一個數組並返回,然後在cc.loader.release中傳入該數組,cc.loader會遍歷它們,將其逐個釋放。

這種方式雖然可以將資源釋放,但卻有可能釋放了不應該釋放的資源,如果有一個資源F依賴D,這時候就會導致F資源無法正常工作。由於cocos creator引擎沒有維護好資源的依賴,導致我們在釋放D的時候並不知道還有F依賴我們。即使沒有F依賴,我們也不確定是否可以釋放D,比如我們調用cc.loader載入D,而後又載入了A,此時D已經載入完成,A可以直接使用。但如果釋放A的時候,將D也釋放了,這就不符合我們的預期,我們期望的是在我們沒有顯式地釋放D時,D不應該隨著其它資源的釋放而自動釋放。

可以簡單地進行測試,可以打開Chrome的開發者模式,在Console面板中進行輸入,如果是舊版本的cocos creator可以在cc.textureCache中dump所有的紋理,而新版本移除了textureCache,但我們可以輸入cc.loader._cache來查看所有的資源。如果資源太多,只關心數量,可以輸入Object.keys(cc.loader._cache).length來查看資源總數,我們可以在資源載入前dump一次,載入後dump一次,釋放後再dump一次,來對比cc.loader中的快取狀態。當然,也可以寫一些便捷的方法,如只dump圖片,或者dump與上次dump的差異項。

image

資源使用

除了資源依賴的問題,我們還需要解決資源使用的問題,前者是cc.loader內部的資源組織問題,後者是應用層邏輯的資源使用問題,比如我們需要在一個介面關閉的時候釋放某資源,同樣會面臨一個該不該釋放的問題,比如另外一個未關閉的介面是否使用了該資源?如果有其他地方用到了該資源,那麼就不應該釋放它!

ResLoader

在這裡我設計了一個ResLoader,來解決cc.loader沒有解決好的問題,關鍵是為每一個資源創建一個CacheInfo來記錄資源的依賴和使用等資訊,以此來判斷資源是否可以釋放,使用ResLoader.getInstance().loadRes()來替代cc.loader.loadRes(),ResLoader.getInstance().releaseRes()來替代cc.loader.releaseRes()。

對於依賴,在資源載入的時候ResLoader會自動建立起映射,釋放資源的時候會自動取消映射,並檢測取消映射後的資源是否可以釋放,是才走釋放的邏輯。

對於使用,提供了一個use參數,通過該參數來區別是哪裡使用了該資源,以及是否有其他地方使用了該資源,當一個資源即沒有倍其他資源依賴,也沒有被其它邏輯使用,那麼這個資源就可以被釋放。

/**   * 資源載入類   * 1. 載入完成後自動記錄引用關係,根據DependKeys記錄反向依賴   * 2. 支援資源使用,如某打開的UI使用了A資源,其他地方釋放資源B,資源B引用了資源A,如果沒有其他引用資源A的資源,會觸發資源A的釋放,   * 3. 能夠安全釋放依賴資源(一個資源同時被多個資源引用,只有當其他資源都釋放時,該資源才會被釋放)   *   * 2018-7-17 by 寶爺   */    // 資源載入的處理回調  export type ProcessCallback = (completedCount: number, totalCount: number, item: any) => void;  // 資源載入的完成回調  export type CompletedCallback = (error: Error, resource: any) => void;    // 引用和使用的結構體  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,  }    // 兼容性處理  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;      }        /**       * 生成一個資源使用Key       * @param where 在哪裡使用,如Scene、UI、Pool       * @param who 使用者,如Login、UIHelp...       * @param why 使用原因,自定義...       */      public static makeUseKey(where: string, who: string = "none", why: string = ""): string {          return `use_${where}_by_${who}_for_${why}`;      }        /**       * 獲取資源快取資訊       * @param key 要獲取的資源url       */      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);      }        /**       * 開始載入資源       * @param url           資源url       * @param type          資源類型,默認為null       * @param onProgess     載入進度回調       * @param onCompleted   載入完成回調       * @param use           資源使用key,根據makeUseKey方法生成       */      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) => {              // 反向關聯引用(為所有引用到的資源打上本資源引用到的標記)              let addDependKey = (item, refKey) => {                  if (item && item.dependKeys && Array.isArray(item.dependKeys)) {                      for (let depKey of item.dependKeys) {                          // 記錄該資源被我引用                          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}`);              }                // 給自己加一個自身的引用              if (item) {                  let info = this.getCacheInfo(item.url);                  info.refs.add(item.url);                  // 更新資源使用                  if (resArgs.use) {                      info.uses.add(resArgs.use);                  }              }                // 執行完成回調              if (resArgs.onCompleted) {                  resArgs.onCompleted(error, resource);              }              console.timeEnd("loadRes|"+resArgs.url);          };            // 預判是否資源已載入          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);          }      }        /**       * 釋放資源       * @param url   要釋放的url       * @param type  資源類型       * @param use   要解除的資源使用key,根據makeUseKey方法生成       */      public releaseRes(url: string, use?: string);      public releaseRes(url: string, type: typeof cc.Asset, use?: string)      public releaseRes() {          /**暫時不釋放資源 */          // 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);      }        // 釋放一個資源      private _release(item, itemUrl) {          if (!item) {              return;          }          let cacheInfo = this.getCacheInfo(item.url);          // 解除自身對自己的引用          cacheInfo.refs.delete(itemUrl);            if (cacheInfo.uses.size == 0 && cacheInfo.refs.size == 0) {              // 解除引用              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);              //如果沒有uuid,就直接釋放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);              }          }      }        /**       * 判斷一個資源能否被釋放       * @param url 資源url       * @param type  資源類型       * @param use   要解除的資源使用key,根據makeUseKey方法生成       */      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;      }  }

使用ResLoader

ResLoader的使用非常簡單,下面是一個簡單的例子,我們可以點擊dump按鈕來查看當前的資源總數,點擊cc.load、cc.release之後分別dump一次,可以發現,開始有36個資源,載入之後有40個資源,而執行釋放之後,還有39個資源,只釋放了一個資源。

如果使用ResLoader進行測試,發現釋放之後只有34個資源,這是因為前面載入場景的資源也被該測試資源依賴,所以這些資源也被釋放掉了,只要我們都使用ResLoader來載入和卸載資源,就不會出現資源泄露的問題。

image

示例程式碼:

@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}`;      }  }

可以看到上面的例子是先移除節點,再進行釋放,這是正確的使用方式,如果我沒有移除直接釋放呢??因為釋放了紋理,所以cocos creator在接下來的渲染中會不斷報錯。

ResLoader只是一個基礎,直接使用ResLoader我們不需要關心資源的依賴問題,但資源的使用問題我們還需要關心,在實際的使用中,我們可能希望資源的生命周期是以下幾種情況:

  • 跟隨某對象的生命周期,對象銷毀時資源釋放
  • 跟隨某介面的生命周期,介面關閉時資源釋放
  • 跟隨某場景的生命周期,場景切換時資源釋放

我們可以實現一個組件掛在到對象身上,當我們在該對象或該對象的其它組件中編寫邏輯,載入資源時,使用這個資源管理組件進行載入,由該組件來維護資源的釋放。介面和場景也類似。下一篇文章再聊一聊這個話題。

項目程式碼位於:https://github.com/wyb10a10/cocos_creator_framework ,打開Scene目錄的ResExample場景即可查看。