腾讯企鹅辅导使用 Cocos Creator 实现课中互动练习。内嵌 Cocos 引擎的方式二次启动v8引擎会有报错,因为 v8 引擎在同一个进程中只能初始化一次。所以,在 Android 平台上,我们将 Cocos 引擎跑在单独的一个进程上,关闭 Cocos 只需销毁进程,不存在内存泄漏问题。问题出在 iOS 平台上,因为 iOS 无法使用多进程,Cocos 引擎只能跑在主进程,每次关闭习题,我们切到一个空场景(场景中没有节点),理想情况下,这样做可以将游戏资源的内存释放掉。但是现实很残酷,内存泄漏还是发生了。
故事要从几周前说起,测试同学在群里发出了 PerfDog 性能测试报告。
性能测试报告
内存曲线开始的位置是打开 App,可以看到此时的内存是 126M。接下来进入直播间后,内存涨到了 252M。后面测试同学打开了自动化发题的脚本,间隔 30s 左右发一道互动练习。内存曲线的每次凸起,就表示打开一次互动练习。关闭练习后,内存会回落。第一次练习过后,内存比没打开习题时之前略高是正常的,因为 Cocos 引擎没有关闭,只是切到了空场景,Cocos 引擎本身需要占据略多于 100M 的内存。但是后面每次习题之后,内存都在一点点增长,这明显存在内存泄漏,我陷入了沉思……
实际上,在此之前我们已经对内存进行了一波优化了,当时的问题更加严峻。刚开始内嵌Cocos 引擎时,内存高得惊人,尤其是在龙骨动画(Cocos 实现复杂动画的一种方式)比较多的场景中,OOM(Out of memory)导致 crash 的概率很大。我们采取了很多的方式去优化内存,包括纹理压缩、低端机使用分辨率更小的图片以及去掉不必要的动画、龙骨动画降为2倍图、龙骨动画分拆以便于动态加载与释放、节点池等。下图是优化前的内存曲线,虽然优化后的代码存在内存泄漏,后面内存越来越高,但是平均内存仍然比优化前要少 100M 左右。
优化前的内存曲线
之前的内存占用虽然总体比较高,但是看起来并不存在内存泄漏,所以内存泄漏应该是内存优化带来的问题。如果能解决内存泄漏,平均内存占用可以降到 350M 左右,比起优化前内存将降低 200M。那么问题出在哪里呢?
直觉告诉我大概率是切换到空场景时,前面场景的资源没释放干净。但是我们已经将所有场景的自动释放资源勾选上了,这样场景中的静态资源(可以理解为场景初始化时就会加载的资源)都会在场景释放后被释放。
对于动态加载的资源,使用 cc.loader.setAutoReleaseRecursively(src, true)
,也可以让 src
对应的资源在场景切换后自动释放。当然配置了这些也不能够说明资源内存一定就被自动释放了,说不定 Cocos 引擎本身存在什么 bug 呢,所以还是需要借助调试工具来进行辅助定位。
Cocos 官方提供了 Chrome 调试工具,需要在构建时勾选调试模式。
这样将会生成开启调试模式的代码,因为调试模式下 COCOS2D_DEBUG 这个宏被定义为 1。
代码语言:javascript复制#if defined(COCOS2D_DEBUG) && COCOS2D_DEBUG > 0
#define SE_ENABLE_INSPECTOR 1
#define SE_DEBUG 2
#else
#define SE_ENABLE_INSPECTOR 0
#define SE_DEBUG 0
#endif
找 App 端的同学打入开启了调试模式的 Cocos 引擎后,就可以通过 Chrome 调试工具对 JS 进行调试了。使用调试功能的具体步骤请见:远程调试与-profile。
首先要确定在每次切空场景后,cc.loader._cache
中是不是还有缓存的资源。使用 console.log
打印出来后发现切到空场景后,cc.loader._cache
只存在 Cocos 内置的一些资源,业务的资源已经被清除。
再使用 memory
工具进行分析,发现在空场景中,JS 的堆内存一直维持在 28M,所以可以断定内存泄漏并不发生在 JS 层。
分析到这里,我有点想当然了。既然通过调试工具分析,JS 层没有内存泄漏,而引擎底层的 C 层其实只是提供给 JS 侧的渲染层。JS 层的资源都销毁了,也不会再渲染,那么 C 层理论上是不会有什么泄漏的。加上我们发现内存泄漏只会发生在某个场景的特定条件下,这个场景就是 1v1PK
口语题。1v1PK 口语题指的是企鹅辅导课中互动的一种形式,老师发起 1v1PK 后,学生两两匹配进行英语口语 PK。但是有时学生可能无法匹配到对手,例如只有一名学生在线的情况,这时就不会展示对手。
内存泄漏就是发生在1v1PK
口语题对手存在的情况下。对手存在的情况,对于 Cocos 侧来说,并没有什么特殊的区别,因为有对手无非是多了一个对手视频显示,而对手的视频是 iOS 端原生实现的。所以我开始怀疑是 iOS 端的这个视频导致的泄漏问题。
然而事情并没有那么简单,iOS 端的同学通过 Xcode 的内存分析工具,发现每次的内存增量发生在一个 Cocos 引擎层的 Texture2D
类的 setImage
方法中。
setImage方法导致
此时我还是有点不太相信这个分析结果,前面分析 JS 内存发现资源内存都被释放了,那么作为渲染层的 C ,为何会泄漏,而且现象上确实是多了一路对手视频,才会出现内存泄漏的。
接下来 iOS 端的同学注释掉 setImage
方法,测试了一下,发现内存泄漏的情况消失了,说明 Cocos 引擎 C 层的 setImage
方法出现了内存泄漏是板上钉钉的事。
注释掉后消失
排查到了这里,已经不得不深入到引擎内部进行分析了。
内存泄漏发生 Texture2D
类的 setImage
方法,说明是纹理 (texture)相关的方法。如果了解过 OpenGL 或者 WebGL,应该知道纹理的作用,就是用来给图形”贴皮肤”用的,这里的皮肤其实就是图片,所以 Cocos 中和图片渲染相关的基本都会用到 Texture2D
这个类。我通过搜索代码,找到 setImage
方法的实现。
void Texture2D::setImage(const ImageOption& option)
{
const auto& img = option.image; // 从参数中取出图片数据
// 省略部分无关代码
if (_compressed)
{
// 渲染压缩格式图片,与else分支相似,略去
}
else
{
GL_CHECK(glTexImage2D(GL_TEXTURE_2D,
... // 省略无关参数
img.data));
}
}
幸好这段代码并不长,我们也不需要完全看懂,只需要要推断一下到底哪里申请了内存,因为在 C 中是没有垃圾自动回收机制的,内存要自己显式申请,显式释放。setImage
的主要作用就是将 JS 侧的传过来图片数据处理一下,然后丢给 OpenGL 进行渲染,图片的数据在 JS 中是 Uint8Array
,在 C 层使用一个 uint8_t
指针直接指向图片数据的内存。所以图片数据从 JS 层到 C 其实不需要复制,C 层中读取的图片数据只是 JS 层内存数据的引用。略去其他无关的处理代码,最终只剩下了调用 OpenGL 渲染图片数据比较可疑了。
glTexImage2D(
GL_TEXTURE_2D,
... // 省略无关参数
img.data
)
因为之前学习过一点 OpenGL,知道 OpenGL 绑定纹理的大致流程。
- 通过
glGenTextures
创建一个 texture 对象。 - 通过
glActiveTexture
激活纹理单元。 - 通过
glBindTexture
绑定纹理对象。 - 通过
glTexImage2D
写入纹理数据。
关键在于 glTexImage2D
方法,实际上会把纹理数据写到显卡的内存,也就是显存里,方便 GPU 去读取。
如果是写到显存里,为啥内存会增加呢?这是因为 iPhone 上 CPU 和 GPU 的 memory 是共享的,没有独立的显存,这就是 setImage
中会申请内存的原因了。
释放纹理内存,需要调用 glDeleteTextures
,它是在哪里被调用的呢?
在回答这个问题之前,我们先来了解,在 C 中实现的 Texture2D
类,是怎么注册给到 JS 调用的。
我从 Cocos 官方文档了解到,可以通过 Cocos 提供 JSB 绑定往 JS 层注册 C 实现的类,我们来看 C 中往 JS 引擎定义Text2D
类的相关代码:
// js_register_gfx_Texture2D方法往js引擎注册Texture2D类,se是script engine的缩写,顾名思义是js引擎对象
bool js_register_gfx_Texture2D(se::Object* obj)
{
// 这里往js引擎注册了Texture2D类,__jsb_cocos2d_renderer_Texture_proto是个空指针,就是不指定类的原型,c 中的方法js_gfx_Texture2D_constructor在js中new Texture2D的时候会被调用。
auto cls = se::Class::create("Texture2D", obj, __jsb_cocos2d_renderer_Texture_proto, _SE(js_gfx_Texture2D_constructor));
...// 略去部分无关代码
// 在类原型上定义了updateNative方法,实际上会调用c 中的js_gfx_Texture2D_update方法
cls->defineFunction("updateNative", _SE(js_gfx_Texture2D_update));
// js_cocos2d_renderer_Texture2D_finalize会在js中的Texture2D类实例被销毁时调用
cls->defineFinalizeFunction(_SE(js_cocos2d_renderer_Texture2D_finalize));
... // 略去部分无关代码
}
C 层定义 JS 中的类,不仅可以在 JS 中类实例化的时候,执行一个构造函数 js_gfx_Texture2D_constructor
,还可以在类实例被 JS 引擎的垃圾收集器回收之后,执行一个清理函数 js_cocos2d_renderer_Texture2D_finalize
。
这里只展示了 updateNative
的定义,其对应的 C 函数为 js_gfx_Texture2D_update
,我们来看下它的定义:
static bool js_gfx_Texture2D_update(se::State& s)
{
cocos2d::renderer::Texture2D* cobj = (cocos2d::renderer::Texture2D*)s.nativeThisObject(); // 获取c 中的Texture2D对象
... // 略去部分代码
const auto& args = s.args(); // 获取参数
size_t argc = args.size(); // 参数数量
... // 略去部分代码
if (argc == 1) {
cocos2d::renderer::Texture::Options arg0;
... // 略去部分代码
cobj->update(arg0); // 调用c 中Texture2D对象的update方法
return true;
}
... // 略去部分代码
}
可以看到 js_gfx_Texture2D_update
实际上会调用 C 中 Texture2D
对象的 update
方法,而 update
方法会调用 updateImage方法
,updateImage
方法最终调用了 setImage
方法,也就是存在内存泄漏的方法,这里就不再展示具体的代码了,有兴趣的同学可以自己去扒代码。
前面提到 js_cocos2d_renderer_Texture2D_finalize
是 JS 中 Texture2D
对象被 JS 引擎的垃圾收集器回收后调用的函数,纹理内存很可能就是在这里被销毁的,这是 js_cocos2d_renderer_Texture2D_finalize
的定义:
static bool js_cocos2d_renderer_Texture2D_finalize(se::State& s)
{
...// 略去部分代码
cocos2d::renderer::Texture2D* cobj = (cocos2d::renderer::Texture2D*)s.nativeThisObject(); // 拿到对应c Texture2D对象
cobj->release(); // 调用对象的release方法
return true;
}
Texture2D
类的 release 方法,继承自祖先类 Ref
, release 方法最终会执行:
delete this;
从而销毁类实例。类实例被销毁,会走到析构函数,Texture2D
中析构函数是空的,只有一行注释。
Texture2D::~Texture2D()
{
// RENDERER_LOGD("Destruct Texture2D: %p", this);
}
答案在 Texture2D
类的父类 Texture
的析构函数中,终于看到了纹理被销毁了:
Texture::~Texture()
{
if (_glID == 0)
{
RENDERER_LOGE("Invalid texture: %p", this);
return;
}
glDeleteTextures(1, &_glID);
}
从上面的分析可以看出,当 JS 中的 Texture2D
对象被销毁后,C 中对应的原生对象也被销毁,这种情况是不会存在内存泄漏的。
那么内存泄漏的原因,可以锁定为 JS 引擎中存在没有被垃圾收集器回收的 Texture2D
对象,导致 C 中对应的对象没有走到析构逻辑。
根据前面的分析,一个 JS 中的 Texture2D
对象,对应一个 C 中的 Texture2D
原生对象,JS 对象的销毁会使得 Texture2D
原生对象被销毁,所以理论上我们通过内存分析工具中的内存快照功能,就能分析出有哪些 Texture2D
对象泄漏了。
Texture2D对象
然而无论我怎样切换场景,都发现在空场景下,Texture2D
对象都只有固定的4个,这几个 Texture2D
都是内置的纹理对象,我们在场景中新建的 Texture2D
对象看起来全部都被释放了,那么理论上 C 层中的 Texture2D
对象不会有残留。
看来光是分析代码有点走不通了,要结合现象来看。前面提到出现内存泄漏的场景在于口语 PK 游戏中有对手的情况,没有对手的情况下并没有泄漏。仔细对比了两者的差异后我发现,在有对手的情况下,测试同学用来发题目的脚本,总是在播放自己录音后,才关闭题目。而没有对手的情况下,则走不到播放录音的情况。这是具体的业务逻辑,就不展开讨论,播放录音的时候会播放波纹动画,类似如下视频展示的:
这里的波纹动画其实是龙骨动画,龙骨动画中只会播放一圈,由代码去创建多圈的波纹。可以看到波纹其实是可以复用的,不需要每次都创建一个新波纹,所以这里使用了 Cocos 提供的节点池进行了优化(cc.NodePool
)。
我将这个播放波纹提取出来,写了一个 demo,发现在切换场景时,确实存在内存泄漏,所以可以确定内存泄漏与这个波纹动画的实现相关。
当我查看 Cocos 官方文档时,发现 NodePool
中有这样一段说明:
当对象池实例不再被任何地方引用时,引擎的垃圾回收系统会自动对对象池中的节点进行销毁和回收。但这个过程的时间点不可控,另外如果其中的节点有被其他地方所引用,也可能会导致内存泄露,所以最好在切换场景或其他不再需要对象池的时候手动调用
clear
方法来清空缓存节点。
我试着在场景销毁时,调用节点池的 clear
方法,结果内存泄漏果真消失了!
问题虽然解决了,但总觉得不明不白,文档明明写着”当对象池实例不再被任何地方引用时,引擎的垃圾回收系统会自动对对象池中的节点进行销毁和回收“。我仔细检查了代码,发现节点池中的节点,确实没有再被其他地方引用了。那么真正的问题到底是怎么引起的呢?这个问题困扰了我许久,我感觉 Cocos 的文档写的是有问题的,乍一看,对象池中的节点确实是会被 JS 引擎的垃圾收集器回收,因为没有其他的对象引用到它,但这仅仅是在 JS 引擎上如此,原生引擎中的对象的生命周期如果不是由 js 引擎中对象的生命周期控制的呢?
我再次阅读了 JSB 绑定这一篇文档,发现确实存在 C 对象控制 JS 引擎对象生命周期的。在这种情况下,JS 引擎中的对象的销毁并不会自动释放对应的 C 层对象,要想销毁 C 层对象,需要主动调用 C 层暴露出来的接口去释放内存。文档中有一句说明:
一般情况下,如果对象是非
cocos2d::Ref
的子类,会采用 CPP 对象控制 JS 对象的生命周期的方式去绑定。引擎内 spine、dragonbones、box2d、anysdk 等第三方库的绑定就是采用此方式。
我们看到 dragonbones,也就是龙骨动画在此列中。所以说,节点池就算被垃圾回收掉了,C 层对应的对象是不会随着释放的。调用节点池对象的 clear
方法后,实际上会主动去调用每个节点对象的 destroy
方法,destroy方法内部会将调用节点上的所有组件对象的 destroy
方法,包括龙骨组件。
clear: function () {
var count = this._pool.length;
for (var i = 0; i < count; i) {
this._pool[i].destroy();
}
this._pool.length = 0;
}
排查到这里,内存泄漏的原因终于快要明朗了。但是还有最后一个问题,为什么龙骨内存没有释放,最终会体现在 Texture2D
上呢?
因为基本锁定了是龙骨相关的对象泄漏,我通过对比内存快照,发现 Armature
类型对象在切换场景时一直在增加。
Armature对象增加
Armature
类是驱动龙骨动画的核心,龙骨动画的每一帧实际上也是纹理,所以内部会用到 Texture2D
类也是正常的。
如果我们在场景销毁时,主动调用节点 destroy
方法,那么龙骨组件dragobones.ArmatureDisplay
的onDestroy
方法会被调用,Armature
对象就会被 dispose 掉:
onDestroy () {
... // 省略无关代码
if (this._armature) {
this._armature.dispose();
this._armature = null;
}
... // 省略无关代码
}
在 JS 中调用的 Armature
对象的 dispose
方法,最终会调用到 C 层对应的 Armature
对象的 dispose
方法:
void Armature::dispose()
{
if (_armatureData != nullptr)
{
_lockUpdate = true;
_dragonBones->bufferObject(this);
}
}
bufferObject
方法把 Armature
对象放到一个_objectMap
中:
void DragonBones::bufferObject(BaseObject* object)
{
if(object == nullptr || object->isInPool())return;
// Just mark object will be put in pool next frame, 'true' is useless.
_objectsMap[object] = true;
}
在下一帧中,将会对 _objectMap
中的 Armature
对象进行释放:
void DragonBones::advanceTime(float passedTime)
{
if (!_objectsMap.empty()) // _objectsMap非空
{
for (auto it = _objectsMap.begin(); it != _objectsMap.end(); it )
{
auto object = it->first;
if (object) {
object->returnToPool(); // 放回对象池,会对其内存进行释放
}
}
_objectsMap.clear(); // 清空map
}
... // 省略无关代码
}
好了,分析到这里,Cocos 内存泄漏的根源终于被定位到了。
总结一下本次内存泄漏的原因,就是 Cocos 节点池在场景销毁后,没有调用 clear
函数造成的。Cocos 在节点池的文档上中,实在应该大大地强调一下,在场景销毁时,必须调用节点池对象的 clear
函数,一般的开发者可能实在想不到节点池都被销毁了,C 内存还没销毁的情况,例如节点池中的节点包含龙骨组件时。