Cocos2dx学习笔记(14)——内存管理

Cocos2dx中的内存管理是极为重要的一部分,本章就将讲一讲内存管理机制。

本文仅供个人记录和复习,不用于其他用途

3.x版本的改变

3.x版本使用了全新的根类Ref,不再使用2.x版本中的CCObject。引擎中所有的类都派生自Ref

为什么要内存管理

C++中由于存在着指针,所以程序员可以使用指针来获取堆上的内存。相比于有限的栈上空间,堆上的空间要更大一些。但是呢,指针分配了内存之后,我们必须将这段内存释放掉,否则会出现内存泄露的情况。内存的分配与释放是非常麻烦的事情,稍不留神就会忘记。Cocos2dx中没有使用构造函数和析构函数,而是分别采用了自定义create()方法以及引用计数的方式来控制内存的分配与释放。

引用计数

什么是引用计数呢?看看下面这张图你就会明白:

示例图</center>

如果把房子看做一段内存,把人看作使用者的话,上图所示的步骤就可以这么解释:

  • 一开始,这段内存放在堆上无人使用,引用计数为0
  • 随后有一人进来,一般表示这段内存被分配给了一个指针,引用计数为1
  • 又进来一个人使用,引用计数为2
  • 同样的,再次被使用,引用计数为3
  • 有一个人使用完了,那么引用计数为2
  • 再次使用完,引用计数为1
  • 最后,这个指针不需要了,引用计数为0,释放这段内存

也就是说,每当一段内存被使用时,引用计数就加1,每当结束一次使用,引用计数就减1。直到引用计数为0时,就将这段内存释放,达到了内存管理的效果。

主要方法

  • retain()方法,使对象的引用计数加1,表示获取该对象的使用权
  • release()方法,使对象的引用计数减1,表示释放该对象的使用权
  • autorelease()方法,将对象放入自动回收池,自身被释放时对池中所有对象执行release()方法,十分方便

关于自动回收池,Cocos定义了AutoreleasePool类,用于管理自动回收对象。

Ref类

查看CCRef文件,我们可以看到Ref类的实现,这里只摘抄了重要的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class CC_DLL Ref
{
public:
void retain(); // 保留。引用计数+1
void release(); // 释放。引用计数-1
Ref* autorelease(); // 实现自动释放。
unsigned int getReferenceCount() const; // 被引用次数
protected:
Ref(); // 初始化
public:
virtual ~Ref(); // 析构
protected:
unsigned int _referenceCount; // 引用次数
friend class AutoreleasePool; // 自动释放池
};
Ref::Ref() : _referenceCount(1)
{
}
void Ref::retain()
{
CCASSERT(_referenceCount > 0, "reference count should greater than 0");
++_referenceCount;
}
void Ref::release()
{
CCASSERT(_referenceCount > 0, "reference count should greater than 0");
--_referenceCount;
if (_referenceCount == 0)
{
delete this;
}
}
Ref* Ref::autorelease()
{
// 将节点加入自动释放池
PoolManager::getInstance()->getCurrentPool()->addObject(this);
return this;
}

Ref类定义了_referenceCount变量用于引用计数,构造函数将_referenceCount初始化为1。retain()将引用计数加1,release()将引用计数减1。这里还有一个判断,那就是当引用计数为0时,release()方法会将内存释放。autorelease()用于将对象放入自动回收池。

自动回收池详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
TestObject* obj = new TestObject("testobj");
CCLOG("obj referenceCount=%d",obj->getReferenceCount());
obj->autorelease();
CCLOG("obj is add in currentpool %s",PoolManager::getInstance()->getCurrentPool()->contains(obj)?"true":"false");
CCLOG("obj referenceCount=%d",obj->getReferenceCount());
obj->retain();
CCLOG("obj referenceCount=%d",obj->getReferenceCount());
obj->release();
CCLOG("obj referenceCount=%d",obj->getReferenceCount());
// obj in current pool will be release
Director::getInstance()->replaceScene(this);

控制台输出日志如下:

cocos2d: TestObject:testobj is created
cocos2d: obj referenceCount=1
cocos2d: obj is add in currentpool true
cocos2d: obj referenceCount=1
cocos2d: obj referenceCount=2
cocos2d: obj referenceCount=1
...
cocos2d: TestObject:testobj is destroyed

obj对象创建,引用计数为1,随后执行一次autorelease(),obj的引用计数不变。随后我们执行一组retain()release(),引用计数还是为1。但是呢,当这一帧结束(这里就是转换场景),自动回收池将对池中所有对象进行一次release()

从这里可以看出,autorelease()是在一帧结束后才会释放。如果对象的释放次数超过了应有次数,这个错误不会被立马发现,只有当一帧结束时,游戏才会崩溃。比如,一个对象含有1个引用计数,但是被调用了两次autorelease()。不过,游戏不会立马崩溃,而是会继续执行这一帧,直到结束时才会崩溃。所以,除非是工厂方法这种不得不用的情况,一般只是用release()来释放对象。

AutoreleasePool类

虽然autorelease()很方便,但我们要知道,自动回收池本身占用着内存和CPU。如果一帧之中产生大量的autorelease(),回收池的性能会下降。所以,在autorelease()产生的密集区域前后,我们最好手动创建并释放一个新的回收池。

1
2
3
4
5
6
7
8
AutoreleasePool pool2;
char name[20];
for (int i = 0; i < 100; ++i)
{
snprintf(name, 20, "object%d", i);
TestObject *tmpObj = new TestObject(name);
tmpObj->autorelease();
}

autorelease()注意事项

