前言:好久没来写东西了,这表示,最近没遇到太大的问题,否则就来这里吐槽了,不过最近还是学到不少的,自己也总结了不少,但是就是懒得过来写。不得不说,前段时间特别忙,时间总也不够用,总觉得浪费过来写东西有点不值得,刚好最近有点小空闲,正好昨天看崩溃日志的时候发现一个问题,仔细排查了下,也算是个小总结吧,可以带给这方面有同样困扰的盆友们。看下,可能有收获也不一定。不过浪费时间了也别怪我啊,哈哈哈。
我在cocos2d-x 2.x中的场景纹理内存有一个释放的策略,就是低端机(设定最大边长分辨率低于1000)会设定在场景析构(CCScene::~CCScene)时顺便清理下未用的CCSpriteFrameCache和CCTexture2DCache数据。并且,在iOS设备上运行时,当得到内存警告时,会主动设置在场景释放时顺便清理下纹理内存,这种策略会让即使高端机器也可以避免内存申请失败导致的崩溃。
场景节点结算顺序
这样的策略在写纹理加载的时候要遵循一定的规则,比如,不允许预加载暂时未使用的纹理(这个很头疼啊)。这部分后面解释,先看下场景加载和释放的顺序:
假设有场景A和场景B,A是当前的场景,B是待加载场景。
先看不带场景转换效果的进场,即直接使用CCDirector::sharedDirector()->replaceScene(B)的方式。
场景的结算顺序是这样的:
- A->onExitTransitionDidStart();
- A->onExit();
- A::~CCScene();
- B->onEnter();
- B->onEnterTransitionDidFinish();
如果B是由CCTransitionScene的效果,即,使用CCDirector::sharedDirector()->replaceScene(CCTransitionFade::create(0.5f,B));
场景的结算顺序是这样的:
- B->onEnter();
- A->onExitTransitionDidStart();
- A->onExit();
- B->onEnterTransitionDidFinish();
- A::~CCScene();// A场景会释放
- CCTransitionScene::~CCScene(); // 转换场景会释放
从上述的两个过程可以看出来,结算顺序的不同表现在原有场景的析构位置。第一种情况是当前场景(A)先释放,后新场景(B)加载的,而后一个的情况是B(新)场景先进场,然后A场景onExit,B场景onEnterTransitionDidFinish()后,A场景释放,最后转换场特效景释放。如果我们是在场景基类CCScene的析构函数中释放未使用的纹理内存,那在第一种情况下,可以工作得很好,无论是否采用预加载纹理资源的方式,而第二种就会出现问题。(稍后分析)
再看下纹理预加载的情况。纹理预加载这个在目前我的用法里面,只是为了在一个场景加载前,先把在当前场景中需要用到的纹理先全部加载进来的手段,这样在场景加载的时候可以显示一个等待框,当纹理内存加载完成后,后续场景内所用到得纹理都已经在内存中,就不需要再次去磁盘中读取数据,毕竟磁盘I/O是比内存读取慢好多个数量级,可能会造成卡顿,如果在业务场景内卡顿是比较影响体验的。并且,如果数据的预加载放在一个地方,也会比较易于管理,方便之后添加和删除,并且可以避免重复加载判断上的效率损失(引擎现有的机制会避免纹理内存重复加载)。
目前使用的方式是,一个场景内使用到的纹理,由这个场景在管理,所以,一般是在需要加载的场景的init函数中加载,顺便还会在onEnter中加载一次,为了安全起见,还可以在onEnterTransitionDidFinish中加载一次,因为这些函数针对节点在界面上出现的过程来讲,都只被调用一次,作为纹理预加载来讲,应该是不错的入口。
预加载纹理还有个特点,就是仅仅会在CCTexture2DCache中带有引用(如果CCSpriteFrameCache的话也会有引用),因为可能没有绑定到界面的节点上,所以如果在纹理增加引用计数前被removeUnused了,内存中就没有了,如果后续使用类似CCSprite的createWithSpriteFrameName的方式,就会加载不到返回空指针。
问题来了
那么问题来了,如果是出现在有场景切换特效的情况下是如何呢?明显,在场景析构函数中会释放未用的纹理,从列表上明显看到,析构是最后发生的,在待加载场景(B)的onEnter和onEnterTransitionDidFinish函数调用后才发生的,也就是说,无论B场景之前预加载多少次,这些暂时没有用到的纹理,都会在A场景析构的时候被清理掉,这就是坑爹的地方!!!
在场景CCScene的析构函数中作纹理释放的好处显而易见:纹理数据针对场景保存,每个场景“管理”自己的纹理,当场景消失的时候释放自己的内存。
这个好处针对不带过度特效的场景切换可谓是好得不得了啊,但是问题就出在,纹理数据其实独立于场景的,场景A可以使用,场景B也可以使用,同样,如果场景A释放了,场景B直接使用就会出问题。要么在每次使用前都加载一次,要么采用每次加载的方式去调用(内部判断是否在内存中,有就直接使用,没有就按照路径去加载),但是因为有个误操作的存在:把纹理打包成一个图片,用CCSpriteFrameCache的方式去管理。其实CCSpriteFrame本来就是用作帧序列图的操作会比较合适,但是貌似用它来管理整个纹理也是很不错的选择,并且加载后,可以调用createWithSpriteFrameName这种方法去创建CCSprite,用起来还是很爽的。但是无节制的使用总会带来各种各样的问题。
因为转换特效的场景切换(使用CCTransitionScene的子类),会使得单个场景对自己的纹理管理流程被切断(如果是带原子性的操作就没OK了,但明显过渡切换的业务类型不允许原子操作——这个自己理解下,不理解也没关系),他们访问的其实是共享的资源,如果顺序不对,会导致逻辑混乱,比如预加载后续使用的纹理数据时,B场景本来以为预加载的纹理后续可以使用,但是A场景析构的时候把未使用的纹理清除了,如果B场景在使用这些纹理之前有做判断是否加载再使用就可以避免空指针的问题,但是如果没判断,就会导致纹理空指针,一访问就崩溃了。
针对引擎的纹理加载释放的代码规范
这里的纹理加载和释放,cocos2d-x 2.x中已经提供了对应的方法,但是因为控制粒度的问题,所以还是需要自己去管理。我这里会造成使用问题的关键点在于,把零碎的图片,整合打包成一个纹理,然后通过一个描述文件(*.plist)去告诉引擎如何切割得到对应的单个纹理数据。然后代码中很爽(自以为很爽)得使用createWithSpriteFrameName来生成CCSprite。并且可以直接使用图片包内的单个图片的名字去得到CCSprite,减少了好多路径代码的书写。
使用CCSpriteFrameCache的好处
开始用起来是很爽的,不得不承认,但是这样的操作其实是有问题的,就是CCSpriteFrame的出现,应该是为了帧序列图,不过这个我也没有深入探究(我能问谁?),但是它这个还正好解决了一些碎片化的小图浪费纹理内存的问题。
使用CCSpriteFrameCache的困扰
不过使用上的限制也不少,比如,小图的名字不能重复,否则,在CCSpriteFrameCache中,同样名字的CCSpriteFrame只会保留最后加载的那个,也就是说,CCSpriteFrameCache,可以看成是一个key=value的map,key是不允许重复的。这个问题会给CCSpriteFrameCache的滥用造成隐患,比如场景中的两个对话框的背景图,名字一样的,但是纹路不同(反正就是两张图的文字一样),并且是放在两个plist描述文件中的,如果两个plist被CCSpriteFrameCache一起加载,那就有一个图片通过createWithSpriteFrameName的方式会访问不到。两个对话框会使用同一个背景,这就和设计不符了。当然,CCTexture2D中,两个纹理都在(其实只是一个纹理图,不同的区块罢了)。
可参考的解决方案
如果不使用CCSpriteFrameCache用作纹理的管理,显然不现实,小碎图那些长长的路径简直要了命了,更何况小碎图浪费内存的问题咋整?
不要使用预加载未使用的纹理,这也是个办法,就是书写的时候要费力点了,要区分,哪些数据是后期加载的,在显示之前,都要先加载一次plist。