当前位置 博文首页 > Cocos Creator 新资源管理系统剖析
v2.4开始,Creator使用AssetBundle完全重构了资源底层,提供了更加灵活强大的资源管理方式,也解决了之前版本资源管理的痛点(资源依赖与引用),本文将带你深入了解Creator的新资源底层。
在了解引擎如何解析、加载资源之前,我们先来了解一下这些资源文件(图片、Prefab、动画等)的规则,在creator项目目录下有几个与资源相关的目录:
资源管理器
在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目录并没有为目录生成任何东西。
在项目构建之后,资源会从library目录下移动到构建输出的build目录中,基本只会导出参与构建的场景和resources目录下的资源,及其引用到的资源。脚本资源会由多个js脚本合并为一个js,各种json文件也会按照特定的规则进行打包。我们可以在Bundle的配置界面和项目的构建界面为Bundle和项目设置
导入编辑器的每张图片都会对应生成一个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
场景资源与Prefab资源非常类似,都是一个描述了所有节点、组件等信息的json文件,在勾选内联所有SpriteFrame
的情况下,Prefab引用到的SpriteFrame会被合并到prefab所在的json文件中,如果一个SpriteFrame被多个prefab引用,那么每个prefab的json文件都会包含该SpriteFrame的信息。而在没有勾选内联所有SpriteFrame
的情况下,SpriteFrame会是单独的json文件。
当Creator将多个资源合并到一个json文件中,我们可以在config.json中的packs字段找到被打包
的资源信息,一个资源有可能被重复打包到多个json中。下面举一个例子,展示在不同的选项下,creator的构建规则:
下面是按不同规则构建后的文件,可以看到,无压缩的情况下生成的文件数量是最多的,不内联的文件会比内联多,但内联可能会导致同一个文件被重复包含,比如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。
Asset Bundle是creator 2.4之后的资源管理方案,简单地说就是通过目录来对资源进行规划,按照项目的需求将各种资源放到不同的目录下,并将目录配置成Asset Bundle。能够起到以下作用:
Asset Bundle的创建非常简单,只要在目录的属性检查器
中勾选配置为bundle
即可,其中的选项官方文档都有比较详细的介绍。
其中关于压缩的理解,文档并没有详细的描述,这里的压缩指的并不是zip之类的压缩,而是通过packAssets的方式,把多个资源的json文件合并到一个,达到减少io的目的。
在选项上打勾非常简单,真正的关键在于如何规划Bundle,规划的原则在于减少包体、加速启动以及资源复用。根据游戏的模块来规划资源是比较不错的选择,比如按子游戏、关卡副本、或者系统功能来规划。
Bundle会自动将文件夹下的资源,以及文件夹中引用到的其它文件夹下的资源打包(如果这些资源不是在其它Bundle中),如果我们按照模块来规划资源,很容易出现多个Bundle共用了某个资源的情况。可以将公共资源提取到一个Bundle中,或者设置某个Bundle有较高的优先级,构建Bundle的依赖关系,否则这些资源会同时放到多个Bundle中(如果是本地Bundle,这会导致包体变大)。
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:
// 当复用其他项目的 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) => {
// ...
});
其它注意项:
v2.4重构后的新框架代码更加简洁清晰,我们可以先从宏观角度了解一下整个资源框架,资源管线是整个框架最核心的部分,它规范了整个资源加载的流程,并支持对管线进行自定义。
__depends__
属性cc.assetManager.builtins
访问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);
接下来我们看一下一个普通的资源是如何加载的,比如最简单的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);
transformPipeline由parse和combine两个管线组成,parse的职责是为每个要加载的资源生成RequestItem对象并初始化其资源信息(AssetInfo、uuid、config等):
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;
}
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:
task.options.__exclude__
中,则标记为完成,并添加引用计数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);
});
}
}
}
}
]);
creator使用packManager.load
来完成下载的工作,当要下载一个文件时,有2个问题需要考虑:
// 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);
}
}
});
});
}