对于autorelease(),有以下几个要注意的地方:

  • autorelease()的实质是将对象加入自动释放池,对象的引用计数不会立刻减1,在自动释放池被回收时对象执行release()
  • autorelease()并不是毫无代价的,其背后的释放池机制同样需要占用内存和CPU资源
  • 不用的对象推荐使用release()来释放对象引用,立即回收

特殊的内存管理

工厂方法

Cocos2dx中提供了大量的工厂方法来创建对象,比如我们很熟悉的create()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
T01LayerAnchorPoint *T01LayerAnchorPoint::create()
{
T01LayerAnchorPoint *pRet = new T01LayerAnchorPoint();
if (pRet && pRet->init())
{
pRet->autorelease();
}
else
{
delete pRet;
pRet = nullptr;
}
return pRet;
}

我们可以看到,工厂方法之中用到了autorelease()

addChild()和removeChild()

在Cocos2d-x中,所有继承自Node类,在调用addChild方法添加子节点时,自动调用了retain。对应的通过removeChild,移除子节点时,自动调用了release

调用addChild方法添加子节点,节点对象执行retain。子节点被加入到节点容器中,父节点销毁时,会销毁节点容器释放子节点。对子节点执行release。如果想提前移除子节点我们可以调用removeChild

在Cocos2d-x内存管理中,大部分情况下我们通过调用addChildremoveChild的方式自动完成了retainrelease调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
bool T09Memory::init()
{
if (!Layer::init())
{
return false;
}
Size winSize = Director::getInstance()->getWinSize();
spr = Sprite::create("sprite/p_1_01.png");
CCLOG("retain count %d", spr->getReferenceCount());
addChild(spr);
CCLOG("retain count %d", spr->getReferenceCount());
spr->setPosition(ccp(winSize.width / 2, winSize.height / 2));
schedule(schedule_selector(T09Memory::mySchedule), 2);
return true;
}
void T09Memory::mySchedule(float dt)
{
if (spr)
{
spr->removeFromParent();
spr = nullptr;
}
}

控制台输出如下:

retain count 1
retain count 2

可以看到,当我们使用工厂方法create()创建spr时,引用计数为1,随后使用addChild()spr加入层,引用计数为2。那么呢,延迟2秒后,定时器调用mySchedule(),将spr从父节点上移除。我们在CCNode中可以查看相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
void Node::removeFromParent()
{
this->removeFromParentAndCleanup(true);
}
void Node::removeFromParentAndCleanup(bool cleanup)
{
if (_parent != nullptr)
{
_parent->removeChild(this, cleanup);
}
}
void Node::removeChild(Node* child, bool cleanup /* = true */)
{
// explicit nil handling
if (_children.empty())
{
return;
}
ssize_t index = _children.getIndex(child);
if( index != CC_INVALID_INDEX )
this->detachChild( child, index, cleanup );
}
void Node::detachChild(Node *child, ssize_t childIndex, bool doCleanup)
{
// IMPORTANT:
// -1st do onExit
// -2nd cleanup
if (_running)
{
child->onExitTransitionDidStart();
child->onExit();
}
// If you don't do cleanup, the child's actions will not get removed and the
// its scheduledSelectors_ dict will not get released!
if (doCleanup)
{
child->cleanup();
}
// set parent nil at the end
child->setParent(nullptr);
_children.erase(childIndex);
}

我们可以看到,当调用removeFromParent()时,实际上就是将cleanUp设置为true,然后在removeFromParentAndCleanup()方法中,让父节点调用方法removeChild()removeChild()中根据子节点的编号,调用detachChild()方法,将子节点退出,并且把父节点设置为空,同时进行其他的擦除动作。

至于cleanUp()方法,便是用于子节点的清理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void Node::cleanup()
{
#if CC_ENABLE_SCRIPT_BINDING
if (_scriptType == kScriptTypeJavascript)
{
if (ScriptEngineManager::sendNodeEventToJS(this, kNodeOnCleanup))
return;
}
else if (_scriptType == kScriptTypeLua)
{
ScriptEngineManager::sendNodeEventToLua(this, kNodeOnCleanup);
}
#endif // #if CC_ENABLE_SCRIPT_BINDING
// actions
this->stopAllActions();
this->unscheduleAllCallbacks();
// timers
for( const auto &child: _children)
child->cleanup();
}

事实上,当子节点被移除时,便退出了渲染树。可以说,如果精灵没有加入渲染树,那么便毫无用处。

内存优化

内存优化原理

游戏中最耗费内存的无疑就是纹理,因此,我们应该想办法减纹理内存的使用,否则游戏的运行会很不流畅。但是呢,也不能过度优化,因为游戏的画质和游戏的流畅运行本身就是难以取舍的。比如,我们在玩游戏的时候,我们可以通过设置选项来调节游戏的画质。我们知道,当游戏的画质调低时,游戏的画面会变得模糊,特别是当近距离观看时,物体的边缘锯齿会很明显。又或者是物体的色彩不再鲜艳,而是换成较为稀薄的颜色。

内存优化注意事项

  • 一帧一帧载入游戏资源
  • 减少绘制调用,使用“Auto-batching”自动批处理
  • 载入纹理时按照从大到小的顺序
  • 避免高峰内存使用
  • 使用载入屏幕预载入游戏资源
  • 需要时释放空闲资源
  • 收到内存警告后释放缓存资源
  • 使用纹理打包器优化纹理大小、格式、颜色深度等
  • 使用JPG格式要谨慎
  • 请使用RGB4444颜色深度16位纹理
  • 请使用NPOT纹理,不要使用POT纹理
  • 避免载入超大纹理
  • 推荐1024*1024 NPOT pvr.ccz纹理集,而不要采用RAW PNG纹理