天天看點

Cocos Creator 資源加載流程剖析【一】——cc.loader與加載管線

這系列文章會對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;