前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >硬核破解 Cocos 内存泄漏

硬核破解 Cocos 内存泄漏

作者头像
用户1097444
发布2022-06-29 14:56:39
2.3K0
发布2022-06-29 14:56:39
举报
文章被收录于专栏:腾讯IMWeb前端团队

腾讯企鹅辅导使用 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 方法的实现。

代码语言:javascript
复制
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 渲染图片数据比较可疑了。

代码语言:javascript
复制
 glTexImage2D(
   GL_TEXTURE_2D,
   ... // 省略无关参数
   img.data
 )

因为之前学习过一点 OpenGL,知道 OpenGL 绑定纹理的大致流程。

  1. 通过 glGenTextures 创建一个 texture 对象。
  2. 通过 glActiveTexture 激活纹理单元。
  3. 通过 glBindTexture 绑定纹理对象。
  4. 通过 glTexImage2D 写入纹理数据。

关键在于 glTexImage2D 方法,实际上会把纹理数据写到显卡的内存,也就是显存里,方便 GPU 去读取。

如果是写到显存里,为啥内存会增加呢?这是因为 iPhone 上 CPU 和 GPU 的 memory 是共享的,没有独立的显存,这就是 setImage 中会申请内存的原因了。

释放纹理内存,需要调用 glDeleteTextures,它是在哪里被调用的呢?

在回答这个问题之前,我们先来了解,在 C++ 中实现的 Texture2D 类,是怎么注册给到 JS 调用的。

我从 Cocos 官方文档了解到,可以通过 Cocos 提供 JSB 绑定往 JS 层注册 C++ 实现的类,我们来看 C++ 中往 JS 引擎定义Text2D 类的相关代码:

代码语言:javascript
复制
// 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,我们来看下它的定义:

代码语言:javascript
复制
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 的定义:

代码语言:javascript
复制
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 方法最终会执行:

代码语言:javascript
复制
delete this;

从而销毁类实例。类实例被销毁,会走到析构函数,Texture2D 中析构函数是空的,只有一行注释。

代码语言:javascript
复制
Texture2D::~Texture2D()
{
//    RENDERER_LOGD("Destruct Texture2D: %p", this);
}

答案在 Texture2D 类的父类 Texture 的析构函数中,终于看到了纹理被销毁了:

代码语言:javascript
复制
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 方法,包括龙骨组件。

代码语言:javascript
复制
    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.ArmatureDisplayonDestroy 方法会被调用,Armature 对象就会被 dispose 掉:

代码语言:javascript
复制
 onDestroy () {
   ... // 省略无关代码
     if (this._armature) {
       this._armature.dispose();
       this._armature = null;
     }
    ... // 省略无关代码  
}

在 JS 中调用的 Armature 对象的 dispose 方法,最终会调用到 C++ 层对应的 Armature 对象的 dispose 方法:

代码语言:javascript
复制
void Armature::dispose()
{
    if (_armatureData != nullptr) 
    {
        _lockUpdate = true;
        _dragonBones->bufferObject(this);
    }
}

bufferObject 方法把 Armature 对象放到一个_objectMap 中:

代码语言:javascript
复制
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 对象进行释放:

代码语言:javascript
复制
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++ 内存还没销毁的情况,例如节点池中的节点包含龙骨组件时。🤔

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-07-05,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯IMWeb前端团队 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
对象存储
对象存储(Cloud Object Storage,COS)是由腾讯云推出的无目录层次结构、无数据格式限制,可容纳海量数据且支持 HTTP/HTTPS 协议访问的分布式存储服务。腾讯云 COS 的存储桶空间无容量上限,无需分区管理,适用于 CDN 数据分发、数据万象处理或大数据计算与分析的数据湖等多种场景。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档