v2.4開始,Creator使用AssetBundle完全重構了資源底層,提供了更加靈活強大的資源管理方式,也解決了之前版本資源管理的痛點(資源依賴與引用),本文將帶你深入瞭解Creator的新資源底層。
- 資源與構建
- 理解與使用AssetBundle
- 新資源框架剖析
- 載入管線
- 檔案下載
- 檔案解析
- 依賴載入
- 資源釋放
1.資源與構建
1.1 creator資原始檔基礎
在瞭解引擎如何解析、載入資源之前,我們先來了解一下這些資原始檔(圖片、Prefab、動畫等)的規則,在creator專案目錄下有幾個與資源相關的目錄:
- assets 所有資源的總目錄,對應creator編輯器的
資源管理器
- library 本地資源庫,預覽專案時使用的目錄
- build 構建後的專案預設目錄
在assets目錄下,creator會為每個資原始檔和目錄生成一個同名的.meta檔案,meta檔案是一個json檔案,記錄了資源的版本、uuid以及各種自定義的資訊(在編輯器的屬性檢查器
中設定),比如prefab的meta檔案,就記錄了我們可以在編輯器修改的optimizationPolicy和asyncLoadAssets等屬性。
{
"ver": "1.2.7",
"uuid": "a8accd2e-6622-4c31-8a1e-4db5f2b568b5",
"optimizationPolicy": "AUTO", // prefab建立優化策略
"asyncLoadAssets": false, // 是否延遲載入
"readonly": false,
"subMetas": {}
}
在library目錄下的imports目錄,資原始檔名會被轉換成uuid,並取uuid前2個字元進行目錄分組存放,creator會將所有資源的uuid到assets目錄的對映關係,以及資源和meta的最後更新時間戳放到一個名為uuid-to-mtime.json的檔案中,如下所示。
{
"9836134e-b892-4283-b6b2-78b5acf3ed45": {
"asset": 1594351233259,
"meta": 1594351616611,
"relativePath": "effects"
},
"430eccbf-bf2c-4e6e-8c0c-884bbb487f32": {
"asset": 1594351233254,
"meta": 1594351616643,
"relativePath": "effects\\__builtin-editor-gizmo-line.effect"
},
...
}
與assets目錄下的資源相比,library目錄下的資源合併了meta檔案的資訊。檔案目錄則只在uuid-to-mtime.json中記錄,library目錄並沒有為目錄生成任何東西。
1.2 資源構建
在專案構建之後,資源會從library目錄下移動到構建輸出的build目錄中,基本只會匯出參與構建的場景和resources目錄下的資源,及其引用到的資源。指令碼資源會由多個js指令碼合併為一個js,各種json檔案也會按照特定的規則進行打包。我們可以在Bundle的配置介面和專案的構建介面為Bundle和專案設定
1.2.1 圖片、圖集、自動圖集
- https://docs.cocos.com/creator/manual/zh/asset-workflow/sprite.html
- https://docs.cocos.com/creator/manual/zh/asset-workflow/atlas.html
- https://docs.cocos.com/creator/manual/zh/asset-workflow/auto-atlas.html
匯入編輯器的每張圖片都會對應生成一個json檔案,用於描述Texture的資訊,如下所示,預設情況下專案中所有的Texture2D的json檔案會被壓縮成一個,如果選擇無壓縮
,則每個圖片都會生成一個Texture2D的json檔案。
{
"__type__": "cc.Texture2D",
"content": "0,9729,9729,33071,33071,0,0,1"
}
如果將紋理的Type屬性設定為Sprite,Creator還會自動生成了SpriteFrame型別的json檔案。
圖集資源除了圖片外,還對應一個圖集json,這個json包含了cc.SpriteAtlas資訊,以及每個碎圖的SpriteFrame資訊
自動圖集在預設情況下只包含了cc.SpriteAtlas資訊,在勾選內聯所有SpriteFrame的情況下,會合並所有SpriteFrame
1.2.2 Prefab與場景
- https://docs.cocos.com/creator/manual/zh/asset-workflow/prefab.html
- https://docs.cocos.com/creator/manual/zh/asset-workflow/scene-managing.html
場景資源與Prefab資源非常類似,都是一個描述了所有節點、元件等資訊的json檔案,在勾選內聯所有SpriteFrame
的情況下,Prefab引用到的SpriteFrame會被合併到prefab所在的json檔案中,如果一個SpriteFrame被多個prefab引用,那麼每個prefab的json檔案都會包含該SpriteFrame的資訊。而在沒有勾選內聯所有SpriteFrame
的情況下,SpriteFrame會是單獨的json檔案。
1.2.3 資原始檔合併規則
當Creator將多個資源合併到一個json檔案中,我們可以在config.json中的packs欄位找到被打包
的資源資訊,一個資源有可能被重複打包到多個json中。下面舉一個例子,展示在不同的選項下,creator的構建規則:
- a.png 一個單獨的Sprite型別圖片
- dir/b.png、c.png、AutoAtlas dir目錄下包含2張圖片,以及一個AutoAtlas
- d.png、d.plist 普通圖集
- e.prefab 引用了SpriteFrame a和b的prefab
- f.prefab 引用了SpriteFrame b的prefab
下面是按不同規則構建後的檔案,可以看到,無壓縮的情況下生成的檔案數量是最多的,不內聯的檔案會比內聯多,但內聯可能會導致同一個檔案被重複包含,比如e和f這兩個Prefab都引用了同一個圖片,這個圖片的SpriteFrame.json會被重複包含,合併成一個json則只會生成一個檔案。
資原始檔 | 無壓縮 | 預設(不內聯) | 預設(內聯) | 合併json |
---|---|---|---|---|
a.png | a.texture.json + a.spriteframe.json | a.spriteframe.json | ||
./dir/b.png | b.texture.json + b.spriteframe.json | b.spriteframe.json | ||
./dir/c.png | c.texture.json + c.spriteframe.json | c.spriteframe.json | c.spriteframe.json | |
./dir/AutoAtlas | autoatlas.json | autoatlas.json | autoatlas.json | |
d.png | d.texture.json + d.spriteframe.json | d.spriteframe.json | d.spriteframe.json | |
d.plist | d.plist.json | d.plist.json | d.plist.json | |
e.prefab | e.prefab.json | e.prefab.json | e.prefab.json(pack a+b) | |
f.prefab | f.prefab.json | f.prefab.json | f.prefab.json(pack b) | |
g.allTexture.json | g.allTexture.json | all.json |
預設選項在絕大多數情況下都是一個不錯的選擇,如果是web平臺,建議勾選內聯所有SpriteFrame
這可以減少網路io,提高效能,而原生平臺不建議勾選,這可能會增加包體大小以及熱更時要下載的內容。對於一些緊湊的Bundle(比如載入該Bundle就需要用到裡面所有的資源),我們可以配置為合併所有的json。
2. 理解與使用 Asset Bundle
2.1 建立Bundle
Asset Bundle是creator 2.4之後的資源管理方案,簡單地說就是通過目錄來對資源進行規劃,按照專案的需求將各種資源放到不同的目錄下,並將目錄配置成Asset Bundle。能夠起到以下作用:
- 加快遊戲啟動時間
- 減小首包體積
- 跨專案複用資源
- 方便實現子游戲
- 以Bundle為單位的熱更新
Asset Bundle的建立非常簡單,只要在目錄的屬性檢查器
中勾選配置為bundle
即可,其中的選項官方文件都有比較詳細的介紹。
其中關於壓縮的理解,文件並沒有詳細的描述,這裡的壓縮指的並不是zip之類的壓縮,而是通過packAssets的方式,把多個資源的json檔案合併到一個,達到減少io的目的。
在選項上打勾非常簡單,真正的關鍵在於如何規劃Bundle,規劃的原則在於減少包體、加速啟動以及資源複用。根據遊戲的模組來規劃資源是比較不錯的選擇,比如按子游戲、關卡副本、或者系統功能來規劃。
Bundle會自動將資料夾下的資源,以及資料夾中引用到的其它資料夾下的資源打包(如果這些資源不是在其它Bundle中),如果我們按照模組來規劃資源,很容易出現多個Bundle共用了某個資源的情況。可以將公共資源提取到一個Bundle中,或者設定某個Bundle有較高的優先順序,構建Bundle的依賴關係,否則這些資源會同時放到多個Bundle中(如果是本地Bundle,這會導致包體變大)。
2.2 使用Bundle
- 關於載入資源 https://docs.cocos.com/creator/manual/zh/scripting/load-assets.html
- 關於釋放資源 https://docs.cocos.com/creator/manual/zh/asset-manager/release-manager.html
Bundle的使用也非常簡單,如果是resources目錄下的資源,可以直接使用cc.resources.load來載入
cc.resources.load("test assets/prefab", function (err, prefab) {
var newNode = cc.instantiate(prefab);
cc.director.getScene().addChild(newNode);
});
如果是其它自定義Bundle(本地Bundle或遠端Bundle都可以用Bundle名載入),可以使用cc.assetManager.loadBundle來載入Bundle,然後使用載入後的Bundle物件,來載入Bundle中的資源。對於原生平臺,如果Bundle被配置為遠端包,在構建時需要在構建釋出皮膚中填寫資源伺服器地址。
cc.assetManager.loadBundle('01_graphics', (err, bundle) => {
bundle.load('xxx');
});
原生或小遊戲平臺下,我們還可以這樣使用Bundle:
- 如果要載入其它專案的遠端Bundle,則需要使用url的方式載入(其它專案指另一個cocos工程)
- 如果希望自己管理Bundle的下載和快取,可以放到本地可寫路徑,並傳入路徑來載入這些Bundle
// 當複用其他專案的 Asset Bundle 時
cc.assetManager.loadBundle('https://othergame.com/remote/01_graphics', (err, bundle) => {
bundle.load('xxx');
});
// 原生平臺
cc.assetManager.loadBundle(jsb.fileUtils.getWritablePath() + '/pathToBundle/bundleName', (err, bundle) => {
// ...
});
// 微信小遊戲平臺
cc.assetManager.loadBundle(wx.env.USER_DATA_PATH + '/pathToBundle/bundleName', (err, bundle) => {
// ...
});
其它注意項:
- 載入Bundle僅僅只是載入了Bundle的配置和指令碼而已,Bundle中的其它資源還需要另外載入
- 目前原生的Bundle並不支援zip打包,遠端包下載方式為逐檔案下載,好處是操作簡單,更新方便,壞處是io多,流量消耗大
- 不同Bundle下的指令碼檔案不要重名
- 一個Bundle A依賴另一個Bundle B,如果B沒有被載入,載入A時並不會自動載入B,而是在載入A中依賴B的那個資源時報錯
3. 新資源框架剖析
v2.4重構後的新框架程式碼更加簡潔清晰,我們可以先從巨集觀角度瞭解一下整個資源框架,資源管線是整個框架最核心的部分,它規範了整個資源載入的流程,並支援對管線進行自定義。
- 公共檔案
- helper.js 定義了一堆公共函式,如decodeUuid、getUuidFromURL、getUrlWithUuid等等
- utilities.js 定義了一堆公共函式,如getDepends、forEach、parseLoadResArgs等等
- deserialize.js 定義了deserialize方法,將json物件反序列化為Asset物件,並設定其
__depends__
屬性 - depend-util.js 控制資源的依賴列表,每個資源的所有依賴都放在_depends成員變數中
- cache.js 通用快取類,封裝了一個簡易的鍵值對容器
- shared.js 定義了一些全域性物件,主要是Cache和Pipeline物件,如載入好的assets、下載完的files以及bundles等
- Bundle部分
- config.js bundle的配置物件,負責解析bundle的config檔案
- bundle.js bundle類,封裝了config以及載入解除安裝bundle內資源的相關介面
- builtins.js 內建bundle資源的封裝,可以通過
cc.assetManager.builtins
訪問
- 管線部分
- CCAssetManager.js 管理管線,提供統一的載入解除安裝介面
- 管線框架
- pipeline.js 實現了管線的管道組合以及流轉等基本功能
- task.js 定義了一個任務的基本屬性,並提供了簡單的任務池功能
- request-item.js 定義了一個資源下載項的基本屬性,一個任務可能會生成多個下載項
- 預處理管線
- urlTransformer.js parse將請求引數轉換成RequestItem物件(並查詢相關的資源配置),combine負責轉換真正的url
- preprocess.js 過濾出需要進行url轉換的資源,並呼叫transformPipeline
- 下載管線
- download-dom-audio.js 提供下載音效的方法,使用audio標籤進行下載
- download-dom-image.js 提供下載圖片的方法,使用Image標籤進行下載
- download-file.js 提供下載檔案的方法,使用XMLHttpRequest進行下載
- download-script.js 提供下載指令碼的方法,使用script標籤進行下載
- downloader.js 支援下載所有格式的下載器,支援併發控制、失敗重試、
- 解析管線
- factory.js 建立Bundle、Asset、Texture2D等物件的工廠
- fetch.js 呼叫packManager下載資源,並解析依賴
- parser.js 對下載完成的檔案進行解析
- 其它
- releaseManager.js 提供資源釋放介面、負責釋放依賴資源以及場景切換時的資源釋放
- cache-manager.d.ts 在非WEB平臺上,用於管理所有從伺服器上下載下來的快取
- pack-manager.js 處理打包資源,包括拆包,載入,快取等等
3.1 載入管線
creator使用管線(pipeline)來處理整個資源載入的流程,這樣的好處是解耦了資源處理的流程,將每一個步驟獨立成一個單獨的管道,管道可以很方便地進行復用和組合,並且方便了我們自定義整個載入流程,我們可以建立一些自己的管道,加入到管線中,比如資源加密。
AssetManager內建了3條管線,普通的載入管線、預載入、以及資源路徑轉換管線,最後這條管線是為前面兩條管線服務的。
// 正常載入
this.pipeline = pipeline.append(preprocess).append(load);
// 預載入
this.fetchPipeline = fetchPipeline.append(preprocess).append(fetch);
// 轉換資源路徑
this.transformPipeline = transformPipeline.append(parse).append(combine);
3.1.1 啟動載入管線【載入介面】
接下來我們看一下一個普通的資源是如何載入的,比如最簡單的cc.resource.load,在bundle.load方法中,呼叫了cc.assetManager.loadAny,在loadAny方法中,建立了一個新的任務,並呼叫正常載入管線pipeline的async方法執行任務。
注意要載入的資源路徑,被放到了task.input中、options是一個物件,物件包含了type、bundle和__requestType__等欄位
// bundle類的load方法
load (paths, type, onProgress, onComplete) {
var { type, onProgress, onComplete } = parseLoadResArgs(type, onProgress, onComplete);
cc.assetManager.loadAny(paths, { __requestType__: RequestType.PATH, type: type, bundle: this.name }, onProgress, onComplete);
},
// assetManager的loadAny方法
loadAny (requests, options, onProgress, onComplete) {
var { options, onProgress, onComplete } = parseParameters(options, onProgress, onComplete);
options.preset = options.preset || 'default';
let task = new Task({input: requests, onProgress, onComplete: asyncify(onComplete), options});
pipeline.async(task);
},
pipeline由兩部分組成 preprocess 和 load。preprocess 由以下管線組成 preprocess、transformPipeline { parse、combine },preprocess實際上只建立了一個子任務,然後交由transformPipeline執行。對於載入一個普通的資源,子任務的input和options與父任務相同。
let subTask = Task.create({input: task.input, options: subOptions});
task.output = task.source = transformPipeline.sync(subTask);
3.1.2 transformPipeline管線【準備階段】
transformPipeline由parse和combine兩個管線組成,parse的職責是為每個要載入的資源生成RequestItem物件並初始化其資源資訊(AssetInfo、uuid、config等):
- 先將input轉換成陣列進行遍歷,如果是批量載入資源,每個載入項都會生成RequestItem
- 如果輸入的item是object,則先將options拷貝到item身上(實際上每個item都會是object,如果是string的話,第一步就先轉換成object了)
- 對於UUID型別的item,先檢查bundle,並從bundle中提取AssetInfo,對於redirect型別的資源,則從其依賴的bundle中獲取AssetInfo,找不到bundle就報錯
- PATH型別和SCENE型別與UUID型別的處理基本類似,都是要拿到資源的詳細資訊
- DIR型別會從bundle中取出指定路徑的資訊,然後批量追加到input尾部(額外生成載入項)
- URL型別是遠端資源型別,無需特殊處理
function parse (task) {
// 將input轉換成陣列
var input = task.input, options = task.options;
input = Array.isArray(input) ? input : [ input ];
task.output = [];
for (var i = 0; i < input.length; i ++ ) {
var item = input[i];
var out = RequestItem.create();
if (typeof item === 'string') {
// 先建立object
item = Object.create(null);
item[options.__requestType__ || RequestType.UUID] = input[i];
}
if (typeof item === 'object') {
// local options will overlap glabal options
// 將options的屬性複製到item身上,addon會複製options上有,而item沒有的屬性
cc.js.addon(item, options);
if (item.preset) {
cc.js.addon(item, cc.assetManager.presets[item.preset]);
}
for (var key in item) {
switch (key) {
// uuid型別資源,從bundle中取出該資源的詳細資訊
case RequestType.UUID:
var uuid = out.uuid = decodeUuid(item.uuid);
if (bundles.has(item.bundle)) {
var config = bundles.get(item.bundle)._config;
var info = config.getAssetInfo(uuid);
if (info && info.redirect) {
if (!bundles.has(info.redirect)) throw new Error(`Please load bundle ${info.redirect} first`);
config = bundles.get(info.redirect)._config;
info = config.getAssetInfo(uuid);
}
out.config = config;
out.info = info;
}
out.ext = item.ext || '.json';
break;
case '__requestType__':
case 'ext':
case 'bundle':
case 'preset':
case 'type': break;
case RequestType.DIR:
// 解包後動態新增到input列表尾部,後續的迴圈會自動parse這些資源
if (bundles.has(item.bundle)) {
var infos = [];
bundles.get(item.bundle)._config.getDirWithPath(item.dir, item.type, infos);
for (let i = 0, l = infos.length; i < l; i++) {
var info = infos[i];
input.push({uuid: info.uuid, __isNative__: false, ext: '.json', bundle: item.bundle});
}
}
out.recycle();
out = null;
break;
case RequestType.PATH:
// PATH型別的資源根據路徑和type取出該資源的詳細資訊
if (bundles.has(item.bundle)) {
var config = bundles.get(item.bundle)._config;
var info = config.getInfoWithPath(item.path, item.type);
if (info && info.redirect) {
if (!bundles.has(info.redirect)) throw new Error(`you need to load bundle ${info.redirect} first`);
config = bundles.get(info.redirect)._config;
info = config.getAssetInfo(info.uuid);
}
if (!info) {
out.recycle();
throw new Error(`Bundle ${item.bundle} doesn't contain ${item.path}`);
}
out.config = config;
out.uuid = info.uuid;
out.info = info;
}
out.ext = item.ext || '.json';
break;
case RequestType.SCENE:
// 場景型別,從bundle中的config呼叫getSceneInfo取出該場景的詳細資訊
if (bundles.has(item.bundle)) {
var config = bundles.get(item.bundle)._config;
var info = config.getSceneInfo(item.scene);
if (info && info.redirect) {
if (!bundles.has(info.redirect)) throw new Error(`you need to load bundle ${info.redirect} first`);
config = bundles.get(info.redirect)._config;
info = config.getAssetInfo(info.uuid);
}
if (!info) {
out.recycle();
throw new Error(`Bundle ${config.name} doesn't contain scene ${item.scene}`);
}
out.config = config;
out.uuid = info.uuid;
out.info = info;
}
break;
case '__isNative__':
out.isNative = item.__isNative__;
break;
case RequestType.URL:
out.url = item.url;
out.uuid = item.uuid || item.url;
out.ext = item.ext || cc.path.extname(item.url);
out.isNative = item.__isNative__ !== undefined ? item.__isNative__ : true;
break;
default: out.options[key] = item[key];
}
if (!out) break;
}
}
if (!out) continue;
task.output.push(out);
if (!out.uuid && !out.url) throw new Error('unknown input:' + item.toString());
}
return null;
}
RequestItem的初始資訊,都是從bundle物件中查詢的,bundle的資訊則是從bundle自帶的config.json檔案中初始化的,在打包bundle的時候,會將bundle中的資源資訊寫入config.json中。
經過parse方法處理後,我們會得到一系列RequestItem,並且很多RequestItem都自帶了AssetInfo和uuid等資訊,combine方法會為每個RequestItem構建出真正的載入路徑,這個載入路徑最終會轉換到item.url中。
function combine (task) {
var input = task.output = task.input;
for (var i = 0; i < input.length; i++) {
var item = input[i];
// 如果item已經包含了url,則跳過,直接使用item的url
if (item.url) continue;
var url = '', base = '';
var config = item.config;
// 決定目錄的字首
if (item.isNative) {
base = (config && config.nativeBase) ? (config.base + config.nativeBase) : cc.assetManager.generalNativeBase;
}
else {
base = (config && config.importBase) ? (config.base + config.importBase) : cc.assetManager.generalImportBase;
}
let uuid = item.uuid;
var ver = '';
if (item.info) {
if (item.isNative) {
ver = item.info.nativeVer ? ('.' + item.info.nativeVer) : '';
}
else {
ver = item.info.ver ? ('.' + item.info.ver) : '';
}
}
// 拼接最終的url
// ugly hack, WeChat does not support loading font likes 'myfont.dw213.ttf'. So append hash to directory
if (item.ext === '.ttf') {
url = `${base}/${uuid.slice(0, 2)}/${uuid}${ver}/${item.options.__nativeName__}`;
}
else {
url = `${base}/${uuid.slice(0, 2)}/${uuid}${ver}${item.ext}`;
}
item.url = url;
}
return null;
}
3.1.3 load管線【載入流程】
load方法做的事情很簡單,基本只是建立了新的任務,在loadOneAssetPipeline中執行每個子任務
function load (task, done) {
if (!task.progress) {
task.progress = {finish: 0, total: task.input.length};
}
var options = task.options, progress = task.progress;
options.__exclude__ = options.__exclude__ || Object.create(null);
task.output = [];
forEach(task.input, function (item, cb) {
// 對每個input項都建立一個子任務,並交由loadOneAssetPipeline執行
let subTask = Task.create({
input: item,
onProgress: task.onProgress,
options,
progress,
onComplete: function (err, item) {
if (err && !task.isFinish && !cc.assetManager.force) done(err);
task.output.push(item);
subTask.recycle();
cb();
}
});
// 執行子任務,loadOneAssetPipeline有fetch和parse組成
loadOneAssetPipeline.async(subTask);
}, function () {
// 每個input執行完成後,最後執行該函式
options.__exclude__ = null;
if (task.isFinish) {
clear(task, true);
return task.dispatch('error');
}
gatherAsset(task);
clear(task, true);
done();
});
}
loadOneAssetPipeline如其函式名所示,就是載入一個資源的管線,它分為2步,fetch和parse:
- fetch方法用於下載資原始檔,由packManager負責下載的實現,fetch會將下載完的檔案資料放到item.file中
- parse方法用於將載入完的資原始檔轉換成我們可用的資源物件
- 對於原生資源,呼叫parser.parse進行解析,該方法會根據資源型別呼叫不同的解析方法
- import資源呼叫parseImport方法,根據json資料反序列化出Asset物件,並放到assets中
- 圖片資源會呼叫parseImage、parsePVRTex或parsePKMTex方法解析影像格式(但不會建立Texture物件)
- 音效資源呼叫parseAudio方法進行解析
- plist資源呼叫parsePlist方法進行解析
- 對於其它資源
- 如果uuid在
task.options.__exclude__
中,則標記為完成,並新增引用計數 - 否則,根據一些複雜的條件來決定是否載入資源的依賴
- 如果uuid在
- 對於原生資源,呼叫parser.parse進行解析,該方法會根據資源型別呼叫不同的解析方法
var loadOneAssetPipeline = new Pipeline('loadOneAsset', [
function fetch (task, done) {
var item = task.output = task.input;
var { options, isNative, uuid, file } = item;
var { reload } = options;
// 如果assets裡面已經載入了這個資源,則直接完成
if (file || (!reload && !isNative && assets.has(uuid))) return done();
// 下載檔案,這是一個非同步的過程,檔案下載完會被放到item.file中,並執行done驅動管線
packManager.load(item, task.options, function (err, data) {
if (err) {
if (cc.assetManager.force) {
err = null;
} else {
cc.error(err.message, err.stack);
}
data = null;
}
item.file = data;
done(err);
});
},
// 將資原始檔轉換成資源物件的過程
function parse (task, done) {
var item = task.output = task.input, progress = task.progress, exclude = task.options.__exclude__;
var { id, file, options } = item;
if (item.isNative) {
// 對於原生資源,呼叫parser.parse進行處理,將處理完的資源放到item.content中,並結束流程
parser.parse(id, file, item.ext, options, function (err, asset) {
if (err) {
if (!cc.assetManager.force) {
cc.error(err.message, err.stack);
return done(err);
}
}
item.content = asset;
task.dispatch('progress', ++progress.finish, progress.total, item);
files.remove(id);
parsed.remove(id);
done();
});
} else {
var { uuid } = item;
// 非原生資源,如果在task.options.__exclude__中,直接結束
if (uuid in exclude) {
var { finish, content, err, callbacks } = exclude[uuid];
task.dispatch('progress', ++progress.finish, progress.total, item);
if (finish || checkCircleReference(uuid, uuid, exclude) ) {
content && content.addRef();
item.content = content;
done(err);
} else {
callbacks.push({ done, item });
}
} else {
// 如果不是reload,且asset中包含了該uuid
if (!options.reload && assets.has(uuid)) {
var asset = assets.get(uuid);
// 開啟了options.__asyncLoadAssets__,或asset.__asyncLoadAssets__為false,直接結束,不載入依賴
if (options.__asyncLoadAssets__ || !asset.__asyncLoadAssets__) {
item.content = asset.addRef();
task.dispatch('progress', ++progress.finish, progress.total, item);
done();
}
else {
loadDepends(task, asset, done, false);
}
} else {
// 如果是reload,或者assets中沒有,則進行解析,並載入依賴
parser.parse(id, file, 'import', options, function (err, asset) {
if (err) {
if (cc.assetManager.force) {
err = null;
}
else {
cc.error(err.message, err.stack);
}
return done(err);
}
asset._uuid = uuid;
loadDepends(task, asset, done, true);
});
}
}
}
}
]);
3.2 檔案下載
creator使用packManager.load
來完成下載的工作,當要下載一個檔案時,有2個問題需要考慮:
- 該檔案是否被打包了,比如由於勾選了內聯所有SpriteFrame,導致SpriteFrame的json檔案被合併到prefab中
- 當前平臺是原生平臺還是web平臺,對於一些本地資源,原生平臺需要從磁碟讀取
// packManager.load的實現
load (item, options, onComplete) {
// 如果資源沒有被打包,則直接呼叫downloader.download下載(download內部也有已下載和載入中的判斷)
if (item.isNative || !item.info || !item.info.packs) return downloader.download(item.id, item.url, item.ext, item.options, onComplete);
// 如果檔案已經下載過了,則直接返回
if (files.has(item.id)) return onComplete(null, files.get(item.id));
var packs = item.info.packs;
// 如果pack已經在載入中,則將回撥新增到_loading佇列,等載入完成後觸發回撥
var pack = packs.find(isLoading);
if (pack) return _loading.get(pack.uuid).push({ onComplete, id: item.id });
// 下載一個新的pack
pack = packs[0];
_loading.add(pack.uuid, [{ onComplete, id: item.id }]);
let url = cc.assetManager._transform(pack.uuid, {ext: pack.ext, bundle: item.config.name});
// 下載pack並解包,
downloader.download(pack.uuid, url, pack.ext, item.options, function (err, data) {
files.remove(pack.uuid);
if (err) {
cc.error(err.message, err.stack);
}
// unpack package,內部實現包含2種解包,一種針對prefab、圖集等json陣列的分割解包,另一種針對Texture2D的content進行解包
packManager.unpack(pack.packs, data, pack.ext, item.options, function (err, result) {
if (!err) {
for (var id in result) {
files.add(id, result[id]);
}
}
var callbacks = _loading.remove(pack.uuid);
for (var i = 0, l = callbacks.length; i < l; i++) {
var cb = callbacks[i];
if (err) {
cb.onComplete(err);
continue;
}
var data = result[cb.id];
if (!data) {
cb.onComplete(new Error('can not retrieve data from package'));
}
else {
cb.onComplete(null, data);
}
}
});
});
}
3.2.1 Web平臺的下載
web平臺的download實現如下:
- 用一個downloaders陣列來管理各種資源型別對應的下載方式
- 使用files快取來避免重複下載
- 使用_downloading佇列來處理併發下載同一個資源時的回撥,並保證時序
- 支援了下載的優先順序、重試等邏輯
download (id, url, type, options, onComplete) {
// 取出downloaders中對應型別的下載回撥
let func = downloaders[type] || downloaders['default'];
let self = this;
// 避免重複下載
let file, downloadCallbacks;
if (file = files.get(id)) {
onComplete(null, file);
}
// 如果在下載中,新增到佇列
else if (downloadCallbacks = _downloading.get(id)) {
downloadCallbacks.push(onComplete);
for (let i = 0, l = _queue.length; i < l; i++) {
var item = _queue[i];
if (item.id === id) {
var priority = options.priority || 0;
if (item.priority < priority) {
item.priority = priority;
_queueDirty = true;
}
return;
}
}
}
else {
// 進行下載,並設定好下載失敗的重試
var maxRetryCount = options.maxRetryCount || this.maxRetryCount;
var maxConcurrency = options.maxConcurrency || this.maxConcurrency;
var maxRequestsPerFrame = options.maxRequestsPerFrame || this.maxRequestsPerFrame;
function process (index, callback) {
if (index === 0) {
_downloading.add(id, [onComplete]);
}
if (!self.limited) return func(urlAppendTimestamp(url), options, callback);
updateTime();
function invoke () {
func(urlAppendTimestamp(url), options, function () {
// when finish downloading, update _totalNum
_totalNum--;
if (!_checkNextPeriod && _queue.length > 0) {
callInNextTick(handleQueue, maxConcurrency, maxRequestsPerFrame);
_checkNextPeriod = true;
}
callback.apply(this, arguments);
});
}
if (_totalNum < maxConcurrency && _totalNumThisPeriod < maxRequestsPerFrame) {
invoke();
_totalNum++;
_totalNumThisPeriod++;
}
else {
// when number of request up to limitation, cache the rest
_queue.push({ id, priority: options.priority || 0, invoke });
_queueDirty = true;
if (!_checkNextPeriod && _totalNum < maxConcurrency) {
callInNextTick(handleQueue, maxConcurrency, maxRequestsPerFrame);
_checkNextPeriod = true;
}
}
}
// retry完成後,將檔案新增到files快取中,從_downloading佇列中移除,並執行callbacks回撥
// when retry finished, invoke callbacks
function finale (err, result) {
if (!err) files.add(id, result);
var callbacks = _downloading.remove(id);
for (let i = 0, l = callbacks.length; i < l; i++) {
callbacks[i](err, result);
}
}
retry(process, maxRetryCount, this.retryInterval, finale);
}
}
downloaders是一個map,對映了各種資源型別對應的下載方法,在web平臺主要包含以下幾類下載方法:
- 圖片類 downloadImage
- downloadDomImage 使用Html的Image元素,指定其src屬性來下載
- downloadBlob 以檔案下載的方式下載圖片
- 檔案類,這裡可以分為二進位制檔案、json檔案和文字檔案
- downloadArrayBuffer 指定arraybuffer型別呼叫downloadFile,用於skel、bin、pvr等檔案下載
- downloadText 指定text型別呼叫downloadFile,用於atlas、tmx、xml、vsh等檔案下載
- downloadJson 指定json型別呼叫downloadFile,並在下載完後解析json,用於plist、json等檔案下載
- 字型類 loadFont 構建css樣式,指定url下載
- 聲音類 downloadAudio
- downloadDomAudio 建立Html的audio元素,指定其src屬性來下載
- downloadBlob 以檔案下載的方式下載音效
- 視訊類 downloadVideo web端直接返回了
- 指令碼 downloadScript 建立Html的script元素,指定其src屬性來下載並執行
- Bundle downloadBundle 同時下載了Bundle的json和指令碼
downloadFile使用了XMLHttpRequest來下載檔案,具體實現如下:
function downloadFile (url, options, onProgress, onComplete) {
var { options, onProgress, onComplete } = parseParameters(options, onProgress, onComplete);
var xhr = new XMLHttpRequest(), errInfo = 'download failed: ' + url + ', status: ';
xhr.open('GET', url, true);
if (options.responseType !== undefined) xhr.responseType = options.responseType;
if (options.withCredentials !== undefined) xhr.withCredentials = options.withCredentials;
if (options.mimeType !== undefined && xhr.overrideMimeType ) xhr.overrideMimeType(options.mimeType);
if (options.timeout !== undefined) xhr.timeout = options.timeout;
if (options.header) {
for (var header in options.header) {
xhr.setRequestHeader(header, options.header[header]);
}
}
xhr.onload = function () {
if ( xhr.status === 200 || xhr.status === 0 ) {
onComplete && onComplete(null, xhr.response);
} else {
onComplete && onComplete(new Error(errInfo + xhr.status + '(no response)'));
}
};
if (onProgress) {
xhr.onprogress = function (e) {
if (e.lengthComputable) {
onProgress(e.loaded, e.total);
}
};
}
xhr.onerror = function(){
onComplete && onComplete(new Error(errInfo + xhr.status + '(error)'));
};
xhr.ontimeout = function(){
onComplete && onComplete(new Error(errInfo + xhr.status + '(time out)'));
};
xhr.onabort = function(){
onComplete && onComplete(new Error(errInfo + xhr.status + '(abort)'));
};
xhr.send(null);
return xhr;
}
3.2.2 原生平臺下載
原生平臺的引擎相關檔案可以在引擎目錄的resources/builtin/jsb-adapter/engine
目錄下,資源載入相關的實現在jsb-loader.js檔案中,這裡的downloader重新註冊了回撥函式。
downloader.register({
// JS
'.js' : downloadScript,
'.jsc' : downloadScript,
// Images
'.png' : downloadAsset,
'.jpg' : downloadAsset,
...
});
在原生平臺下,downloadAsset等方法都會呼叫download來進行資源的下載,在資源下載之前會呼叫transformUrl對url進行檢測,主要判斷該資源是網路資源還是本地資源,如果是網路資源,是否已經下載過了。只有沒下載過的網路資源,才需要進行下載。不需要下載的在檔案解析的地方會直接讀檔案。
// func傳入的是下載完成之後的處理,比如指令碼下載完成後需要執行,此時會呼叫window.require
// 如果說要下載的是json資源之類的,傳入的func是doNothing,也就是直接呼叫onComplete方法
function download (url, func, options, onFileProgress, onComplete) {
var result = transformUrl(url, options);
// 如果是本地檔案,直接指向func
if (result.inLocal) {
func(result.url, options, onComplete);
}
// 如果在快取中,更新資源的最後使用時間(lru)
else if (result.inCache) {
cacheManager.updateLastTime(url)
func(result.url, options, function (err, data) {
if (err) {
cacheManager.removeCache(url);
}
onComplete(err, data);
});
}
else {
// 未下載的網路資源,呼叫downloadFile進行下載
var time = Date.now();
var storagePath = '';
if (options.__cacheBundleRoot__) {
storagePath = `${cacheManager.cacheDir}/${options.__cacheBundleRoot__}/${time}${suffix++}${cc.path.extname(url)}`;
}
else {
storagePath = `${cacheManager.cacheDir}/${time}${suffix++}${cc.path.extname(url)}`;
}
// 使用downloadFile下載並快取
downloadFile(url, storagePath, options.header, onFileProgress, function (err, path) {
if (err) {
onComplete(err, null);
return;
}
func(path, options, function (err, data) {
if (!err) {
cacheManager.cacheFile(url, storagePath, options.__cacheBundleRoot__);
}
onComplete(err, data);
});
});
}
}
function transformUrl (url, options) {
var inLocal = false;
var inCache = false;
// 通過正則匹配是不是URL
if (REGEX.test(url)) {
if (options.reload) {
return { url };
}
else {
// 檢查是否在快取中(本地磁碟快取)
var cache = cacheManager.cachedFiles.get(url);
if (cache) {
inCache = true;
url = cache.url;
}
}
}
else {
inLocal = true;
}
return { url, inLocal, inCache };
}
downloadFile會呼叫原生平臺的jsb_downloader來下載資源,並儲存到本地磁碟中
downloadFile (remoteUrl, filePath, header, onProgress, onComplete) {
downloading.add(remoteUrl, { onProgress, onComplete });
var storagePath = filePath;
if (!storagePath) storagePath = tempDir + '/' + performance.now() + cc.path.extname(remoteUrl);
jsb_downloader.createDownloadFileTask(remoteUrl, storagePath, header);
},
3.3 檔案解析
在loadOneAssetPipeline中,資源會經過fetch和parse兩個管線進行處理,fetch負責下載而parse負責解析資源,並例項化資源物件。在parse方法中呼叫了parser.parse將檔案內容傳入,解析成對應的Asset物件,並返回。
3.3.1 Web平臺解析
Web平臺下的parser.parse主要做的是對解析中的檔案的管理,為解析中、解析完的檔案維護一個列表,避免重複解析。同時維護瞭解析完成後的回撥列表,而真正的解析方法在parsers陣列中。
parse (id, file, type, options, onComplete) {
let parsedAsset, parsing, parseHandler;
if (parsedAsset = parsed.get(id)) {
onComplete(null, parsedAsset);
}
else if (parsing = _parsing.get(id)){
parsing.push(onComplete);
}
else if (parseHandler = parsers[type]){
_parsing.add(id, [onComplete]);
parseHandler(file, options, function (err, data) {
if (err) {
files.remove(id);
}
else if (!isScene(data)){
parsed.add(id, data);
}
let callbacks = _parsing.remove(id);
for (let i = 0, l = callbacks.length; i < l; i++) {
callbacks[i](err, data);
}
});
}
else {
onComplete(null, file);
}
}
parsers對映了各種型別檔案的解析方法,下面以圖片和普通的asset資源為例:
注意:在parseImport方法中,反序列化方法會將資源的依賴放到asset.__depends__中,結構為陣列,陣列中每個物件包含3個欄位,資源id uuid、owner 物件、prop 屬性。比如一個Prefab資源,下面有2個節點,都引用了同一個資源,depends列表需要為這兩個節點物件分別記錄一條依賴資訊 [{uuid:xxx, owner:1, prop:tex}, {uuid:xxx, owner:2, prop:tex}]
// 對映圖片格式到解析方法
var parsers = {
'.png' : parser.parseImage,
'.jpg' : parser.parseImage,
'.bmp' : parser.parseImage,
'.jpeg' : parser.parseImage,
'.gif' : parser.parseImage,
'.ico' : parser.parseImage,
'.tiff' : parser.parseImage,
'.webp' : parser.parseImage,
'.image' : parser.parseImage,
'.pvr' : parser.parsePVRTex,
'.pkm' : parser.parsePKMTex,
// Audio
'.mp3' : parser.parseAudio,
'.ogg' : parser.parseAudio,
'.wav' : parser.parseAudio,
'.m4a' : parser.parseAudio,
// plist
'.plist' : parser.parsePlist,
'import' : parser.parseImport
};
// 圖片並不會解析成Asset物件,而是解析成對應的圖片物件
parseImage (file, options, onComplete) {
if (capabilities.imageBitmap && file instanceof Blob) {
let imageOptions = {};
imageOptions.imageOrientation = options.__flipY__ ? 'flipY' : 'none';
imageOptions.premultiplyAlpha = options.__premultiplyAlpha__ ? 'premultiply' : 'none';
createImageBitmap(file, imageOptions).then(function (result) {
result.flipY = !!options.__flipY__;
result.premultiplyAlpha = !!options.__premultiplyAlpha__;
onComplete && onComplete(null, result);
}, function (err) {
onComplete && onComplete(err, null);
});
}
else {
onComplete && onComplete(null, file);
}
},
// Asset物件的解析,通過deserialize實現,大致流程是解析json然後找到對應的class,並呼叫對應class的_deserialize方法拷貝資料、初始化變數,並將依賴資源放到asset.__depends
parseImport (file, options, onComplete) {
if (!file) return onComplete && onComplete(new Error('Json is empty'));
var result, err = null;
try {
result = deserialize(file, options);
}
catch (e) {
err = e;
}
onComplete && onComplete(err, result);
},
3.3.2 原生平臺解析
在原生平臺下,jsb-loader.js中重新註冊了各種資源的解析方法:
parser.register({
'.png' : downloader.downloadDomImage,
'.binary' : parseArrayBuffer,
'.txt' : parseText,
'.plist' : parsePlist,
'.font' : loadFont,
'.ExportJson' : parseJson,
...
});
圖片的解析方法竟然是downloader.downloadDomImage?跟蹤原生平臺除錯了一下,確實是呼叫的這個方法,建立了Image物件並指定src來載入圖片,這種方式載入本地磁碟的圖片也是可以的,但紋理物件又是如何建立的呢?通過Texture2D對應的json檔案,creator在載入真正的原生紋理之前,就已經建立好了Texture2D這個Asset物件,而在載入完原生圖片資源後,會將Image物件設定為Texture2D物件的_nativeAsset,在這個屬性的set方法中,會呼叫initWithData或initWithElement,這裡才真正使用紋理資料建立了用於渲染的紋理物件。
var Texture2D = cc.Class({
name: 'cc.Texture2D',
extends: require('../assets/CCAsset'),
mixins: [EventTarget],
properties: {
_nativeAsset: {
get () {
// maybe returned to pool in webgl
return this._image;
},
set (data) {
if (data._data) {
this.initWithData(data._data, this._format, data.width, data.height);
}
else {
this.initWithElement(data);
}
},
override: true
},
而對於parseJson、parseText、parseArrayBuffer等實現,這裡只是簡單地呼叫了檔案系統讀取檔案而已。像一些拿到檔案內容之後,需要進一步解析才能使用的資源呢?比如模型、骨骼等資源依賴二進位制的模型資料,這些資料的解析在哪裡呢?沒錯,跟上面的Texture2D一樣,都是放在對應的Asset資源本身,有些在_nativeAsset欄位的setter回撥中初始化,而有些會在真正使用這個資源時才惰性地進行初始化。
// 在jsb-loader.js檔案中
function parseText (url, options, onComplete) {
readText(url, onComplete);
}
function parseArrayBuffer (url, options, onComplete) {
readArrayBuffer(url, onComplete);
}
function parseJson (url, options, onComplete) {
readJson(url, onComplete);
}
// 在jsb-fs-utils.js檔案中
readText (filePath, onComplete) {
fsUtils.readFile(filePath, 'utf8', onComplete);
},
readArrayBuffer (filePath, onComplete) {
fsUtils.readFile(filePath, '', onComplete);
},
readJson (filePath, onComplete) {
fsUtils.readFile(filePath, 'utf8', function (err, text) {
var out = null;
if (!err) {
try {
out = JSON.parse(text);
}
catch (e) {
cc.warn('Read json failed: ' + e.message);
err = new Error(e.message);
}
}
onComplete && onComplete(err, out);
});
},
像圖集、Prefab這些資源又是怎麼初始化的呢?Creator還是使用parseImport方法進行解析,因為這些資源對應的型別是import
,原生平臺下並沒有覆蓋這種型別對應的parse函式,而這些資源會直接反序列化成可用的Asset物件。
3.4 依賴載入
creator將資源分為兩大類,普通資源和原生資源,普通資源包括cc.Asset及其子類,如cc.SpriteFrame、cc.Texture2D、cc.Prefab等等。原生資源包括各種格式的紋理、音樂、字型等檔案,在遊戲中我們無法直接使用這些原生資源,而是需要讓creator將他們轉換成對應的cc.Asset物件之後才能使用。
在creator中,一個Prefab可能會依賴很多資源,這些依賴也可以分為普通依賴和原生資源依賴,creator的cc.Asset提供了_parseDepsFromJson
和_parseNativeDepFromJson
方法來檢查資源的依賴。loadDepends通過getDepends方法蒐集了資源的依賴。
loadDepends建立了一個子任務來負責依賴資源的載入,並呼叫pipeline執行載入,實際上無論有無依賴需要載入,都會執行這段邏輯,載入完成後執行以下重要邏輯:
- 初始化assset:在依賴載入完成後,將依賴的資源賦值到asset對應的屬性後呼叫asset.onLoad
- 將資源對應的files和parsed快取移除,並快取資源到assets中(如果是場景的話,不會快取)
- 執行repeatItem.callbacks列表中的回撥(在loadDepends的開頭構造,預設記錄傳入的done方法)
// 載入指定asset的依賴項
function loadDepends (task, asset, done, init) {
var item = task.input, progress = task.progress;
var { uuid, id, options, config } = item;
var { __asyncLoadAssets__, cacheAsset } = options;
var depends = [];
// 增加引用計數來避免載入依賴的過程中資源被釋放,呼叫getDepends獲取依賴資源
asset.addRef && asset.addRef();
getDepends(uuid, asset, Object.create(null), depends, false, __asyncLoadAssets__, config);
task.dispatch('progress', ++progress.finish, progress.total += depends.length, item);
var repeatItem = task.options.__exclude__[uuid] = { content: asset, finish: false, callbacks: [{ done, item }] };
let subTask = Task.create({
input: depends,
options: task.options,
onProgress: task.onProgress,
onError: Task.prototype.recycle,
progress,
onComplete: function (err) {
// 在所有依賴項載入完成之後回撥
asset.decRef && asset.decRef(false);
asset.__asyncLoadAssets__ = __asyncLoadAssets__;
repeatItem.finish = true;
repeatItem.err = err;
if (!err) {
var assets = Array.isArray(subTask.output) ? subTask.output : [subTask.output];
// 構造一個map,記錄uuid到asset的對映
var map = Object.create(null);
for (let i = 0, l = assets.length; i < l; i++) {
var dependAsset = assets[i];
dependAsset && (map[dependAsset instanceof cc.Asset ? dependAsset._uuid + '@import' : uuid + '@native'] = dependAsset);
}
// 呼叫setProperties將對應的依賴資源設定到asset的成員變數中
if (!init) {
if (asset.__nativeDepend__ && !asset._nativeAsset) {
var missingAsset = setProperties(uuid, asset, map);
if (!missingAsset) {
try {
asset.onLoad && asset.onLoad();
}
catch (e) {
cc.error(e.message, e.stack);
}
}
}
}
else {
var missingAsset = setProperties(uuid, asset, map);
if (!missingAsset) {
try {
asset.onLoad && asset.onLoad();
}
catch (e) {
cc.error(e.message, e.stack);
}
}
files.remove(id);
parsed.remove(id);
cache(uuid, asset, cacheAsset !== undefined ? cacheAsset : cc.assetManager.cacheAsset);
}
subTask.recycle();
}
// 這個repeatItem可能有很多個地方都載入了它,要通知所有回撥載入完成
var callbacks = repeatItem.callbacks;
for (var i = 0, l = callbacks.length; i < l; i++) {
var cb = callbacks[i];
asset.addRef && asset.addRef();
cb.item.content = asset;
cb.done(err);
}
callbacks.length = 0;
}
});
pipeline.async(subTask);
}
3.4.1 依賴解析
getDepends (uuid, data, exclude, depends, preload, asyncLoadAssets, config) {
var err = null;
try {
var info = dependUtil.parse(uuid, data);
var includeNative = true;
if (data instanceof cc.Asset && (!data.__nativeDepend__ || data._nativeAsset)) includeNative = false;
if (!preload) {
asyncLoadAssets = !CC_EDITOR && (!!data.asyncLoadAssets || (asyncLoadAssets && !info.preventDeferredLoadDependents));
for (let i = 0, l = info.deps.length; i < l; i++) {
let dep = info.deps[i];
if (!(dep in exclude)) {
exclude[dep] = true;
depends.push({uuid: dep, __asyncLoadAssets__: asyncLoadAssets, bundle: config && config.name});
}
}
if (includeNative && !asyncLoadAssets && !info.preventPreloadNativeObject && info.nativeDep) {
config && (info.nativeDep.bundle = config.name);
depends.push(info.nativeDep);
}
} else {
for (let i = 0, l = info.deps.length; i < l; i++) {
let dep = info.deps[i];
if (!(dep in exclude)) {
exclude[dep] = true;
depends.push({uuid: dep, bundle: config && config.name});
}
}
if (includeNative && info.nativeDep) {
config && (info.nativeDep.bundle = config.name);
depends.push(info.nativeDep);
}
}
}
catch (e) {
err = e;
}
return err;
},
dependUtil是一個控制依賴列表的單例,通過傳入uuid和asset物件來解析該物件的依賴資源列表,返回的依賴資源列表可能包含以下4個欄位:
- deps 依賴的Asset資源
- nativeDep 依賴的原生資源
- preventPreloadNativeObject 禁止預載入原生物件,這個值預設是false
- preventDeferredLoadDependents 禁止延遲載入依賴,預設為false,對於骨骼動畫、TiledMap等資源為true
- parsedFromExistAsset 是否直接從
asset.__depends__
中取出
dependUtil還維護了_depends快取來避免依賴的重複查詢,這個快取會在首次查詢某資源依賴時新增,當該資源被釋放時移除
// 根據json資訊獲取其資源依賴列表,實際上json資訊就是asset物件
parse (uuid, json) {
var out = null;
// 如果是場景或者Prefab,data會是一個陣列,scene or prefab
if (Array.isArray(json)) {
// 如果已經解析過了,在_depends中有依賴列表,則直接返回
if (this._depends.has(uuid)) return this._depends.get(uuid)
out = {
// 對於Prefab或場景,直接使用_parseDepsFromJson方法返回
deps: cc.Asset._parseDepsFromJson(json),
asyncLoadAssets: json[0].asyncLoadAssets
};
}
// 如果包含__type__,獲取其建構函式,並從json中查詢依賴資源 get deps from json
// 實際測試,預載入的資源會走下面這個分支,預載入的資源並沒有把json反序列化成Asset物件
else if (json.__type__) {
if (this._depends.has(uuid)) return this._depends.get(uuid);
var ctor = js._getClassById(json.__type__);
// 部分資源重寫了_parseDepsFromJson和_parseNativeDepFromJson方法
// 比如cc.Texture2D
out = {
preventPreloadNativeObject: ctor.preventPreloadNativeObject,
preventDeferredLoadDependents: ctor.preventDeferredLoadDependents,
deps: ctor._parseDepsFromJson(json),
nativeDep: ctor._parseNativeDepFromJson(json)
};
out.nativeDep && (out.nativeDep.uuid = uuid);
}
// get deps from an existing asset
// 如果沒有__type__欄位,則無法找到它對應的ctor,從asset的__depends__欄位中取出依賴
else {
if (!CC_EDITOR && (out = this._depends.get(uuid)) && out.parsedFromExistAsset) return out;
var asset = json;
out = {
deps: [],
parsedFromExistAsset: true,
preventPreloadNativeObject: asset.constructor.preventPreloadNativeObject,
preventDeferredLoadDependents: asset.constructor.preventDeferredLoadDependents
};
let deps = asset.__depends__;
for (var i = 0, l = deps.length; i < l; i++) {
var dep = deps[i].uuid;
out.deps.push(dep);
}
if (asset.__nativeDepend__) {
// asset._nativeDep會返回類似這樣的物件 {__isNative__: true, uuid: this._uuid, ext: this._native}
out.nativeDep = asset._nativeDep;
}
}
// 第一次找到依賴,直接放到_depends列表中,cache dependency list
this._depends.add(uuid, out);
return out;
}
CCAsset預設的_parseDepsFromJson
和_parseNativeDepFromJson
實現如下,_parseDepsFromJson
通過呼叫parseDependRecursively遞迴json,將json物件及其子物件的所有__uuid__
全部找到放到depends陣列中。Texture2D、TTFFont、AudioClip的實現為直接返回空陣列,而SpriteFrame的實現為返回cc.assetManager.utils.decodeUuid(json.content.texture)
,這個欄位記錄了SpriteFrame對應紋理的uuid。
而_parseNativeDepFromJson
在改asset的_native
有值的情況下,會返回{ __isNative__: true, ext: json._native}
。實際上大部分的native資源走的是_nativeDep
,這個屬性的get方法會返回一個包含類似這樣的物件{__isNative__: true, uuid: this._uuid, ext: this._native}
。
_parseDepsFromJson (json) {
var depends = [];
parseDependRecursively(json, depends);
return depends;
},
_parseNativeDepFromJson (json) {
if (json._native) return { __isNative__: true, ext: json._native};
return null;
}
3.5 資源釋放
這一小節重點介紹在Creator中釋放資源的三種方式以及其背後的實現,最後介紹在專案中如何排查資源洩露的情況。
3.5.1 Creator的資源釋放
Creator支援以下3種資源釋放的方式:
釋放方式 | 釋放效果 |
---|---|
勾選:場景->屬性檢查器->自動釋放資源 | 在場景切換後,自動釋放新場景不使用的資源 |
引用計數釋放res.decRef |
使用addRef和decRef維護引用計數,在decRef後引用計數為0時自動釋放 |
手動釋放cc.assetManager.releaseAsset(texture); |
手動釋放資源,強制釋放 |
3.5.2 場景自動釋放
當一個新場景執行的時候會執行Director.runSceneImmediate方法,這裡呼叫了_autoRelease來實現老場景資源的自動釋放(如果老場景勾選了自動釋放資源)。
runSceneImmediate: function (scene, onBeforeLoadScene, onLaunched) {
// 省略程式碼...
var oldScene = this._scene;
if (!CC_EDITOR) {
// 自動釋放資源
CC_BUILD && CC_DEBUG && console.time('AutoRelease');
cc.assetManager._releaseManager._autoRelease(oldScene, scene, persistNodeList);
CC_BUILD && CC_DEBUG && console.timeEnd('AutoRelease');
}
// unload scene
CC_BUILD && CC_DEBUG && console.time('Destroy');
if (cc.isValid(oldScene)) {
oldScene.destroy();
}
// 省略程式碼...
},
最新版本的_autoRelease的實現非常簡潔乾脆,將持久節點的引用從老場景遷移到新場景,然後直接呼叫資源的decRef減少引用計數,而是否釋放老場景引用的資源,則取決於老場景是否設定了autoReleaseAssets。
// do auto release
_autoRelease (oldScene, newScene, persistNodes) {
// 所有持久節點依賴的資源自動addRef、並記錄到sceneDeps.persistDeps中
for (let i = 0, l = persistNodes.length; i < l; i++) {
var node = persistNodes[i];
var sceneDeps = dependUtil._depends.get(newScene._id);
var deps = _persistNodeDeps.get(node.uuid);
for (let i = 0, l = deps.length; i < l; i++) {
var dependAsset = assets.get(deps[i]);
if (dependAsset) {
dependAsset.addRef();
}
}
if (sceneDeps) {
!sceneDeps.persistDeps && (sceneDeps.persistDeps = []);
sceneDeps.persistDeps.push.apply(sceneDeps.persistDeps, deps);
}
}
// 釋放老場景的依賴
if (oldScene) {
var childs = dependUtil.getDeps(oldScene._id);
for (let i = 0, l = childs.length; i < l; i++) {
let asset = assets.get(childs[i]);
asset && asset.decRef(CC_TEST || oldScene.autoReleaseAssets);
}
var dependencies = dependUtil._depends.get(oldScene._id);
if (dependencies && dependencies.persistDeps) {
var persistDeps = dependencies.persistDeps;
for (let i = 0, l = persistDeps.length; i < l; i++) {
let asset = assets.get(persistDeps[i]);
asset && asset.decRef(CC_TEST || oldScene.autoReleaseAssets);
}
}
dependUtil.remove(oldScene._id);
}
},
3.5.3 引用計數和手動釋放資源
剩下兩種釋放資源的方式,本質上都是呼叫releaseManager.tryRelease來實現資源釋放,區別在於decRef是根據引用計數和autoRelease來決定是否呼叫tryRelease,而releaseAsset是強制釋放。資源釋放的完整流程大致如下圖所示:
// CCAsset.js 減少引用
decRef (autoRelease) {
this._ref--;
autoRelease !== false && cc.assetManager._releaseManager.tryRelease(this);
return this;
}
// CCAssetManager.js 手動釋放資源
releaseAsset (asset) {
releaseManager.tryRelease(asset, true);
},
tryRelease支援延遲釋放和強制釋放2種模式,當傳入force引數為true時直接進入釋放流程,否則creator會將資源放入待釋放的列表中,並在EVENT_AFTER_DRAW
事件中執行freeAssets方法真正清理資源。不論何種方式,資源會傳入到_free方法處理,這個方法做了以下幾件事情。
- 從_toDelete中移除
- 在非force釋放時,需要檢查是否還有其它引用,如果是則返回
- 從assets快取中移除
- 自動釋放依賴資源
- 呼叫資源的destroy方法銷燬資源
- 從dependUtil中移除資源的依賴記錄
checkCircularReference返回值如果大於0,表示資源還有被其它地方引用,其它地方指所有我們addRef的地方,該方法會先記錄asset當前的refCount,然後消除掉資源和依賴資源中對asset的引用,這相當於資源A內部掛載了元件B和C,它們都引用了資源A,此時資源A的引用計數為2,而元件B和C其實是要跟著A釋放的,而A被B和C引用著,計數就不為0無法釋放,所以checkCircularReference先排除了內部的引用。如果資源的refCount減去了內部的引用次數還大於1,說明有其它地方還引用著它,不能釋放。
tryRelease (asset, force) {
if (!(asset instanceof cc.Asset)) return;
if (force) {
releaseManager._free(asset, force);
}
else {
_toDelete.add(asset._uuid, asset);
// 在下次Director繪製完成之後,執行freeAssets
if (!eventListener) {
eventListener = true;
cc.director.once(cc.Director.EVENT_AFTER_DRAW, freeAssets);
}
}
}
// 釋放資源
_free (asset, force) {
_toDelete.remove(asset._uuid);
if (!cc.isValid(asset, true)) return;
if (!force) {
if (asset.refCount > 0) {
// 檢查資源內部的迴圈引用
if (checkCircularReference(asset) > 0) return;
}
}
// 從快取中移除
assets.remove(asset._uuid);
var depends = dependUtil.getDeps(asset._uuid);
for (let i = 0, l = depends.length; i < l; i++) {
var dependAsset = assets.get(depends[i]);
if (dependAsset) {
dependAsset.decRef(false);
releaseManager._free(dependAsset, false);
}
}
asset.destroy();
dependUtil.remove(asset._uuid);
},
// 釋放_toDelete中的資源並清空
function freeAssets () {
eventListener = false;
_toDelete.forEach(function (asset) {
releaseManager._free(asset);
});
_toDelete.clear();
}
asset.destroy做了什麼?資源物件是如何被釋放掉的?像紋理、聲音這樣的資源又是如何被釋放掉的呢?Asset物件本身並沒有destroy方法,而是Asset物件所繼承的CCObject物件實現了destroy,這裡的實現只是將物件放到了一個待釋放的陣列中,並打上ToDestroy
的標記。Director每一幀都會呼叫deferredDestroy來執行_destroyImmediate
進行資源釋放,這個方法會對物件的Destroyed標記進行判斷和操作、呼叫_onPreDestroy
方法執行回撥、以及_destruct
方法進行析構。
prototype.destroy = function () {
if (this._objFlags & Destroyed) {
cc.warnID(5000);
return false;
}
if (this._objFlags & ToDestroy) {
return false;
}
this._objFlags |= ToDestroy;
objectsToDestroy.push(this);
if (CC_EDITOR && deferredDestroyTimer === null && cc.engine && ! cc.engine._isUpdating) {
// 在編輯器模式下可以立即銷燬
deferredDestroyTimer = setImmediate(deferredDestroy);
}
return true;
};
// Director每一幀都會呼叫這個方法
function deferredDestroy () {
var deleteCount = objectsToDestroy.length;
for (var i = 0; i < deleteCount; ++i) {
var obj = objectsToDestroy[i];
if (!(obj._objFlags & Destroyed)) {
obj._destroyImmediate();
}
}
// 當我們在a.onDestroy中呼叫b.destroy,objectsToDestroy陣列的大小會變化,我們只銷毀在這次deferredDestroy之前objectsToDestroy中的元素
if (deleteCount === objectsToDestroy.length) {
objectsToDestroy.length = 0;
}
else {
objectsToDestroy.splice(0, deleteCount);
}
if (CC_EDITOR) {
deferredDestroyTimer = null;
}
}
// 真正的資源釋放
prototype._destroyImmediate = function () {
if (this._objFlags & Destroyed) {
cc.errorID(5000);
return;
}
// 執行回撥
if (this._onPreDestroy) {
this._onPreDestroy();
}
if ((CC_TEST ? (/* make CC_EDITOR mockable*/ Function('return !CC_EDITOR'))() : !CC_EDITOR) || cc.engine._isPlaying) {
this._destruct();
}
this._objFlags |= Destroyed;
};
在這裡_destruct
做的事情就是將物件的屬性清空,比如將object型別的屬性置為null,將string型別的屬性置為'',compileDestruct方法會返回一個該類的解構函式,compileDestruct先收集了普通object和cc.Class這兩種型別下的所有屬性,並根據型別構建了一個propsToReset用來清空屬性,支援JIT的情況下會根據要清空的屬性生成一個類似這樣的函式返回function(o) {o.a='';o.b=null;o.['c']=undefined...}
,而非JIT情況下會返回一個根據propsToReset遍歷處理的函式,前者佔用更多記憶體,但效率更高。
prototype._destruct = function () {
var ctor = this.constructor;
var destruct = ctor.__destruct__;
if (!destruct) {
destruct = compileDestruct(this, ctor);
js.value(ctor, '__destruct__', destruct, true);
}
destruct(this);
};
function compileDestruct (obj, ctor) {
var shouldSkipId = obj instanceof cc._BaseNode || obj instanceof cc.Component;
var idToSkip = shouldSkipId ? '_id' : null;
var key, propsToReset = {};
for (key in obj) {
if (obj.hasOwnProperty(key)) {
if (key === idToSkip) {
continue;
}
switch (typeof obj[key]) {
case 'string':
propsToReset[key] = '';
break;
case 'object':
case 'function':
propsToReset[key] = null;
break;
}
}
}
// Overwrite propsToReset according to Class
if (cc.Class._isCCClass(ctor)) {
var attrs = cc.Class.Attr.getClassAttrs(ctor);
var propList = ctor.__props__;
for (var i = 0; i < propList.length; i++) {
key = propList[i];
var attrKey = key + cc.Class.Attr.DELIMETER + 'default';
if (attrKey in attrs) {
if (shouldSkipId && key === '_id') {
continue;
}
switch (typeof attrs[attrKey]) {
case 'string':
propsToReset[key] = '';
break;
case 'object':
case 'function':
propsToReset[key] = null;
break;
case 'undefined':
propsToReset[key] = undefined;
break;
}
}
}
}
if (CC_SUPPORT_JIT) {
// compile code
var func = '';
for (key in propsToReset) {
var statement;
if (CCClass.IDENTIFIER_RE.test(key)) {
statement = 'o.' + key + '=';
}
else {
statement = 'o[' + CCClass.escapeForJS(key) + ']=';
}
var val = propsToReset[key];
if (val === '') {
val = '""';
}
func += (statement + val + ';\n');
}
return Function('o', func);
}
else {
return function (o) {
for (var key in propsToReset) {
o[key] = propsToReset[key];
}
};
}
}
那麼_onPreDestroy
又做了什麼呢?主要是將各種事件、定時器進行登出,對子節點、元件等進行刪除,詳情可以看下面這段程式碼。
// Node的_onPreDestroy
_onPreDestroy () {
// 呼叫_onPreDestroyBase方法,實際是呼叫BaseNode.prototype._onPreDestroy,這個方法下面介紹
var destroyByParent = this._onPreDestroyBase();
// 登出Actions
if (ActionManagerExist) {
cc.director.getActionManager().removeAllActionsFromTarget(this);
}
// 移除_currentHovered
if (_currentHovered === this) {
_currentHovered = null;
}
this._bubblingListeners && this._bubblingListeners.clear();
this._capturingListeners && this._capturingListeners.clear();
// 移除所有觸控和滑鼠事件監聽
if (this._touchListener || this._mouseListener) {
eventManager.removeListeners(this);
if (this._touchListener) {
this._touchListener.owner = null;
this._touchListener.mask = null;
this._touchListener = null;
}
if (this._mouseListener) {
this._mouseListener.owner = null;
this._mouseListener.mask = null;
this._mouseListener = null;
}
}
if (CC_JSB && CC_NATIVERENDERER) {
this._proxy.destroy();
this._proxy = null;
}
// 回收到物件池中
this._backDataIntoPool();
if (this._reorderChildDirty) {
cc.director.__fastOff(cc.Director.EVENT_AFTER_UPDATE, this.sortAllChildren, this);
}
if (!destroyByParent) {
if (CC_EDITOR) {
// 確保編輯模式下的,節點的被刪除後可以通過ctrl+z撤銷(重新新增到原來的父節點)
this._parent = null;
}
}
},
// BaseNode的_onPreDestroy
_onPreDestroy () {
var i, len;
// 加上Destroying標記
this._objFlags |= Destroying;
var parent = this._parent;
// 根據檢測父節點的標記判斷是不是由父節點的destroy發起的釋放
var destroyByParent = parent && (parent._objFlags & Destroying);
if (!destroyByParent && (CC_EDITOR || CC_TEST)) {
// 從編輯器中移除
this._registerIfAttached(false);
}
// 把所有子節點進行釋放,它們的_onPreDestroy也會被執行
var children = this._children;
for (i = 0, len = children.length; i < len; ++i) {
children[i]._destroyImmediate();
}
// 把所有的元件進行釋放,它們的_onPreDestroy也會被執行
for (i = 0, len = this._components.length; i < len; ++i) {
var component = this._components[i];
component._destroyImmediate();
}
// 登出事件監聽,比如otherNode.on(type, callback, thisNode) 註冊了事件
// thisNode被釋放時,需要登出otherNode身上的監聽,避免事件回撥到已銷燬的物件上
var eventTargets = this.__eventTargets;
for (i = 0, len = eventTargets.length; i < len; ++i) {
var target = eventTargets[i];
target && target.targetOff(this);
}
eventTargets.length = 0;
// 如果自己是常駐節點,則從常駐節點列表中移除
if (this._persistNode) {
cc.game.removePersistRootNode(this);
}
// 如果是自己釋放的自己,而不是從父節點釋放的,要通知父節點,把這個失效的子節點移除掉
if (!destroyByParent) {
if (parent) {
var childIndex = parent._children.indexOf(this);
parent._children.splice(childIndex, 1);
parent.emit && parent.emit('child-removed', this);
}
}
return destroyByParent;
},
// Component的_onPreDestroy
_onPreDestroy () {
// 移除ActionManagerExist和schedule
if (ActionManagerExist) {
cc.director.getActionManager().removeAllActionsFromTarget(this);
}
this.unscheduleAllCallbacks();
// 移除所有的監聽
var eventTargets = this.__eventTargets;
for (var i = eventTargets.length - 1; i >= 0; --i) {
var target = eventTargets[i];
target && target.targetOff(this);
}
eventTargets.length = 0;
// 編輯器模式下停止監控
if (CC_EDITOR && !CC_TEST) {
_Scene.AssetsWatcher.stop(this);
}
// destroyComp的實現為呼叫元件的onDestroy回撥,各個元件會在回撥中銷燬自身的資源
// 比如RigidBody3D元件會呼叫body的destroy方法,而Animation元件會呼叫stop方法
cc.director._nodeActivator.destroyComp(this);
// 將元件從節點身上移除
this.node._removeComponent(this);
},
3.5.4 資源釋放的問題
最後我們來聊一聊資源釋放的問題與定位,在加入引用計數後,最常見的問題還是沒有正確增減引用計數導致的記憶體洩露(迴圈引用、少呼叫了decRef或多呼叫了addRef),以及正在使用的資源被釋放的問題(和記憶體洩露相反,資源被提前釋放了)。
從目前的程式碼來看,如果正確使用了引用計數,新的資源底層是可以避免記憶體洩露等問題的
這種問題怎麼解決呢?首先是定位出哪些資源出了問題,如果是被提前釋放,我們可以直接定位到這個資源,如果是記憶體洩露,當我們發現問題時程式往往已經佔用了大量的記憶體,這種情況下可以切換到一個空場景,並清理資源,把資源清理完後,可以檢查assets中殘留的資源是否有未被釋放的資源。
要了解資源為什麼會洩露,可以通過跟蹤addRef和decRef的呼叫得到,下面提供了一個示例方法,用於跟蹤某資源的addRef和decRef呼叫,然後呼叫資源的dump方法列印出所有呼叫的堆疊:
public static traceObject(obj : cc.Asset) {
let addRefFunc = obj.addRef;
let decRefFunc = obj.decRef;
let traceMap = new Map();
obj.addRef = function() : cc.Asset {
let stack = ResUtil.getCallStack(1);
let cnt = traceMap.has(stack) ? traceMap.get(stack) + 1 : 1;
traceMap.set(stack, cnt);
return addRefFunc.apply(obj, arguments);
}
obj.decRef = function() : cc.Asset {
let stack = ResUtil.getCallStack(1);
let cnt = traceMap.has(stack) ? traceMap.get(stack) + 1 : 1;
traceMap.set(stack, cnt);
return decRefFunc.apply(obj, arguments);
}
obj['dump'] = function() {
console.log(traceMap);
}
}