本篇为 “引擎的实例化”。
当布置好一个场景并在游戏中显示,或者使用预制体调用 cc.instantiate
实例化出节点时,我们可能有一些同样的疑问:
- 引擎是怎么样实例化的?
- ”优化多次创建“ 和 ”优化单次创建“ 的优化策略有什么不同?
- 在编辑器拖节点是不是要比
getChild
更好? - 怎么样做,实例化时性能会更好?
相对上篇写 AssetManager 的时候我有了一些经验,本篇我希望更好地围绕本篇主题,并从原理和实践两个点进行更全面的叙述,感谢阅读,也请注意,本篇大部分内容是针对预制体的。
本篇涉及的原理以 CocosCreator v2.4 alpha3 的源码为准,本篇内容应该适用于 Creator v2.0 以上版本。
接下来我会以问题为引子,阐述 Cocos 实例化的部分原理。
0.实例化是什么?
如果你不清楚这里谈论的 ”实例化“ 到底指的是什么,这章为你准备。
不管如何,先打好基础,至少使用一次 cc.instantiate
这个函数,知道它大概会发生什么。
本篇说的实例化是指调用 cc.instantiate
从一个预制体创建出游戏中的节点的操作。
你项目中的预制体文件(.prefab)实际上就是一个 JSON 文件,存储着你的一些节点和组件的数据,当游戏运行并读取该预制体文件,引擎会通过这个 JSON 文件数据还原出预制体对象,这个 数据 -> 对象 的过程称为 “反序列化”,但请注意,本文主要讨论的是 “实例化”。
我们不会直接使用这个反序列化后的预制体对象,一般会调用 cc.instantiate
克隆该预制体对象生成一个 cc.Node 节点对象来使用,而这个过程称为 ”实例化“。
“实例化” 一般是指 对象类 -> 对象实例 的过程,虽然 Cocos 的 ”实例化“ 在实现上更像是 “对象深拷贝”,但是两者区别其实不大,所以在这称为实例化。
1.”优化多次创建性能“,“优化单次创建性能”,“自动调整”有什么不同?
在 Creator 中,点击任意一个预制体资源可以在属性检查器面板看到 “优化策略” 的下拉框选项:
预制体如果不手动调整,则默认为 “自动调整”,三个策略的主要区别为:
**自动调整:**实例化时优先走普通实例化的流程,若实例化超过了 3 次,则在之后包括第 3 次会使用 JIT 优化实例化流程。
**优化单次创建性能:**实例化只走普通实例化流程。
**优化多次创建性能:**实例化只走 JIT 优化实例化流程。
也就是在引擎中,”优化单次“ 对应的是普通实例化流程,“优化多次” 对应的是 JIT 实例化流程,接下来会分别叙述这两个流程大概是如何进行的。
2.普通实例化流程是怎么进行的?
这个例子中,我有一个 LoadScene 预制体,我使用 cc.instantiate
想实例化节点,然后加入我的场景中。
let load_scene_prefab: cc.Prefab;
let load_scene_node = cc.instantiate(load_scene_prefab);
this.node.addChild(load_scene_node);
其中 cc.instantiate
函数的部分内容:
var clone;
if (original instanceof CCObject) {
if (original._instantiate) {
clone = original._instantiate();
return clone;
}
// ...
}
clone = doInstantiate(original);
return clone;
1.实例化的多样性
观察代码,引擎实例化时对不同的对象有不同的处理,如果传入的是 CCObject 实例,并且有一个 _instantiate
的实例化函数,就会调用该函数使用独特的实例化过程。
而我传入的预制体是 cc.Prefab 对象,cc.Prefab 继承了 cc.Asset,cc.Asset 继承 CCObject(也叫 cc.Object 类),所以会调用 cc.Prefab 的 _instantiate
函数。
于是可以总结为:
cc.Prefab 包括其它 CCObject 对象都使用不同的实例化函数(如果有的话)。
其它对象使用通用的实例化函数 doInstantiate
进行实例化。
附加小问题:cc.instantiate 可以克隆普通对象吗?
现在是可以的,但要注意一些规则(以下列出部分):
- 除下面特殊规则的对象之外,普通对象,数组,TypedArray 和 DataView 会进行深度拷贝
- cc.Asset 对象,ES6 Map Set,自己的 Class 实例等其余有 constructor 但是
constructor !== Object
的引用对象都不会拷贝 - 对象中双下划线
__
开头的属性不会再添加到克隆对象,但__type__
例外 - 引用原型链和自身都无 hasOwnProperty 函数,却有属性的对象会直接出错(Object.create(null))
先别纠结这些规则的原因,cc.instantiate 的主要用途也不是用来深拷贝,回到我们的主题。
2.预制体的实例化流程
上面提到了不同的 CCObject 对象有不同的实例化函数,但这次只深入 cc.Prefab 的实例化:
上面是 cc.Prefab 实例化关键的几个函数和流程,其中的 doInstantiate
函数和上面第 1 小节提到的
“其它对象使用通用的实例化函数 doInstantiate
进行实例化”
是同一个函数,也就是 cc.Prefab 使用 doInstantiate
函数进行非 JIT 实例化。
3.doInstantiate 函数
调用 cc.instantiate
绝大部分时候都会使用 doInstantiate
函数进行实例化(搜索了一下源码,现在仅有 cc.Prefab,CCComponent,CCBaseNode 有特殊的实例化函数,只有 cc.Prefab 的 JIT 流程是真正替代了实例化过程,其它两个本质上还是调用 doInstantiate
进行实例化,所以现在 cc.instantiate
基本等同 doInstantiate
函数。)
doInstantiate
函数主要流程是:
- 创建一个空对象作为 clone 对象
- 判断被克隆对象是否是 CCClass 对象(引擎内 cc.Node / cc.Component / cc.Prefab 等,使用了 @ccclass 装饰器的类都属于 CCClass 对象)
- 如果是 CCClass 对象走 CCClass 对象克隆函数
- 不是则走普通对象克隆函数
这时候引出两个重要的部分,一个是 CCClass 对象是怎么克隆的,一个是普通对象是怎么克隆的。
4.普通对象克隆
引擎克隆普通对象的深度拷贝实现,使用在原对象上添加属性 _iN$t
存放克隆对象来解决循环引用的问题。
当提供的 obj 对象如下:
class MyClass {
some_prop = 1;
}
let a = {
b: {
c: 1,
map: new Map(),
my: new MyClass()
}
};
let obj = {
a: a,
same_a: a
};
克隆 obj 的主要过程是:
- 创建一个空对象
- 把空对象赋值给 obj 的
_iN$t
属性 - 遍历 obj 的属性,找到第一个属性
a
- 属性
a
的值是一个对象,所以重复“创建一个空对象,遍历属性并拷贝赋值”的过程 - 属性
a
的对象中有属性b
,b
的map
和my
由于是 ES6 Map 和类对象,则不会拷贝直接引用原对象 - 继续遍历 obj 属性,找到第二个属性
same_a
,这个属性引用的是同一个对象 a - 由于是同一个对象 a,而因为第一次拷贝对象 a 的时候,给 a 赋值了属性
_iN$t
防止循环引用,到了第二次拷贝属性same_a
时判断到 a 对象有_iN$t
属性,说明 a 对象被拷贝过,那么就不再次拷贝而是直接返回_iN$t
属性的拷贝对象 - 至此为止,obj 对象就被正确拷贝完成,当你给
cc.instantiate
传入一个普通对象时,它就是这么工作的。
额外的小问题:为什么不支持 ES6 Map/Set,和其它未知构造函数的对象拷贝?
这个我不知道确切答案,但我的想法是:
-
通常使用
cc.instantiate
来实例化节点,而不是拷贝对象,所以其实也可以不完全实现 -
要支持技术上是可以实现的
-
实际使用中基本不会出现因未完全实现拷贝的问题
-
hasOwnProperty 应该还是得做一个容错的
5.CCClass 对象拷贝
CCClass 对象的拷贝其实和普通对象拷贝是差不多的,只不过引擎为了优化和解决某些问题而采用了几个不同的处理:
- 由于当拷贝节点或者组件时,引擎需要且只需要把序列化的数据拷贝,比如下面这个组件代码
@ccclass
export default class Main extends cc.Component {
@property(cc.Prefab)
a_prefab: cc.Prefab = null;
@property(cc.Node)
a_node: cc.Node = null;
}
通常的做法是在组件这里声明一个要序列化的属性,然后在 Creator 中绑定,这样就可以直接在组件代码中使用绑定的数据了。
打印一下 Main Componet 对象:
可以看到序列化的数据(a_node
和 a_prefab
属性)和拷贝的痕迹(_iN$t
属性),但除此之外,还有很多节点运行时的属性,这些其实在实例化时是不需要拷贝的。
为了解决这一问题,引擎在 Main 的构造函数里添加了 __values__
属性:
在拷贝 CCClass 对象时,引擎只会拷贝 __values__
中记录的属性,这就是与拷贝普通对象不同的第一点,在构造函数中添加 __values__
记录序列化属性,解决只拷贝某些属性的需求,这也提高了性能。
2.第二个不同点要引出另外一个问题,如下节点树:
如果 title 节点中,有一个组件声明了一个属性并绑定了 bg 节点,那么想拷贝一个新的 title 节点是否要同时拷贝组件引用的父节点 bg 呢?
答案肯定是不要的,并且这看起来似乎是一个循环引用的问题,但其实不是。再假设这个组件引用的不是父节点 bg 而是 time 节点呢?要拷贝吗?time 节点与 title 节点是两个独立的节点,但你拷贝 title 节点的时候当然也不会想同时拷贝引用的 time 节点。
而引擎解决的方法是判断引用的 cc.Component 或 cc.Node 对象是否是需拷贝的 cc.Node 或 cc.Component 对象的子节点。
if (cc.Class._isCCClass(ctor)) {
if (parent) {
if (parent instanceof cc.Component) {
if (obj instanceof cc._BaseNode || obj instanceof cc.Component) {
return obj;
}
}
else if (parent instanceof cc._BaseNode) {
if (obj instanceof cc._BaseNode) {
if (!obj.isChildOf(parent)) {
// should not clone other nodes if not descendant
return obj;
}
}
else if (obj instanceof cc.Component) {
if (!obj.node.isChildOf(parent)) {
// should not clone other component if not descendant
return obj;
}
}
}
}
clone = new ctor();
}
以上就是拷贝非 CCClass 对象和 CCClass 对象的不同点了。
而看到这,对于实例化预制体时的流程就已经知晓的差不多了,再复习一遍,然后继续来了解一下引擎给出的预制体 JIT 优化方案。
3.实例化时进行的 JIT 优化的原理是什么?
JIT 优化只会在你选择的 “优化策略” 不是 “优化单次创建性能” 的时候才进行,并且部分平台是不支持的,比如微信小游戏。
由于这个原理非常简单,所以不重要的具体实现就直接跳过了,引擎当前进行的 JIT 优化原理大致是:
把整个实例化过程通过字符串拼接,生成一个没有遍历,没有分支的实例化函数。
JS 支持直接执行文本代码,不管是用 eval 或者是 new Function() 都可以实现,这也是为什么部分平台上不支持这个 JIT 优化的原因。
经过 JIT 优化的实例化函数是怎么样的?来看一个例子:
上面是实例化这个 Dialog 预制体的代码,也就是如果预制体被 JIT 优化后,所有上面 doInstantiate
做的一系列判断,遍历,拷贝的实例化操作都会变成赋值操作,而这也很明显更容易被 JavaScript 引擎直接编译,能降低不少的性能消耗。
4.JIT 优化能提升多少性能?
这有一个片面的,不负任何责任的性能测试:
第一次实例化耗时:
JIT: 16 ~ 23ms
Normal: 3 ~ 6ms
Auto: 3 ~ 5ms
Normal 快了 77% / 14ms
多次实例化耗时:
JIT: 1 ~ 3ms
Normal: 3 ~ 6ms
Auto: 1 ~ 3ms
JIT 快了 50% / 3ms
我制作了几种不同复杂度的预制体,通过测试得出的结论是:
勾选 “优化多次创建” 对反复进行实例化的预制体速度提升了 50%,但第一次实例化时由于需要创建 JIT 优化函数,会慢大概 50% 到 100%。
JIT 优化的确更适合需要重复创建的预制体,比如举例子都举烂了的子弹,能让创建速度大大提高。
不过第一次耗时还是挺长的,感觉上可以选择自动把创建 JIT 函数的消耗放在 loadRes 反序列化的时候,对于手动执行 JIT 预优化,引擎也给了接口可以调用:prefab.compileCreateFunction();
,对于有需求的场景可以手动调用预先创建函数。
5.那么在编辑器拖节点要比 getChild
更好?
由于 JIT 优化后,在编辑器拖节点直接就是赋值操作,基本没有可比性,如下图:
这是上面我提到的 Main Component 经过 JIT 优化后的实例化代码,可以看到 a_prefab
和 a_node
属性都是直接赋值为 Creator 上绑定的节点。
所以就只看普通实例化和手动 getChild 绑定节点的不同:
普通实例化
在上面的内容已经了解过,如果在组件上增加一个属性并绑定节点,会在调用 cc.instantiate
时多遍历一次并赋值属性的消耗,消耗的确比较少。
getChild
看一下引擎的代码实现:
getChildByName (name) {
if (!name) {
cc.log("Invalid name");
return null;
}
var locChildren = this._children;
for (var i = 0, len = locChildren.length; i < len; i++) {
if (locChildren[i]._name === name)
return locChildren[i];
}
return null;
}
会对子节点列表进行循环,包括 getComponent
getComponentInChildren
等函数,都是一个数组遍历查找的操作。
对比之下很明显,直接绑定节点的性能的确是会比 getChild 要高,但是提升的多少取决于 getChild 会调用多少层和子节点的数量,这点性能提升是否值得放弃自己习惯的方式呢?我觉得大部分情况下并不值得。
6.要加快实例化的速度,不改引擎能做些什么?
清楚是瓶颈再优化,否则大部分情况下会得不偿失。
假设说打开一个预制体,光实例化就要卡住好几秒,根据引擎实例化原理,可以有以下几点可以加快预制体,欢迎一起讨论:
- 减少组件的属性,减少节点的数量(废话)
- 属性太多的情况,可以考虑不直接绑定节点,也不在
onLoad
中getChild
,而是在用到该属性时再getChild
,分摊性能消耗(Lazy) - 节点太多的情况,分割预制体,每一部分进行分帧载入
7.直接实例化节点或场景会有 JIT 优化吗?
两个暂时都没有,加载场景(loadScene)有点特殊,应该是会重新走一遍反序列化过程而不是实例化过程。
实例化节点的时候虽然有一个 _instantiate
实例化函数,但本质上也是调用 doInstantiate
进行拷贝。
8.动手:给普通节点的实例化添加 JIT 加成
阐述完上面的理论知识过后,接下来做一点轻松的事情,cc.Prefab 既然支持 JIT 优化,那么就利用它来给普通节点做 JIT 函数吧:
// Create
let prefab = new cc.Prefab();
prefab.data = this.a_node;
this.a_node["_prefab"] = prefab; // 解决警告信息,不加这句也可以
prefab.optimizationPolicy = cc.Prefab.OptimizationPolicy.MULTI_INSTANCE;
prefab.compileCreateFunction();
// Test
let clone_node = cc.instantiate(prefab);
this.node.addChild(clone_node);
clone_node.y += 150;
// Destroy
this.a_node["_prefab"] = null;
prefab.data = null; // prefab destroy 时会销毁 data 的节点,视情况置空
prefab.destroy();
效果有了,本篇内容就到此结束了。