這系列文章會對Cocos Creator的資源加載和管理進行深入的剖析。主要包含以下內容:
- cc.loader與加載管線
- Download部分
- Load部分
- 額外流程(MD5 Pipe)
- 從編輯器到運行時
- 場景切換流程
前面4章節介紹了完整的資源加載流程以及資源管理,以及如何自定義這個加載流程(有時候我們需要加載一些特殊類型的資源)。“從編輯器到運行時”介紹了我們在編輯器中編輯的場景、Prefab等資源是如何序列化到磁盤,打包發布之后又是如何被加載到游戲中。
准備工作
在開始之前我們需要解決這幾個問題:
- 如何閱讀代碼?
引擎的代碼大體分為js和原生c++ 兩種類型,在web平台上不使用任何 c++ 代碼,而是一個基於webgl編寫的渲染底層。而在移動平台上仍然使用 c++ 的底層,通過jsb將原生的接口暴露給上層的js。在引擎安裝目錄下的resources/engine下放着引擎的所有js代碼。而原生c++ 代碼放在引擎安裝目錄下的resources/cocos2d-x目錄下。我們可以在這兩個目錄下查看代碼。這系列文章中我們要查看的代碼位於引擎安裝目錄下的resources/engine/cocos2d/core/load-pipeline目錄下。
- 如何調試代碼?
JS的調試非常簡單,我們可以在Chrome瀏覽器運行程序,按F12進入調試模式,通過ctrl + p快捷鍵可以根據文件名搜索源碼,進行斷點調試。具體的各種調試技巧可參考以下幾個教程。
- https://juejin.im/entry/5804669f570c35006c828548
- http://wiki.jikexueyuan.com/project/chrome-devtools/debugging-javascript.html
- https://developers.google.com/web/tools/chrome-devtools/javascript/
原生平台的調試也可以用Chrome,官方的文檔介紹了如何調試原生普通的JS代碼。至於原生平台的C++ 代碼調試,可以在Windows上使用Visual Studio調試,也可以在Mac上使用XCode調試。
- https://docs.cocos.com/creator/manual/zh/publish/debug-jsb.html
- https://docs.cocos.com/creator/manual/zh/publish/debug-native.html
框架結構
首先我們從整體上觀察CCLoader大致的類結構,這個密密麻麻的圖估計沒有人會仔細看,所以這里簡單介紹一下:
- 我們的CCLoader繼承於Pipeline,CCLoader提供了友好的資源管理接口(加載、獲取、釋放)以及一些輔助接口(如自動釋放、對Pipeline的修改)。
- Pipeline中主要包含了多個Pipe和多個LoadingItems,這里實現了一個Pipe到Pipe銜接流轉的過程,以及Pipe和LoadingItems的管理接口。
- Pipe有多種子類,每一種Pipe都會對資源進行特定的加工,后面會對每一種Pipe都作詳細介紹。
- LoadingItems為一個加載隊列,繼承於CallbackInvoker,管理着LoadingItem(注意沒有復數),一個LoadingItem就是資源從開始加載到加載完成的上下文。這里說的上下文,指的是與加載該資源相關的變量的集合,比如當前加載的狀態、url、依賴哪些資源、以及加載完成后的對象等等。
CocosCreator2.x和1.x版本對比,整個加載的流程沒有太大的變化,主要的變化是引入了FontLoader,將Font初始化的邏輯從Downloader轉移到了Loader這個Pipe中。將JSB的部分分開,在編譯時徹底根據不同的平台編譯不同的js,而不是在一個js中使用條件判斷當前是什么平台來執行對應的代碼。其他優化了一些寫法,比如cc.Class.inInstanceOf調整為instanceof,JS.getClassName、cc.isChildClassOf等方法移動到js這個模塊中。
資源加載
CCLoader提供了多種加載資源的接口,要加載的資源必須放到resources目錄下,我們在加載資源的時候,除了要加載的資源url和完成回調,最好將type參數傳入,這是一個良好的習慣。CCLoader提供了以下加載資源的接口:
- load(resources, progressCallback, completeCallback)
- loadRes(url, type, progressCallback, completeCallback)
- loadResArray(urls, type, progressCallback, completeCallback)
- loadResDir(url, type, progressCallback, completeCallback)
loadRes是我們最常用的一個接口,該函數主要做了3個事情:
- 調用_getResUuid查詢uuid,該方法會調用AssetTable的getUuid方法查詢資源的uuid。從網絡上加載的資源以及SD卡中我們存儲的資源,Creator並沒有為它們生成uuid。所以這些不是在Creator項目中生成的資源不能使用loadRes來加載。
- 調用this.load方法加載資源。
- 在加載完成后,該資源以及其引用的資源都會被標記為禁止自動釋放(在場景切換的時候,Creator會自動釋放下個場景不使用的資源)。
proto.loadRes = function (url, type, progressCallback, completeCallback) {
var args = this._parseLoadResArgs(type, progressCallback, completeCallback);
type = args.type;
progressCallback = args.onProgress;
completeCallback = args.onComplete;
var self = this;
var uuid = self._getResUuid(url, type);
if (uuid) {
this.load(
{
type: 'uuid',
uuid: uuid
},
progressCallback,
function (err, asset) {
if (asset) {
// 禁止自動釋放資源
self.setAutoReleaseRecursively(uuid, false);
}
if (completeCallback) {
completeCallback(err, asset);
}
}
);
}
else {
self._urlNotFound(url, type, completeCallback);
}
};
無論調用哪個接口,最后都會走到load函數,load函數做了幾個事情,首先是對輸入的參數進行處理,以滿足其他資源加載接口的調用,所有要加載的資源最后會被添加到_sharedResources中(不論該資源是否已加載,如果已加載會push它的item,未加載會push它的res對象,res對象是通過getResWithUrl方法從AssetLibrary中查詢出來的,AssetLibrary在后面的章節中會詳細介紹)。
load和其它接口的最大區別在於,load可以用於加載絕對路徑的資源(比如一個sd卡的絕對路徑、或者網絡上的一個url),而loadRes等只能加載resources目錄下的資源。
proto.load = function(resources, progressCallback, completeCallback) {
// 下面這幾段代碼對輸入的參數進行了處理,保證了load函數的各種重載寫法能被正確識別
// progressCallback是可選的,可以只傳入resources和completeCallback
if (completeCallback === undefined) {
completeCallback = progressCallback;
progressCallback = this.onProgress || null;
}
// 檢測是否為單個資源的加載
var self = this;
var singleRes = false;
if (!(resources instanceof Array)) {
singleRes = true;
resources = resources ? [resources] : [];
}
// 將待加載的資源放到_sharedResources數組中
_sharedResources.length = 0;
for (var i = 0; i < resources.length; ++i) {
var resource = resources[i];
// 前向兼容 {id: 'http://example.com/getImageREST?file=a.png', type: 'png'} 這種寫法
if (resource && resource.id) {
cc.warnID(4920, resource.id);
if (!resource.uuid && !resource.url) {
resource.url = resource.id;
}
}
// 支持以下格式的寫法
// 1. {url: 'http://example.com/getImageREST?file=a.png', type: 'png'}
// 2. 'http://example.com/a.png'
// 3. 'a.png'
var res = getResWithUrl(resource);
if (!res.url && !res.uuid)
continue;
// 如果是已加載過的資源這里會把它取出
var item = this._cache[res.url];
_sharedResources.push(item || res);
}
// 創建一個LoadingItems加載隊列,在所有資源加載完成后的下一幀執行完成回調
var queue = LoadingItems.create(this, progressCallback, function (errors, items) {
callInNextTick(function () {
if (completeCallback) {
if (singleRes) {
let id = res.url;
completeCallback.call(self, items.getError(id), items.getContent(id));
}
else {
completeCallback.call(self, errors, items);
}
completeCallback = null;
}
if (CC_EDITOR) {
for (let id in self._cache) {
if (self._cache[id].complete) {
self.removeItem(id);
}
}
}
items.destroy();
});
});
// 初始化隊列
LoadingItems.initQueueDeps(queue);
// 真正的啟動加載管線
queue.append(_sharedResources);
_sharedResources.length = 0;
};
初始化_sharedResources之后,開始創建一個LoadingItems,將調用queue.append將_sharedResources追加到LoadingItems中。特別需要注意的地方是,我們的加載完成回調,至少會在下一幀才執行,因為這里用了一個callInNextTick包裹了傳入的completeCallback。
LoadingItems.create方法主要的職責包含LoadingItems的創建(使用對象池進行復用),綁定onProgress和onComplete回調到queue對象中(創建出來的LoadingItems類實例)。
queue.append完成了資源加載的准備和啟動,首先遍歷要加載的所有資源(urlList),檢查已在隊列中的資源對象,如果已經加載完成或者為循環引用對象則當做加載完成處理,否則在該資源的加載隊列中添加監聽,在資源加載完成后執行self.itemComplete(item.id)。
如果是一個全新的資源,則調用createItem創建這個資源的item,把item放到this.map和accepted數組中。綜上,如果我們使用CCLoader去加載一個已加載完成的資源,也會在下一幀才得到回調。
proto.append = function (urlList, owner) {
if (!this.active) {
return [];
}
if (owner && !owner.deps) {
owner.deps = [];
}
this._appending = true;
var accepted = [], i, url, item;
for (i = 0; i < urlList.length; ++i) {
url = urlList[i];
// 已經在另一個LoadingItems隊列中了,url對象就是實際的item對象
// 在load方法中,如果已加載或正在加載,會取出_cache[res.url]添加到urlList
if (url.queueId && !this.map[url.id]) {
this.map[url.id] = url;
// 將url添加到owner的deps數組中,以便於檢測循環引用
owner && owner.deps.push(url);
// 已加載完成或循環引用(在遞歸該資源的依賴時,發現了該資源自己的id,owner.id)
if (url.complete || checkCircleReference(owner, url)) {
this.totalCount++;
this.itemComplete(url.id);
continue;
}
// 還未加載完成,需要等待其加載完成
else {
var self = this;
var queue = _queues[url.queueId];
if (queue) {
this.totalCount++;
LoadingItems.registerQueueDep(owner || this._id, url.id);
// 已經在其它隊列中加載了,監聽那個隊列該資源加載完成的事件即可
// 如果加載失敗,錯誤會記錄在item.error中
queue.addListener(url.id, function (item) {
self.itemComplete(item.id);
});
}
continue;
}
}
// 隊列中的新item,從未加載過
if (isIdValid(url)) {
item = createItem(url, this._id);
var key = item.id;
// 不存在重復的url
if (!this.map[key]) {
this.map[key] = item;
this.totalCount++;
// 將item添加到owner的deps數組中,以便於檢測循環引用
owner && owner.deps.push(item);
LoadingItems.registerQueueDep(owner || this._id, key);
accepted.push(item);
}
}
}
this._appending = false;
// 全部完成則手動結束
if (this.completedCount === this.totalCount) {
this.allComplete();
}
else {
// 開始加載本次需要加載的資源(accepted數組)
this._pipeline.flowIn(accepted);
}
return accepted;
};
如果全部資源已經加載完成,則執行this.allComplete,否則調用this._pipeline.flowIn(accepted),啟動由本隊列進行加載的部分資源。
基本上所有的資源都會有一個uuid,Creator會為它生成一個json文件,一般都是先加載其json文件,再進一步加載其依賴資源。CCLoader和LoadingItems本身並不處理這些依賴資源的加載,依賴加載是由UuidLoader這個加載器進行加載的。這個設計看上去會導致的一個問題就是加載大部分的資源都會有2個io操作,一個是json文件的加載,一個是raw資源的加載。Creator是如何處理資源的,具體可參考《從編輯器到運行時》一章。
Pipeline的流轉
在LoadingItems的append方法中,調用了flowIn啟動了Pipeline,傳入的accepted數組為新加載的資源——即未加載完成,也不處於加載中的資源。
Pipeline的flowIn方法中獲取this._pipes的第一個pipe,遍歷所有的item,調用flow傳入該pipe來處理每一個item。如果獲取不到第一個pipe,則調用flowOut來處理所有的item,直接將item從Pipeline中流出。
默認情況下,CCLoader初始化有3個Pipe,分別是AssetLoader(獲取資源的詳細信息以便於決定后續使用何種方式處理)、Downloader(處理了iOS、Android、Web等平台以及各種類型資源的下載——即讀取文件)、Loader(對已下載的資源進行加載解析處理,使游戲內可以直接使用)。
proto.flowIn = function (items) {
var i, pipe = this._pipes[0], item;
if (pipe) {
// 第一步先Cache所有的item,以防止重復加載相同的item!!!
for (i = 0; i < items.length; i++) {
item = items[i];
this._cache[item.id] = item;
}
for (i = 0; i < items.length; i++) {
item = items[i];
flow(pipe, item);
}
}
else {
for (i = 0; i < items.length; i++) {
this.flowOut(items[i]);
}
}
};
flow方法主要的職責包含檢查item處理的狀態,如果有異常進行異常處理,調用pipe的handle方法對item進行處理,銜接下一個pipe,如果沒有下一個pipe則調用Pipeline.flowOut對item進行流出。
function flow (pipe, item) {
var pipeId = pipe.id;
var itemState = item.states[pipeId];
var next = pipe.next;
var pipeline = pipe.pipeline;
// 出錯或已在處理中則不需要進行處理
if (item.error || itemState === ItemState.WORKING || itemState === ItemState.ERROR) {
return;
// 已完成則驅動下一步
} else if (itemState === ItemState.COMPLETE) {
if (next) {
flow(next, item);
}
else {
pipeline.flowOut(item);
}
} else {
// 開始處理
item.states[pipeId] = ItemState.WORKING;
// pipe.handle【可能】是異步的,傳入匿名函數在pipe執行完時調用
var result = pipe.handle(item, function (err, result) {
if (err) {
item.error = err;
item.states[pipeId] = ItemState.ERROR;
pipeline.flowOut(item);
}
else {
// result可以為null,這意味着該pipe沒有result
if (result) {
item.content = result;
}
item.states[pipeId] = ItemState.COMPLETE;
if (next) {
flow(next, item);
}
else {
pipeline.flowOut(item);
}
}
});
// 如果返回了一個Error類型的result,則要進行記錄,修改item狀態,並調用flowOut流出item
if (result instanceof Error) {
item.error = result;
item.states[pipeId] = ItemState.ERROR;
pipeline.flowOut(item);
}
// 如果返回了非undefined的結果
else if (result !== undefined) {
// 意為着這個pipe沒有result
if (result !== null) {
item.content = result;
}
item.states[pipeId] = ItemState.COMPLETE;
if (next) {
flow(next, item);
}
else {
pipeline.flowOut(item);
}
}
// 其它情況為返回了undefined,這意味着這個pipe是一個異步的pipe,且啟動handle的時候沒有出現錯誤,我們傳入的回調會被執行,在回調中驅動下一個pipe或結束Pipeline。
}
}
flowOut方法流出資源,如果item在Pipeline處理中出現了錯誤,會被刪除。否則會保存該item到this._cache中,this._cache中是緩存所有已加載資源的容器。最后調用LoadingItems.itemComplete(item),這個方法會驅動onProgress、onCompleted等方法的執行。
proto.flowOut = function (item) {
if (item.error) {
delete this._cache[item.id];
}
else if (!this._cache[item.id]) {
this._cache[item.id] = item;
}
item.complete = true;
LoadingItems.itemComplete(item);
};
在每一個item加載結束后,都會執行LoadingItems.itemComplete進行收尾。
proto.itemComplete = function (id) {
var item = this.map[id];
if (!item) {
return;
}
// 錯誤處理
var errorListId = this._errorUrls.indexOf(id);
if (item.error && errorListId === -1) {
this._errorUrls.push(id);
}
else if (!item.error && errorListId !== -1) {
this._errorUrls.splice(errorListId, 1);
}
this.completed[id] = item;
this.completedCount++;
// 遍歷_queueDeps,找到所有依賴該資源的queue,將該資源添加到對應queue的completed數組中
LoadingItems.finishDep(item.id);
// 進度回調
if (this.onProgress) {
var dep = _queueDeps[this._id];
this.onProgress(dep ? dep.completed.length : this.completedCount, dep ? dep.deps.length : this.totalCount, item);
}
// 觸發該id加載結束的事件,所有依賴該資源的LoadingItems對象會觸發該事件
this.invoke(id, item);
// 移除該id的所有監聽回調
this.removeAll(id);
// 如果全部加載完成了,會執行allComplete,驅動onComplete回調
if (!this._appending && this.completedCount >= this.totalCount) {
// console.log('===== All Completed ');
this.allComplete();
}
};
AssetLoader
AssetLoader是Pipeline的第一個Pipe,這個Pipe的職責是進行初始化,從cc.AssetLibrary中取出該資源的完整信息,獲取該資源的類型,對rawAsset類型進行設置type,方便后面的pipe執行不同的處理,而非rawAsset則執行callback進入下一個Pipe處理。其實AssetLoader在這里的作用看上去並不大,因為基本上所有的資源走到這里都是直接執行回調或返回,從Creator最開始的代碼來看,默認只有Downloader和Loader兩個Pipe。且我在調試的時候注釋了Pipeline初始化AssetLoader的地方,在一個開發到后期的項目中測試發現對資源加載這塊毫無影響。
我們調用loadRes加載的資源都會被轉為uuid,所以都會通過cc.AssetLibrary.queryAssetInfo查詢到對應的信息。然后執行item.type = 'uuid',對應的raw類型資源,如紋理會在UuidLoader中進行依賴加載的處理,詳見Load部分。
var AssetLoader = function (extMap) {
this.id = ID;
this.async = true;
this.pipeline = null;
};
AssetLoader.ID = ID;
var reusedArray = [];
AssetLoader.prototype.handle = function (item, callback) {
var uuid = item.uuid;
if (!uuid) {
return !!item.content ? item.content : null;
}
var self = this;
cc.AssetLibrary.queryAssetInfo(uuid, function (error, url, isRawAsset) {
if (error) {
callback(error);
}
else {
item.url = item.rawUrl = url;
item.isRawAsset = isRawAsset;
if (isRawAsset) {
/* 基本上raw類型的資源也不會走到這個分支,經過各種調試都沒有讓程序運行到這個分支下,
因為所有的資源在加載的時候都是先獲取其uuid進行加載的。而沒有uuid的情況基本在這個函數的第一行判斷uuid的時候就返回了。
我還嘗試了直接用cc.loader.load加載resources的資源,直接傳入resources下的文件會報路徑錯誤。
提示的錯誤類似 http://localhost:7456/loadingBar/image.png 404錯誤。
正確的路徑應該是在res/import/...下的,使用使用cc.url.raw可以獲取到正確的路徑。
我將一個紋理修改為RAW類型資源進行加載,並使用cc.url.raw進行加載,直接在函數開始的uuid判斷這里返回了。
另一個嘗試是加載網絡中的資源,然而都在函數開始的uuid判斷處返回了。
所以這段代碼應該是被廢棄的,不被維護的代碼。*/
var ext = Path.extname(url).toLowerCase();
if (!ext) {
callback(new Error(cc._getError(4931, uuid, url)));
return;
}
ext = ext.substr(1);
var queue = LoadingItems.getQueue(item);
reusedArray[0] = {
queueId: item.queueId,
id: url,
url: url,
type: ext,
error: null,
alias: item,
complete: true
};
if (CC_EDITOR) {
self.pipeline._cache[url] = reusedArray[0];
}
queue.append(reusedArray);
// 傳遞給特定type的Downloader
item.type = ext;
callback(null, item.content);
}
else {
item.type = 'uuid';
callback(null, item.content);
}
}
});
};
Pipeline.AssetLoader = module.exports = AssetLoader;