解读 Cocos Creator 引擎:让实例化快 50% 的原理,“拖节点”性能会更好吗?


本篇为 “引擎的实例化”。

当布置好一个场景并在游戏中显示,或者使用预制体调用 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 函数主要流程是:

  1. 创建一个空对象作为 clone 对象
  2. 判断被克隆对象是否是 CCClass 对象(引擎内 cc.Node / cc.Component / cc.Prefab 等,使用了 @ccclass 装饰器的类都属于 CCClass 对象)
  3. 如果是 CCClass 对象走 CCClass 对象克隆函数
  4. 不是则走普通对象克隆函数

这时候引出两个重要的部分,一个是 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 的主要过程是:

  1. 创建一个空对象
  2. 把空对象赋值给 obj 的 _iN$t 属性
  3. 遍历 obj 的属性,找到第一个属性 a
  4. 属性 a 的值是一个对象,所以重复“创建一个空对象,遍历属性并拷贝赋值”的过程
  5. 属性 a 的对象中有属性 bbmapmy 由于是 ES6 Map 和类对象,则不会拷贝直接引用原对象
  6. 继续遍历 obj 属性,找到第二个属性 same_a ,这个属性引用的是同一个对象 a
  7. 由于是同一个对象 a,而因为第一次拷贝对象 a 的时候,给 a 赋值了属性 _iN$t 防止循环引用,到了第二次拷贝属性 same_a 时判断到 a 对象有 _iN$t 属性,说明 a 对象被拷贝过,那么就不再次拷贝而是直接返回 _iN$t 属性的拷贝对象
  8. 至此为止,obj 对象就被正确拷贝完成,当你给 cc.instantiate 传入一个普通对象时,它就是这么工作的。

额外的小问题:为什么不支持 ES6 Map/Set,和其它未知构造函数的对象拷贝?

这个我不知道确切答案,但我的想法是:

  1. 通常使用 cc.instantiate 来实例化节点,而不是拷贝对象,所以其实也可以不完全实现

  2. 要支持技术上是可以实现的

  3. 实际使用中基本不会出现因未完全实现拷贝的问题

  4. hasOwnProperty 应该还是得做一个容错的


5.CCClass 对象拷贝

CCClass 对象的拷贝其实和普通对象拷贝是差不多的,只不过引擎为了优化和解决某些问题而采用了几个不同的处理:

  1. 由于当拷贝节点或者组件时,引擎需要且只需要把序列化的数据拷贝,比如下面这个组件代码
@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_nodea_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_prefaba_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.要加快实例化的速度,不改引擎能做些什么?

清楚是瓶颈再优化,否则大部分情况下会得不偿失。

假设说打开一个预制体,光实例化就要卡住好几秒,根据引擎实例化原理,可以有以下几点可以加快预制体,欢迎一起讨论:

  • 减少组件的属性,减少节点的数量(废话)
  • 属性太多的情况,可以考虑不直接绑定节点,也不在 onLoadgetChild,而是在用到该属性时再 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();


效果有了,本篇内容就到此结束了。

64赞

虽然看不懂 但还是要抢个沙发。

1赞

谢谢,“重在参与”

1赞

:smile_cat: 大佬厉害 :cow2::+1:

我记个笔记。

结论

直接绑定节点的性能的确是会比 getChild 要高,但是具体看改动多不多。

方案

确定瓶颈再优化,否则大部分情况下会得不偿失。

  • 减少组件的属性,减少节点的数量(废话)
  • 属性太多的情况,可以考虑不直接绑定节点,也不在 onLoad 中 getChild,而是在用到该属性时再 getChild,分摊性能消耗(Lazy)
  • 节点太多的情况,分割预制体,每一部分进行分帧载入

:rose:

4赞

:ox::beer:
学到好多

其实这点损耗完全可以忽略不记。。只要不是频繁的getChild 没啥太大区别

3赞

辛苦了。
mark一下。

instantiate无非就是通过json(prefab)创建node
我们用vscode打开prefab可以看到是一个数组。而不是树状结构。node的parent属性为一个int值,其实就是数组的index。通过拖拽到MyClass的节点。其实也就是记录了这个index(猜测)。getChild还需要foreach 比较字符串。
所以要求效率的场景不建议大量getChild

正式打包是没有的哦

关于性能的帖子,必须顶一下

以前经常用getChild,看来要改掉这习惯了:grin:

1赞

感谢楼主的测试数据
并不要脸的贴个链接

1赞

挺有深度的分析。理论上我们优化后的实例化,性能会比开发者手动用 API 手动复制节点更快,因为手动调用 API 访问的都是公有 API,公有 API 没办法批量初始化。
实际上 Creator 的黑科技还不少,有兴趣还可以看看引擎 master 分支上新版的反序列化代码。

11赞

很有深度啊

讲的很详细

mack一下

我对反序列话这块比较好奇,可以具体分享一下这块如何实现的吗?

1赞

这篇文章很有深度,可以,赞。

好————