此时官方还未发布 2.4.0 版本,但在代码仓库可以看到 cc.loader 被重构为全新的 cc.AssetManager,本系列专门解读有关 AssetManager 的一些特性和原理,第一篇我会先以自己的理解做一个总结,后面再挑选几个有意思的点进行深入。
着重提醒
如本篇涉及到引擎源码,请勿在意其具体实现,在 #6560 的 PR 可以看到代码正在被优化修改,而在本文章发布的时候,优化已经正式合并到 2.4.0 分支了。
0.总结
在 cc.loader 的时期我们通过 load / loadRes 加载资源,通过 release 释放资源,loader 自带了 subpackage 和版本控制(md5)使得在刚开始的开发中完全不用关心资源加载这方面的实现。
但后来,有关于资源和加载的问题会接踵而至:
- 简陋的资源释放,引擎自带的「场景切换自动释放资源」适用范围很小不办事
- 加载资源时下载和反序列化引起的卡顿不可控
- 编译生成的 json / 脚本文件数量不可控
- subpackage 仅仅是一个适配小游戏平台的功能
- 定制加载/解析/下载流程时不太友好
而替代者 cc.AssetManager 在源码中已经可以看到了不小的改进和变化,看起来包括了以下几点:
- 不仅是加载/解析/下载流程,包括整个实现都更清晰,标准化和模块化
- 可控的下载并发数
- Asset Bundle (资源包)
- 引擎资源计数与释放
接下来我会以问题为引子,阐述一些 AssetManager 的特性和原理。
1.资源是怎么加载的?
AssetManager 通过管线(Pipeline)的概念进行资源加载,可以理解为多条管道(Pipe)组成的一条管线,加载请求作为一个任务(Task)在管线中流动,就像产品在流水线进行组装。
假设调用函数传入一个 URL 进行资源加载时,加载请求会经过以下流程:
1.把 URL 加载请求封装成 Task 实例
Task 是一个类,当调用函数加载时,会创建一个 Task 实例,URL 参数赋值在 Task 实例的 input 属性,然后传入管线进行处理,Task 除了 input 属性外,还有 output,options 和一系列回调加载进度的函数。
列出这三个属性:input,output,options 是因为这些是它的重点:
1.input/output
管道函数会读取 input 属性中的加载请求进行处理,并将处理结果赋值到 output 属性,完成一次对 Task 的处理,当这个管道函数处理完毕,管线会将 Task 的 output 属性赋值给 input 属性,然后调用下一个管道函数对任务进行处理,管线加载流程中数据的接力就通过这两个属性实现。
2.options
AssetManager 允许你在加载资源的时候传入一些选项,options 属性就记录着这些选项。
加载多个资源时,可以传入全局 options,也可以对单独的请求传入 options,以实现不同的加载方式。
比如加载一个特别的资源,该资源如果 options 中 encrypt 属性为 true,管道函数可以检查到该属性则会对该资源进行自定义的解密操作。
要注意的是保留关键字还挺多的,命名不要与保留关键字冲突了。
2.经过 preprocess 预处理
上面 AssetManager 把任何加载请求封装成了 Task,下面就是把 Task 放入管线进行加载。
var task = new Task({input: requests, onProgress, onComplete: asyncify(onComplete), options});
pipeline.async(task);
首先经过的是预处理管道,而预处理管道的作用是:
1.筛选指定的 options 属性
2.调用 transformPipeline(URL 转换管线)对请求进行格式化
也就是 preprocess 管道唯一的任务就是调用 transformPipeline 对请求进行处理。
3.经过 transformPipeline 处理
注意这个是 Pipeline 而不是 Pipe,上面讲到 preprocess 管道唯一的任务就是调用这个转换管线进行处理。
首先注意这是管线,不是管道(上面我做 XMind 图的时候没注意到写成了管道),这时候可能就有疑问:
如果是一个管道函数调用另一个管线(或者是另一个管道),那么依照管线设计为什么不把这个管线所有管道放在这个管道后面?
答案是我不知道。猜测是为了复用。
转换管线做的事情主要有以下几点:
(为了更好地理解,先回忆一下举例子的时候,加载资源的时候提供的是 URL,但实际上并不是只能提供 URL,还可以传入目录,路径,场景名或者 UUID)
1.对传入的加载请求进行解析,并使用 RequestItem 封装(parse pipe)
转换管线会为不同类型的参数使用不同的转换策略,如果忽略实现细节,传入 URL 管线会直接保留不进行任何处理,传入目录则会寻找目录下的资源并加入加载请求,传入场景名则会查找资源包(Bundle)标准化请求。
按不同解析方式处理后,会通过 RequestItem 类对所有加载请求进行封装,并 push 到 Task 的 output 数组中。
什么是 RequestItem ?
可以把 RequestItem 理解为格式化好的加载请求实例:
// RequestItem 类的一些属性(代码经过简化)
function RequestItem () {
this._id = '';
this.uuid = '';
this.url = '';
this.ext = '.json';
this.content = null;
this.file = null;
this.info = null;
this.config = null;
this.isNative = false;
this.options = Object.create(null);
}
// 假设我们通过 url 进行加载,transformPipeline 会进行如下转换(只是比方)
let url = "https://zerobye.com/a.zip"; // before
let item = { // after
url: "https://zerobye.com/a.zip",
uuid: "https://zerobye.com/a.zip",
ext: ".zip",
isNative: true
};
2.版本控制(combine pipe)
转换管道另一个任务就是把加载请求做版本控制处理,现在实现的原理应该都耳熟能详,就是在文件名后面加上一串 md5 值。
也就是在理论情况下,我们可以移除引擎自带的版本控制管道,加入我们自己的版本控制管道函数,就可以轻松进行定制了。
4.经过 load 完成资源加载
如果你阅读了上面的内容,会发现实际上加载管线的流程整理出来是:
在预处理管道的帮助下(实际上干活的是转换管线),传入的资源加载 URL 被转换为了 RequestItem 对象,经过转换后,Task 的 output 属性是一个 RequestItem 对象数组,该数组会发给最后一个管道处理:
1.对每个 RequestItem 都创建一个单独的 Task(子任务)并传给 loadOneAssetPipeline 处理
也就是 load 管道函数实际上做的是调用 loadOneAssetPipeline 对每个 RequestItem 进行并行处理,loadOneAssetPipeline 有两个管道函数:fetch(下载) 和 parse(解析)。
为什么创建子任务用 loadOneAssetPipeline 处理?猜测两点:
- 避免 load 实现太复杂
- 每个任务单独推入管线可以并行执行“下载”和“解析”两个处理,若没有另外的管线,就必须线性先下载所有加载请求,再解析所有下载请求。
2.fetch
fetch 管道函数使用 packManager 处理 RequestItem,而 packManager 则是调用 downloader 进行下载资源,然后处理每个 RequestItem。
packManager 是什么?
它有两个用途:
1.调用 downloader 下载
2.解包合并后的 .json 文件
官方的注释:处理打包资源,包括拆包,加载,缓存等等
所以理论上也可以注册自己的解包函数对资源进行解包
3.parse
parse 管道函数作为最后资源加载的最后一个管道,执行了以下处理:
- 调用 parser 解析资源数据(parser 可以理解为把不同格式的文件解析为可用的内存数据)
- 调用 loadDepends 函数获取并使用加载管线加载依赖的其它资源
至此,大致的资源加载流程就浏览完了,回到问题:资源是怎么加载的?
总结为通过 load 管线,对请求进行:预处理 - 版本控制 - 下载 - 解包 - 解析 完成加载。
2.动态加载脚本和资源包(Bundle)怎么实现和使用?
这个就算我浏览代码也不能很准确地解答,因为引擎部分只有对资源包操作的代码,而资源包实际长什么样是在 Creator 实现的,我也不想去仔细研究反推出来,所以只猜测有以下几点:
- 资源包以一个目录为单位,设置某个目录为一个资源包
- 内置资源是一个 Bundle,resources 目录是一个 Bundle,包体是一个 Bundle,如果开启首场景分包,首场景内容会放在一个 Bundle(推断依据:shared.js)
- resources 目录在之前的作用是动态加载资源,在里面的资源不会被剔除,而 Bundle 的概念实际上就是旧版中的 resources(新版 loadRes 改为 cc.resources.load,cc.resources 实际上就是一个 Bundle 实例)
- 可以统一加载/释放包内资源,也可以单独加载包内某个资源
- AssetManager 除了支持动态加载脚本文件,Bundle 内也支持脚本文件进行分包载入(小游戏平台用 subpackage 进行 adapte Bundle 可以支持脚本分包,但单独动态加载脚本应该暂时不行)
3.能否解决下载和反序列化引起的卡顿?
可以在 downloader 中看到支持设置下载的最大并发数和每帧最大启动数
对于序列化,可以看到 AssetManager 新增了 preload 接口,preload 与 load 不同的是只会对资源进行下载,而不会进行反序列化和初始化工作,于是我们可以在游戏过程中下载资源,在真正使用到的时候再进行反序列化。
4.引擎的引用计数是怎么实现的?
对于引用计数的问题,也只能像资源包一样分点写出我已知的内容:
-
引擎中 dependUtil.js 和 finalizer.js(最新更名为 releaseManager.js) 两个文件分别负责依赖管理和释放管理。
-
CCAsset 是 Cocos 引擎的资源基类,该类中有 addRef 和 decRef 两个方法对资源进行增加引用和减少引用计数。
-
引擎只会对静态引用增加引用计数(用户动态加载的不会)。
-
cc.AssetManager 提供了三个方法来释放资源,分别是 releaseUnusedAssets,releaseAsset,releaseAll。
-
releaseUnusedAssets 只会释放掉没有引用计数的资源。
-
releaseAsset 会直接释放传入的资源实例,但该资源依赖的其它资源会先减少引用计数再判断是否没有引用再释放。
-
releaseAll 等于对所有资源遍历执行 releaseAsset。
应该在不久后 2.4.0 测试版本就会放出,本系列文章会跟进更新最新的内容,而本篇就到此结束了。