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快捷键可以根据文件名搜索源码,进行断点调试。具体的各种调试技巧可参考以下几个教程。

原生平台的调试也可以用Chrome,官方的文档介绍了如何调试原生普通的JS代码。至于原生平台的C++ 代码调试,可以在Windows上使用Visual Studio调试,也可以在Mac上使用XCode调试。


框架结构

首先我们从整体上观察CCLoader大致的类结构,这个密密麻麻的图估计没有人会仔细看,所以这里简单介绍一下:

  • 我们的CCLoader继承于Pipeline,CCLoader提供了友好的资源管理接口(加载、获取、释放)以及一些辅助接口(如自动释放、对Pipeline的修改)。
  • Pipeline中主要包含了多个Pipe和多个LoadingItems,这里实现了一个Pipe到Pipe衔接流转的过程,以及Pipe和LoadingItems的管理接口。
  • Pipe有多种子类,每一种Pipe都会对资源进行特定的加工,后面会对每一种Pipe都作详细介绍。
  • LoadingItems为一个加载队列,继承于CallbackInvoker,管理着LoadingItem(注意没有复数),一个LoadingItem就是资源从开始加载到加载完成的上下文。这里说的上下文,指的是与加载该资源相关的变量的集合,比如当前加载的状态、url、依赖哪些资源、以及加载完成后的对象等等。

image

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;
posted @ 2019-03-22 10:55  深圳-宝爷  阅读(...)  评论(... 编辑 收藏