Cocos2d-x(1) 内存:缓存机制(预加载与重复使用 ) && 对象池机制(可回收与重复使用 ) && 对象池实现
缓存机制:预加载与重复使用
缓存在软硬件设计中是一个十分常见的优化方法,多用于高性能软硬件的设计。简单地说,缓存就是利用存储器的速度等级差异,将低速存储中使用频率高的内容加载到高速存储中,这样可以有效提高访问速度。比如将常用的图片资源从磁盘读到内存,将常用的程序段从内存搬到CPU 的高速缓存中。
移动设备比较常用的是外存到内存的缓存。尽管大部分手机使用闪存作为外部存储已经比 PC上磁盘的速度快得多,但相比内存而言还是差了两到三个数量级。从闪存内读取一张图片平均会耗费接近0.1 秒的时间,这样反复加载将占用非常多的系统资源。而缓存机制可以预先加载我们需要的内容到内存,并且在之后的操作中重复使用。
缓存空间毕竟有限,每一个缓存都应该实现合理的换入换出机制来保证缓存中的内容确实是最需要被反复利用的。同时,缓存应该尽量透明化,也就是说,在不主动调用的情况下缓存就应该生效。
Cocos2d -x 中的缓存
幸运的是,我们不需要自己实现缓存,因为Cocos2d-x 已经为我们提供了足够强大的实现。引擎中存在 3 个缓存类,都是全局单例模式。
1.CCTextureCache
首先是最底层也最有效的纹理缓存CCTextureCache,这里缓存的是加载到内存中的纹理资源,也就是图片资源。其原理是对加入缓存的纹理资源进行一次引用,使其引用计数加一,保持不被清除,而Cocos2d-x 的渲染机制是可以重复使用同一份纹理在不同的场合进行绘制,从而达到重复使用,降低内存和GPU 运算资源的开销的目的。常用的是如下所示的 3 个接口:
static CCTextureCache* sharedTextureCache(); //返回纹理缓存的全局单例 CCTexture2D* addImage(const char* fileimage); //添加一张纹理图片到缓存中 void removeUnusedTextures(); //清除不使用的纹理
在这3 个接口中,CCTextureCache 屏蔽了加载纹理的许多细节;addImage函数会返回一个纹理 CCTexture2D 的引用,可能是新加载到内存的,也可能是之前已经存在的;而 removeUnusedTextures则会释放当前所有引用计数为 1 的纹理,即目前没有被使用的纹理。后面会看到,引用计数的内存管理方式为缓存的设计带来了很大的便利。
实际上,我们很少需要调用addImage 这个接口,因为引擎内部所有的纹理加载都是通过这个缓存进行的,换句话说,载入的每一张图片都被缓存了,所以我们更需要关心什么时候清理缓存。引擎会在设备出现内存警告时自动清理缓存,但是这显然在很多情况下已经为时过晚了。一般情况下,我们应该在切换场景时清理缓存中的无用纹理,因为不同场景间使用的纹理是不同的。如果确实存在着共享的纹理,将其加入一个标记数组来保持其引用计数,以避免被清理了。
2.CCSpriteFrameCache
第二个则是精灵框帧缓存。顾名思义,这里缓存的是精灵框帧CCSpriteFrame ,它主要服务于多张碎图合并出来的纹理图片。这种纹理在一张大图中包含了多张小图,直接通过CCTextureCache引用会有诸多不便,因而衍生出来精灵框帧的处理方式,即把截取好的纹理信息保存在一个精灵框帧内,精灵通过切换不同的框帧来显示出不同的图案。
CCSpriteFrameCache的常用接口和 CCTextureCache类似,不再赘述了,唯一需要注意的是添加精灵帧的配套文件-- 一个plist 文件和一张大的纹理图。下面列举了 CCSpriteFrame Cache 常用的方法:
static CCSpriteFrameCache* sharedSpriteFrameCache(void); //全局共享的缓存单例 void addSpriteFramesWithFile(const char *pszPlist); //通过plist配置文件添加一组精灵帧 void removeUnusedSpriteFrames(void); //清理无用缓存
3.CCAnimationCache
最后一个是CCAnimationCache动画的缓存。通常情况下,对于一个精灵动画,每次创建时都需要加载精灵帧,按顺序添加到数组,再创建对应动作类,这是一个非常烦琐的计算过程。对于使用频率高的动画,比如鱼的游动,将其加入缓存可以有效降低每次创建的巨大消耗。由于这个类的目的和缓存内容都非常简单直接,所以其接口也是最简单明了的,如下所示:
static CCAnimationCache* sharedAnimationCache(void);//全局共享的缓存单例 void addAnimation(CCAnimation *animation, const char * name);// 添加一个动画到缓存 void removeAnimationByName(const char* name);// 移除一个指定的动画 CCAnimation* animationByName(const char* name);// 获得事先存入的动画
唯一不一样的是,这次动画缓存需要我们手动维护全部动画信息。也因为加载帧动画完全是代码操作的,目前还没有配置文件指导,所以不能像另外两个缓存那样透明化。实际上,如果考虑到两个场景间使用的动画基本不会重复,可以直接清理整个动画缓存。
所以,在场景切换时我们应该加入如下的清理缓存操作:
void releaseCaches() { CCAnimationCache::purgeSharedAnimationCache(); CCSpriteFrameCache::sharedSpriteFrameCache()->removeUnusedSpriteFrames(); CCTextureCache::sharedTextureCache()->removeUnusedTextures(); }
值得注意的是清理的顺序,应该先清理动画缓存,然后清理精灵帧,最后是纹理。按照引用层级由高到低,以保证释放引用有效。
对象池机制:可回收与重复使用
另一个能有效提高内存和计算效率的是对象池机制。其本质与缓存类似,即希望能减少那些频繁使用的对象的重复创建和销毁,例如飞行射击类游戏中的子弹。使用对象池机制能带来两方面的收益,首先是减少对象初始化阶段的重复计算,其次是避免反复地向操作系统申请归还内存。一个很好的例子就是捕鱼游戏中的鱼,鱼和鱼之间的属性是类似的,不一样的仅仅是当前的坐标位置及正在播放的动画帧。那么,当鱼游出屏幕后,可以不对其进行销毁,而是暂存起来。某一时刻需要重新创建鱼时,我们可以将其从对象池中取出,重新申请内存并初始化,这样就大大减轻了CPU 的负担。
对象池和缓存很像,但比缓存更抽象,也更简单一些,因为我们不需要考虑从哪里加载的问题:都已经被抽象为初始化函数了。而且更简化的是,加入对象池的每一个对象都是无差别的,我们不需要对每一个对象进行特定的标记,直接取出任意一个未使用的对象即可。
看完上面的描述,读者应该有了初步的认识:缓存是一个字典,而对象池则是一个数组。得益于引用计数的内存管理机制,只需要在数组上做适当封装就可以提供一个对象池的功能了。尽管如此,一个高效实现的对象池还要考虑如何有效地处理对象的生成和归还,以及占用内存的动态增长等问题。因此,我们不妨借助前人成果,在已有对象池的基础上搭建适合我们游戏使用的对象池。
对象池实现(1)
Boost 是一个可移植、免费开源的 C++库,提供了大量实用的开发组件,而且由于对跨平台和 C++标准的强调,其实现的功能几乎不依赖于操作系统和标准库外的其他组件,因此可以在任何支持C++的平台上运作良好。
Boost 提供了一个对象池object_pool ,它位于boost 库的"boost/pool/object/_pool.hpp"中。这是一个泛型的对象池,能够针对指定类型的对象进行分配。一个对象池的声明和使用规范为如下结构:
object_pool<CCSprite > spritePool; //为CCSprite 声明一个对象池 CCSprite* sp =spritePool.construct(); //从对象池得到一个对象,并调用默认构造函数 spritePool.destroy(sp); //对从对象池得到的对象调用析构函数,并返还到对象池中备用
object_pool 的一大特色是可以针对不同的参数调用被分配对象的构造函数。可惜在 Cocos2d-x 对象生命周期管理中,对象的创建和初始化是分离的,大部分类的初始化都不在构造函数中完成,构造函数中仅仅作引用计数的初始化。这里也引入了一个新的问题,Cocos2d -x 对象在引用计数为零的时候会自动触发 delete。对于从对象池分配的对象来说,不能通过delete而必须通过destroy 来删除。因此,在不修改引擎源码的前提下,我们需要在 object_pool 的基础上作一点小小的包装使其可以配合引擎的内存管理使用,相关代码如下:
template <class T> class MTPoolFromBoost : public ObjectPoolProtocol { object_pool<T> pool; CCArray* objects; MTPoolFromBoost() : pool(256){ objects = CCArray::create(); objects->retain(); } public: ~MTPoolFromBoost() { objects->removeAllObjects(); objects->release(); } static MTPoolFromBoost<T>* sharedPool() { static MTPoolFromBoost<T> __sharedPool; return &__sharedPool; } T* getObject() { T* pObj = pool.construct(); objects->addObject(pObj); pObj->release(); return pObj; } void freeObjects(int maxScan = 100 ) { static int lastIndex =0; int count = objects->count(); if(lastIndex >= count) lastIndex = 0; if(maxScan > count) maxScan = count; CCArray* toRemove = CCArray::create(); for(int i = 0; i < maxScan; i++) { CCObject* obj = objects->objectAtIndex((i + lastIndex) % count); if(obj->retainCount() == 1) { toRemove ->addObject(obj); } } objects->removeObjectsInArray(toRemove); for(int i=0; i < toRemove ->count(); i++) { T* obj = dynamic_cast<T*>(toRemove->lastObject()); obj ->retain(); toRemove ->removeLastObject(); pool.destroy(obj); } CCLOG("%s ends. Obj now = %d", __FUNCTION__, objects->count()); } };
由于做成了模板类的形式,类的实现就全部存在于头文件中了。在这个包装类中,我们仅仅做了一件事情--在分配对象的时候,同时将对象添加到一个数组中,数组会增加对象的一次引用计数,因此可以保证在正常使用的情况下,不会有对象会被触发delete操作。由此引出的便是,需要在合适的时候回收对象,否则对象池将持续增长直到耗尽内存。
在提供的内存释放函数freeObjects 中,我们检查当前缓冲数组中每个元素的引用计数,对于引用计数为 1 的对象,表示已经没有其他对象在引用这个对象,将其回收归还到对象池中。值得注意的是,在释放对象的循环中,我们将一个待回收的对象retain后并没有 release ,这是对引用计数内存管理的一个小小破例,保证了该对象在从数组清理之后仍然不会触发delete操作。
另外,这里设计了一个回收的扫描步长,每次回收仅在数组中扫描一定数量的对象就返回。这样做的好处在于,我们可以将整个对象池的回收扫描分散到每一帧中,隐性地完成并发。这个步长可以根据工程规模和所需的清理频率进行调整,对于游戏中对象生成和销毁并不频繁的情况,可以设置一个较长的清理周期,在每次清理时设置一个较大的扫描步长以回收更多的对象,同时减轻计算压力。
模板化之后,实际上每个类对应了一个对象池,以硬编码的形式清理这些对象池是十分费劲的,因此我们再在此基础上扩展一个管理器,管理这些对象池的清理。
对象池实现(2)
首先,需要做的是将回收操作分离抽象。我们定义一个接口并让MTPoolFromBoost 继承,这样就能够在运行时用统一接口调用内存池回收对象:
class ObjectPoolProtocol : public CCObject{ public: virtual void freeObjects(int maxScan = 100 ) = 0; };
这样抽象的目的是将所有用到的对象池添加到数组内,以便统一管理。首先,为管理器封装一个获取对象指针的函数:
class MTPoolManager : public CCObject{ CCArray* pools; class PoolCounter { public: PoolCounter(ObjectPoolProtocol* pool,CCArray* pools) { pools->addObject(pool); } }; MTPoolManager() { pools = CCArray::create(); pools->retain(); } ~MTPoolManager() { pools->release(); } public: static MTPoolManager* sharedManager() { static MTPoolManager __sharedManager; return &__sharedManager; } void freeObjects(ccTime dt) { for(int i = 0; i < pools->count(); i++) { ObjectPoolProtocol* pool = dynamic_cast<ObjectPoolProtocol *>(pools-> objectAtIndex(i)); pool->freeObjects(); } } template <class T> T* getObject(T*& pObj) { static PoolCounter ___poolCounter(MTPoolFromBoost<T>::sharedPool(), pools); return pObj = MTPoolFromBoost<T>::sharedPool()->getObject(); } };
在管理器中我们设计了一个获取对象的接口函数getObject,可以根据传入的指针类型调用相应类型的对象池获得对象。这里我们设置一个静态变量,使用这个变量的构造函数添加当前对象池到类的对象池数组中。由于这个函数是模板化的,最终将把每种调用到的对象池添加到管理器的对象池数组中。这样设计的另一个好处是,管理器调用某一类型的对象池之前,不会在管理器的清理函数中触发该对象池的清理。
而在管理器的清理函数中,可以获取每一个曾经使用过的管理器,调用其清理接口清理对象。 最后,我们只需要在程序初始化完毕后添加该管理器到引擎的定时触发器中:
CCDirector::sharedDirector() ->getScheduler()->scheduleSelector( schedule_selector(MTPoolManager::freeObjects), MTPoolManager::sharedManager(), 1, false );
落实到工厂方法
经过上面的封装,显式地使用对象池已经很方便了,但我们还可以做得更好。借助于工厂方法,我们可以将对象池隐藏起来,透明化其使用:
FishSprite* FishSprite::spriteWithSpriteFrameName(const char* file) { FishSprite *pRet = MTPoolManager ::sharedManager()->getObject(pRet); if(pRet && !pRet->initWithSpriteFrameName(file)) { CC_SAFE_RELEASE_NULL(pRet); } return pRet; }
这样做的好处是,游戏中的逻辑代码是没有改动的,直接无缝引入了对象池增强内存管理。
一个简单的性能测试
对象池的效率如何?我们来做一个简单的性能测试,示例代码如下:
object_pool<CCSprite > spritePool; void testPoolEfficiency() { const int test_time = 101000; vector<int > ops; int tot = 0; for(int i = 0; i < test_time; i++) { int op = 0; if(tot) { if(rand() % 2 == 0) op=1; } if(op) tot --; else tot++; ops.push_back(op); } { LifeCircleLogger logger("use system new"); CCArray* tmp = CCArray::create(); for(int i = 0; i < test_time; i++) { if(ops[i]) { tmp ->removeLastObject(); } else { CCSprite* sp = CCSprite ::create(); tmp ->addObject(sp); } } } op=1; } if(op) tot --; else tot++; ops.push_back(op); } { LifeCircleLogger logger("use system new"); CCArray* tmp = CCArray::create(); for(int i = 0; i < test_time; i++) { if(ops[i]) { tmp ->removeLastObject(); } else { CCSprite* sp = CCSprite ::create(); tmp ->addObject(sp); } } } CCSprite* sp = spritePool.construct(); tmp ->addObject(sp); } } } }
在这个性能测试中,我们分别使用系统new、编写的对象池和 boost 的对象池作10 000次随机的分配或回收操作。连续执行上面的函数3 次,得到的执行时间如下:
[Wed Aug 08 16:31:12 2012] use system new BEGINS! [Wed Aug 08 16:31:13 2012] use system new ENDS! Time Consumed : 100 ms [Wed Aug 08 16:31:13 2012] use MTPool BEGINS! [Wed Aug 08 16:31:13 2012] use MTPool ENDS! Time Consumed : 14 ms [Wed Aug 08 16:31:13 2012] use boost Pool BEGINS! [Wed Aug 08 16:31:13 2012] use boost Pool ENDS! Time Consumed : 12 ms [Wed Aug 08 16:31:18 2012] use system new BEGINS! [Wed Aug 08 16:31:18 2012] use system new ENDS! Time Consumed : 141 ms [Wed Aug 08 16:31:18 2012] use MTPool BEGINS! [Wed Aug 08 16:31:18 2012] use MTPool ENDS! Time Consumed : 14 ms [Wed Aug 08 16:31:18 2012] use boost Pool BEGINS! [Wed Aug 08 16:31:18 2012] use boost Pool ENDS! Time Consumed : 12 ms
可以看到,使用对象池后,分配对象的耗时降低到了单纯使用new 的1/10左右,这个提升是非常可观的。
使用时机
最后需要强调的是,缓存和对象池都不是万金油,我们需要把握好它们的使用时机。缓存和对象池的使用动机都是为频繁使用的资源作优化(这里的资源可以是外存的纹理,也可以是一个对象),避免大量的重复计算。缓存和对象池内都做了一些额外的小计算量的标记来满足这一需求。对于游戏中那些使用频率并不高的部分,加入缓存或者对象池反而很可能因为额外的标记计算而降低性能。这也是引擎只为我们提供了3 个缓存器的原因。
值得一提的是,缓存和对象池不仅仅适用于C++这类偏底层的开发语言,在C#和JavaScript等语言中,内存的开销更大,使用好缓存和对象池能有效减少不必要的系统内存管理,提升游戏执行效率。
小结
在这一章中,我们探讨了缓存和对象池这两个隐蔽而实用的组件,也在我们的《捕鱼达人》中加入了相应的优化,进一步降低了内存和计算消耗。在Cocos2d-x 中,缓存无处不在,然而在实际开发时,我们仍然需要合理地利用缓存与对象池这两个组件,才能有效地提高游戏的性能。下面总结这一章的知识点。
Cocos2d-x 内建的缓存:CCTextureCache 用于缓存纹理,CCSpriteFrameCache 用于缓存精灵框帧文件,CCAnimationCache用于缓存帧动画。
对象池:对于频繁使用的对象,应当尽可能地减少分配内存和初始化的资源开销。因此,建立一个对象的容器,用于取出或返还对象,这种机制称为对象池。对于大量使用对象的场合,对象池机制可以大大提高运行效率。
浙公网安备 33010602011771号