Cocos Creator 资源加载流程剖析【一】——cc.loader与加载管线

2023-05-25,,

这系列文章会对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;

Cocos Creator 资源加载流程剖析【一】——cc.loader与加载管线的相关教程结束。

《Cocos Creator 资源加载流程剖析【一】——cc.loader与加载管线.doc》

下载本文的Word格式文档,以方便收藏与打印。