本文是关于笔者在开发App过程中发现的一些内存问题。然后,在学习YYKit框架的时候,我也发现了图片缓存处理(YYKit作者联系我,说明YYKit重写imageNamed:的目的不是为了内存管理,是为了增加兼容性,也是YYKit中的动画服务)。以下内容是笔者在开发过程中所做的一些实验和总结。如有错误,请第一时间反馈,作者会第一时间更正。它是对两种不同的UIImage工厂方法的分析,并列出了这两种工厂方法在内存管理方面的优缺点。文章的后半部分是本文的重点,如何结合两种工厂方法的优点进一步节省内存管理。PS本文所说的Resource是指使用imageWithContentsOfFile:创建图片的图片管理方法。ImageAssets是指使用imageNamed:创建图片的图片管理方法。如果你熟悉这两种方式,可以直接看UIImage和YYImage的内存问题以及后面的内容[TOC]UIImage内存处理在实际的AppleApp开发中,有两种方式可以将图片文件导入到项目中。一种是Resource(不知道应该叫什么,就这样叫吧),另一种是将ImageAssets存放在一个图片资源管理文件中。这两种方法都可以存储任何形式的图像文件,但各有优缺点。接下来我们就来谈谈这两种图像数据管理方式的优缺点。Resource和“imageWithContentsOfFile:”Resource的使用直接将文件拖到项目目录下,并在打包项目时告诉Xcode打包这些图片文件。这样,在应用程序的“.app”文件夹下就有了这些图片,在项目中可以读取这些图片得到图片文件,封装成UIImge对象,方法如下:NSString*path=[NSBundle.mainBundlepathForResource:@"image@2x"type:@"png"];UIImage*image=[UIImageimageWithContentsOfFile:path];底层实现原理大概是:+(instancetype)imageWithContentsOfFile:(NSString*)fileName{NSUIntegerscale=0;{scale=2;//这部分是取fileName中“@”符号后的数字,不存在则为1,逻辑这部分省略}return[[selfalloc]initWithData:[NSDatadataWithContentsOfFile:fileNamescale:scale];}这个方法有一个限制,就是image文件必须在.ipa的根目录或者sandbox里面。只有一种方法,就是通过Xcode将图片文件直接拖到项目中。还有一种情况会创建图片文件,即当项目支持低版本的iOS系统时,低版本的iOS系统不支持ImageAssets包文件的图片读取。所以Xcode在编译的时候会自动将ImageAssets中的图片复制到根目录下。这时,你也可以使用这种方法来创建图像。Resource的特点是在Resource的图片管理方法中,所有图片的创建都是通过读取文件数据获取的,读取一次文件数据会生成一个NSData和一个UIImage,创建图片时销毁对应的NSData,自动当UIImage的引用计数器变为0时销毁UIImage。这样,可以保证图片不会长期存在于内存中。Resource的常见场景是由于该方法的特点,所以Resource方法一般在图片数据比较大的时候使用,图片一般不需要多次使用。比如引导页的背景(图片是全屏的,有时候运行APP的时候会显示,有时候根本用不到)。Resource的优势图片的生命周期可管理无疑是Resource最大的优势。当我们需要图片时,我们可以创建一个。当我们不需要这张图的时候,让他把它毁了。图片不会长期保存在内存中,所以不会有大量的内存浪费。同时,大图一般都不会长时间使用,而大图一般比小图占用内存多很多倍,所以Resource在降低大图的内存占用方面做得非常好。ImageAssets和“imageNamed:”ImageAssets的设计初衷主要是自动适配ReTina屏和非Retina屏,即解决iPhone4、iPhone3GS及之前机型的屏幕适配问题。现在iPhone3GS及之前的机型已经淘汰,非Retina屏不再考虑开发。不过,plus机型的推出,让Retina屏幕提升了一个档次。ImageAssets现在的主要功能是区分正屏和非正屏,即解决2倍Retina屏和3倍Retina屏的视频问题。ImageAssets的使用方法在iOS开发中,项目中一般会导入两到三个内容相同但像素不同的图片文件,一般为:image.png(30x30)image@2x.png(60x60)image@3x.png(90x90)这三张图片内容相同,图片名称前缀相同,区别在于图片名称和图片分辨率。开发者将这三张图片拉入ImageAssets后,Xcode会创建一个图片组,图片前缀为图片(这里也是“image”)。然后在代码中写入:UIImage*image=[UIImageimageNamed:@"image"];它会根据不同的屏幕获取相应的图像数据来创建图像。如果是3GS类型之前的机器会读“image.png”,普通Retina会读“image@2x.png”,加上Retina会读“image@3x.png”,如果某个文件不存在,另一种分辨率会用到ImageAssets的特性,类似于Resources。ImageAssets也会从图片文件中读取图片数据,并转换成UIImage,只不过这些图片数据是打包在ImageAssets中的。另一个重要的区别是图像缓存。相当于有一个字典,key是图片名,value是图片对象。调用imageNamed:方法时,先从这个字典中获取,获取到则直接返回,获取不到则在文件中创建,然后保存到这个字典中再返回。由于字典的key和value是强引用,所以一旦创建,图像就永远不会被破坏。它的内部代码类似于:+(NSMutableDictionary*)imageBuff{staticNSMutableDictionary*_imageBuff;staticdispatch_once_tonceToken;dispatch_once(&onceToken,^{_imageBuff=[[NSMutableDictionaryalloc]init];});return_imageBuff;}+(instancetype)imageNamed:(NSString*)imageName{if(!imageName){returnnil;}UIImage*image=self.imageBuff[imageName];if(image){returnimage;}NSString*path=@"thisistheimagepath"//这个逻辑忽略image=[selfimageWithContentsOfFile:path];if(image){self.imageBuff[imageName]=image;}returnimage;}ImageAssets使用场景ImageAssets的主要使用场景是图标类图片,一般图标图片大小在3kb到20kb之间,都是小文件。ImageAssets的优点当一个icon需要在多个地方展示时,其对应的UIImage对象只会创建一次,多个地方的Icons会共享一个UIImage对象。减少沙箱读取操作。+(YYImage*)imageNamed:(NSString*)name{if(name.length==0)returnnil;if([namehasSuffix:@"/"])returnnil;NSString*res=name.stringByDeletingPathExtension;NSString*ext=name.pathExtension;NSString*path=nil;CGFloatscale=1;//如果没有扩展,guessbysystemsupported(sameasUIImage).NSArray*exts=ext.length>0?@[ext]:@[@"",@"png",@"jpeg",@"jpg",@"gif",@"webp",@"apng"];NSArray*scales=[NSBundlepreferredScales];for(ints=0;scount;s++){scale=((NSNumber*)scales[s]).floatValue;NSString*scaledName=[ressstringByAppendingNameScale:scale];for(NSString*einexts){path=[[NSBundlemainBundle]pathForResource:scaledNameofType:e];if(path)break;}if(path)break;}if(path.length==0)returnil;NSData*data=[NSDatadataWithContentsOfFile:path];if(data.length==0)returnil;return[[selfalloc]initWithData:datascale:scale];}UIImage的内存问题资源的不足当我们需要图片的时候,我们会在沙盒中读取图片拿这张图片文件并将其转换为UIImage对象以供使用。现在假设一个场景:image@2x.png图片占用内存5kbimage@2x.png在多个界面使用,这张图片会同时显示在7个地方通过代码分析可以知道Resource方法在这种情况下将占用5kb/pieceX7=35kb的内存。但是在ImageAssets的方式中,都是从字典缓存中的UIImage中取出来的,无论在多少地方显示图片,都只会占用5kb/pieceX1=5kb的内存。这时候Resource占用的内存会比较大。ImageAssets的缺点是第一次读取的图片保存到buffer中,然后就一直存在不要破坏。如果图片太大,需要几百kb,这块内存得不到释放,必然会造成内存浪费,而这个浪费周期与APP的生命周期是同步的。解决方法是解决ResourceQuestion多图共存的问题,可以学习ImageAssets中的字典,形成键值对。当字典中名字对应的图片存在时,则不会创建。如果它不存在,它将被创建。字典的存在必然会导致UIImage永不销毁,所以还要考虑到字典不会影响到UIImage自动销毁的问题。由此,我们可以得出以下结论:需要一个字典来存储创建的Image的名称-图像映射。当除了这个字典之外没有其他对象持有图像时,从这个字典中删除对应的名称-图像映射的第一个需求的实现非常简单。接下来,我们将讨论第二个要求。首先,我们可以考虑如何判断除了字典之外没有其他物体持有图像?字典强烈引用键和值。当图片放入字典时,图片的引用计数器会+1。我们可以判断图片在字典中的引用计数器是否为1,如果为1,则判断当前只有字典持有图像,因此我们可以从字典中删除Thisimage.这样就可以提出MRC+dictionary的解决方案。我们也可以改变我们的想法。字典是一个强大的参考容器。字典的存在必然会导致内部值的引用计数器大于等于1,如果字典是弱引用容器,字典的存在不会影响内部值的引用计数器值,所以图像的破坏不会受到字典的影响。所以还有另外一种弱引用字典的方案。接下来,我们将对这两种方案进行深入的分析和实现:方案一:MRC+字典该方案的具体思路是:找一个合适的时间,遍历所有value的引用计数器,当某个value的引用计数器为1(表示只有字典持有这个图像),然后删除这个键值对。***第一步是在ARC下获取一个对象的引用计数器:首先,ARC下不允许有retainCount属性,但是因为ARC的原理是编译器自动帮我们管理引用计数器,所以即使在ARC环境下,引用计数器也是Enable状态,仍然使用引用计数器来管理内存。所以我们可以使用KVC来获取引用计数器:@implementationNSObject(MRC)//不能直接重写retainCount方法,所以加了一个前缀-(NSUInteger)obj_retainCount{return[[selfvalueForKey:@"retainCount"]unsignedLongValue];}@end第二步遍历引用计数器ofvalue//因为遍历键值对时不能增删,所以把要删除的key放在一个数组中NSMutableArray*keyArr=[NSMutableArrayarray];[self.imageDicenumerateKeysAndObjectsUsingBlock:^(id_Nonnullkey,NSObject*_Nonnulllobj,BOOL*_Nonnullstop){NSIntegercount=obj.obj_retainCount;if(count==2){//字典持有+obj参数持有=2[keyArraddObject:key];}}];[keyArrenumerateObjectsUsingBlock:^(id_Nonnullobj,NSUIntegeridx,BOOL*_Nonnullstop){[self.imageDicremoveObjectForKey:obj];}];然后处理遍历时序。遍历的时机很难选择,也不能因为遍历占用大量的系统资源。你可以每次通过名字创建(或者从字典中获取)遍历一次,但是这个方法可能会很长时间都不会被调用(比如用户长时间停留在某个界面)。所以我们可以在每次runloop来的时候做一次遍历,同时我们也需要标记遍历状态,防止第一个runloop到来的时候,第一次遍历还没结束就开始新的遍历(这时候应该是第二次遍历直接放弃)。代码如下:CFRunLoopObserverRefoberver=CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(),kCFRunLoopAllActivities,YES,0,^(CFRunLoopObserverRefobserver,CFRunLoopActivityactivity){if(activity==kCFRunLoopBeforeWaiting){staticenuming=NO;if(!enuming){enuming=YES;//这里是遍历代码enuming=NO;}}});CFRunLoopAddObserver(CFRunLoopGetMain(),oberver,kCFRunLoopCommonModes);具体实现请参考代码。方案2弱引用字典上述方案中,每次runloop到来都会创建一个线程遍历key-value对。一般来说,每个APP创建的图片数量都非常多,所以遍历键值对不会阻塞主线程,但仍然是一个非常耗时耗资源的工作。弱引用容器是指基于NSArray、NSDictionary、NSSet的容器??类。这个容器和这些类的区别***原因是把对象放到容器里不会改变对象的引用计数器。同时,容器用弱引用指针指向对象。当对象被销毁时,它会自动从容器中删除,无需额外操作。目前常用的弱引用容器实现方式是块封装和解封装使用块来封装一个对象,对象在块中的持有操作是一个弱引用指针。然后将块作为对象放入容器中。容器直接持有块,而不是直接持有对象。取物时解包block得到对应的物件。第一步打包解包typedefid(^WeakReference)(void);WeakReferencemakeWeakReference(idobject){__weakidweakref=object;return^{returnweakref;};}idweakReferenceNonretainedObjectValue(WeakReferenceref){returnref?ref():nil;}第二步就是改造原来的容器-(void)weak_setObject:(id)anObjectforKey:(NSString*)aKey{[selfsetObject:makeWeakReference(anObject)forKey:aKey];}-(void)weak_setObjectWithDictionary:(NSDictionary*)dic{for(NSString*keyindic.allKeys){[selfsetObject:makeWeakReference(dic[key])forKey:key];}}-(id)weak_getObjectForKey:(NSString*)key{returnweakReferenceNonretainedObjectValue(self[key]);}这实现了弱引用字典,然后将imageNamed:中的强引用字典替换为弱引用字典。
