Cocso2d-x v3.11 一项重点改进就是 JSB 新内存模型。这篇文章将专门介绍这项改进所带来的新研发体验和一些技术细节。
1. 成果
在 Cocos2d-x v3.11 之前的版本中,使用 JS 语言发布原生版本的用户可能多少都会遇到一个经典的问题:Invalid Native Object,或者遇到一些莫名其妙的 JS 对象失效的崩溃。而解决这些问题,我们给出的解决方案基本是使用 retain / release 来显式声明持有或释放对象,或者是在脚本层更合理得持有对象索引。而在 v3.11 中,用户不再需要担心这些问题,新的内存模型会更合理得控制原生对象和 JS 对象的生命周期,基本让 C++ 层的对象对用户透明化,不再需要考虑它的存在。
可以说,启用新内存模型后,用户可能根本不会感受到它,但它切实得为用户减少了问题的产生,让开发体验更流畅舒心。
我们针对新内存模型做了很多的测试,目前没有发现任何问题,但是为了避免影响成熟的用户项目,目前新内存模型默认是关闭的,你需要手动开启该功能。开启的方法是在 cocos/base/ccConfig.h 里把 CC_ENABLE_GC_FOR_NATIVE 的值改为1:
#ifdef CC_ENABLE_SCRIPT_BINDING
#ifndef CC_ENABLE_GC_FOR_NATIVE_OBJECTS
#define CC_ENABLE_GC_FOR_NATIVE_OBJECTS 1 // change to 1
#endif
#endif
2. 新内存模型所解决的问题
让我们回到问题本身,之前的内存模型导致问题的根本原因在于:JSB 中的一个 Cocos 对象实际上同时对应一个 C++ 层的 Native 对象和一个脚本层的 JS 对象,而这两个对象的生命周期不完全同步。在 JSB 引擎中有如此设计的原因在于,JSB 的核心层执行在 C++ 中,JS 层提供的是用户接口,为了让用户的 JS 对象接口可以影响到核心层的执行,我们通过 JS 绑定技术维护了 C++ 对象和 JS 对象的一一映射关系,让 JS 对象的接口可以通过绑定层转发给 C++ 层。
而两种对象生命周期的不同步,会引发前文所提到的各种难以调试的问题:
- Invalid Native Object:JS 对象在脚本层仍然被持有,但是其对应的 C++ 对象已经被释放。典型的案例是用removeFromParent 将节点移除出场景,此时 C++ 对象将会被释放,而 JS 对象索引如果仍然被持有,是可以访问的,但是调用任何绑定层提供的接口,都会发现无法找到 C++ 层对象而崩溃。
- 脚本对象丢失:与上一条情况相反,C++ 对象仍然存在,而与它关联的 JS 对象已经被垃圾回收机制回收。这种情况往往可以归因于绑定层没有正确得持有 JS 对象,较为罕见,可以视为绑定层的 bug。
新的内存模型尝试从根本上解决这个问题:同步原生对象和脚本对象的生命周期。
3. 研发历程
其实内存问题从 JSB 诞生之日就存在,解决它的过程经历了几个重要的节点:
- 从 2014 年我们就开始尝试解决这个问题,不过当时遇到了一些 Spidermonkey 脚本引擎中的技术难题未能彻底解决,被搁置。
- 去年底重开这个课题的研究,在切换了几次思路后,终于有了解决方案的雏形。
- Cocos2d 创始人,也是我们的总架构师 Ricardo 介入,从基础上对 JSB 绑定层代码进行了重构,完成了绑定接口的抽象,避免直接使用 Spidermonkey 接口。也在此基础上提供了一种新的解决方案。
- 绑定接口的抽象被合并入 Cocos2d-x v3.9。
- 在 v3.10 中,通过对绑定层的完整检查,我们基本解决了脚本对象丢失的问题。
- 通过多轮测试并稳定后的新内存模型在默认关闭的情况下被合并入 v3.11。
可以看出这套解决方案并不是一蹴而就完成的,它经历了多次迭代和基础框架的重构,我们不能保证它是完美的,但我们很负责任得在做这件事情。如果开发者们遇到任何问题,请反馈给我们,我们会持续迭代,争取让这套新内存模型可靠稳定得运行在用户的 JS 游戏中,并降低游戏的崩溃率,提升开发效率。
4. 基本原理
让我们先看看 v3.10 中的绑定层是如何工作的:
![](https://pic3.zhimg.com/80/028cc34c50b3559be3862b6e6ddd9e7a_hd.png)
这张图展示了一个游戏的场景树在 JSB 中的实际内存结构,左半部分是原生层的 C++ 对象,右半部分是脚本层的 JS 对象。可以看到每个节点以两份对象同时存在于原生层和脚本层,如此设计的原因是:
- 为了让引擎尽可能高效,我们将大多数函数的实现放在了原生层,由原生对象来执行,其编译后的效率远高于 JS。
- 同时,为了让这些接口可以在 JS 层被调用,让用户感受到无缝的 JS 编程体验,原生对象的壳实际上是一个 JS 对象,它的 API 接口被桥接到原生实现上。
基于这样的设计,我们在绑定层保存了原生对象和脚本对象的双向映射关系,然而这还不够,我们还需要保障原生对象和脚本对象生命周期的一致性。在原生层,Cocos2d-x 使用引用计数机制来控制对象生命周期,而在脚本层则依赖 Spidermonkey 的垃圾回收机制。那么下面开始介绍 Cocos2d-x v3.10 和 v3.11 分别是怎么处理生命周期的。
回看上图,其中红色的箭头表示原生对象对脚本对象的引用,这个引用是在 Spidermonkey 中建立的,所以它可以保障原生对象存在时,脚本对象不会被释放。而反过来就不一定了,让我们看看下面的例子:
var scene = new cc.Scene();
cc.director.runScene(scene);
var sprite = new cc.Sprite('role.png');
setTimeout(function () {
// Crash !!! Invalid Native Object
scene.addChild(sprite);
}, 1000);
由于在创建好 sprite 之后,没有立即将它加入到场景中,所以 sprite 的引用计数会在当前帧将为 0 并被释放。而在脚本层,Spidermonkey 却很好得维护了 sprite 的索引,因为在 setTimeout 的回调函数中还引用了它。所以当调用 addChild 的原生层实现时,会发现找不到 sprite 的原生对象了,继而触发 Invalid Native Object 并崩溃。
而在 v3.11 的新内存模型中,我们反其道而行之,由脚本层对象持有原生对象的引用,而仅在脚本对象被垃圾回收的时候才释放原生对象。所以新内存模型也被称为 Full GC Relied Memory Model(完全依赖垃圾回收机制的内存模型)。通过下面这张图可以看到它的基本运作方式:
![](https://pic4.zhimg.com/80/a4080e2c13e5de1a6725c956d88d361b_hd.png)
图中虚线代表脚本对象对原生对象的引用(通过增加引用计数),这样即便从节点树上删除某个节点,它的原生对象也不会被释放。而当脚本对象被垃圾回收的时候,会减少它所引用的原生对象的引用计数,使得原生对象也会被释放。
看起来似乎不会再出现恼人的 Invalid Native Object 了,但不知道大家注意没有,如果排除掉图中红色的箭头,其实只是 v3.10 的反向而已,那么会出现原生层对象还存在,但是脚本对象已经被释放的问题。参考下面的代码:
(function () {
var scene = new cc.Scene();
cc.director.runScene(scene);
var sprite = new cc.Sprite('role.png');
sprite.custom = 'A custom property';
var TAG = 1;
scene.addChild(sprite, 1, TAG);
setTimeout(function () {
cc.sys.garbageCollect();
var sp = scene.getChildByTag(TAG);
// sp.custom will be undefined
cc.log(sp.custom);
}, 1000);
})();
这次在 setTimeout 的回调函数中,经过我们模拟调用垃圾回收,外部的 sprite 由于在 JS 层已经完全不可访问所以被释放了(闭包)。而它的原生对象还被 scene 所引用,所以从 scene 中是可以获取到的(这里涉及绑定层的一个设计,在原生对象对应的脚本对象不存在时,会主动创建一个新的脚本对象),但是已经和外部的 sprite 不是同一个对象了,所以无法获取像 custom 这样的任何自定义属性。
为了解决这个问题,我们将原生层的映射关系复制到了脚本层,也就是上图中红色的箭头部分。在调用 addChild 的时候,有一段特殊代码会给脚本层的 scene 添加一个指向 sprite 的索引,尽管脚本层仍然不知道这个索引的意义是什么,但简单的索引足够解决上面的问题了。
至此,游戏环境中完整的引用关系已经暴露给脚本层的垃圾回收机制,所以依赖垃圾回收机制来控制脚本对象和原生对象的生命周期可以认为是可靠的。
5. 总结
以上就是 v3.11 中新内存模型的基本原理,它能够在绝大多数情况下避免原生对象和脚本对象生命周期不同步的问题。这个方案的核心思路有两点:
- 使用垃圾回收机制同时控制原生对象和脚本对象的生命周期
- 传递原生层的引用关系(比如父子节点引用)给脚本层
当然,新的内存模型也有一个难以避免的问题,那就是它的内存占用往往比旧的版本更高,这点取决于游戏中的内存管理做得如何。所以在 v3.11 中我们同时提供了两种内存模型,可以使用 CC_ENABLE_GC_FOR_NATIVE_OBJECTS 宏来进行切换,默认情况下,引擎使用的是旧内存模型。
对于开发者们,我们给的建议是,如果是已经发布了原生版本的成熟游戏,并且没有遇到对象生命周期引起的崩溃问题,那么可以继续使用旧的内存模型。对于下面的这些情况,我们建议使用新内存模型:
- 新开发的游戏
- 开发者对于 Cocos2d-x 中的内存模型不熟悉
- 开发者对于 C++ 开发不熟悉
- 已有项目中深受 Invalid Native Object 之苦