這篇文章是筆者在開(kāi)發(fā)App過(guò)程中發(fā)現(xiàn)的一些內(nèi)存問(wèn)題, 然后學(xué)習(xí)了YYKit框架時(shí)候也發(fā)現(xiàn)了圖片的緩存處理的<del>不夠得當(dāng)</del> (YYKit 作者聯(lián)系了我, 說(shuō)明了YYKit重寫(xiě)imageNamed:的目的不是為了內(nèi)存管理, 而是增加兼容性, 同時(shí)也是為了YYKit中的動(dòng)畫(huà)服務(wù)). 以下內(nèi)容是筆者在開(kāi)發(fā)中做了一些實(shí)驗(yàn)以及總結(jié). 如有錯(cuò)誤望即時(shí)提出, 筆者會(huì)第一時(shí)間改正.
文章的前篇主要是對(duì)兩種不同的UIImage工廠方法的分析, <del>以及對(duì) YYKit 中的 YYImage 的分析</del>. 羅列出這些工廠方法的內(nèi)存管理的優(yōu)缺點(diǎn).
文章的后篇是本文要說(shuō)明的重點(diǎn), 如何結(jié)合兩種工廠方法的優(yōu)點(diǎn)做更進(jìn)一步的節(jié)約內(nèi)存的管理.
PS
本文所說(shuō)的 Resource 是指使用
imageWithContentsOfFile:創(chuàng)建圖片的圖片管理方式.ImageAssets 是指使用
imageNamed:創(chuàng)建圖片的圖片管理方式.如果你對(duì)這兩個(gè)方法已經(jīng)了如指掌, 可以直接看UIImage 與 YYImage 的內(nèi)存問(wèn)題和后面的內(nèi)容
UIImage 的內(nèi)存處理
在實(shí)際的蘋(píng)果App開(kāi)發(fā)中, 將圖片文件導(dǎo)入到工程中無(wú)非使用兩種方式. 一種是 Resource (我也不知道應(yīng)該稱呼什么,就這么叫吧),還有一種是 ImageAssets 形式存儲(chǔ)在一個(gè)圖片資源管理文件中. 這兩種方式都可以存儲(chǔ)任何形式的圖片文件, 但是都有各自的優(yōu)缺點(diǎn)在內(nèi). 接下來(lái)我們就來(lái)談?wù)勥@兩種圖片數(shù)據(jù)管理方式的優(yōu)缺點(diǎn).
Resource 與 "imageWithContentsOfFile:"
Resource 的使用方式
將文件直接拖入到工程目錄下, 并告訴Xcode打包項(xiàng)目時(shí)候把這些圖片文件打包進(jìn)去. 這樣在應(yīng)用的".app"文件夾中就有這些圖片. 在項(xiàng)目中, 讀取這些圖片可以通過(guò)以下方式來(lái)獲取圖片文件并封裝成UIImge對(duì)象:
NSString *path = [NSBundle.mainBundle pathForResource:@"image@2x" type:@"png"];
UIImage *image = [UIImage imageWithContentsOfFile:path];
而底層的實(shí)現(xiàn)原理近似是:
+ (instancetype)imageWithContentsOfFile:(NSString *)fileName {
NSUInteger scale = 0;
{
scale = 2;//這一部分是取 fileName 中"@"符號(hào)后面那個(gè)數(shù)字, 如果不存在則為1, 這一部分的邏輯省略
}
return [[self alloc] initWithData:[NSData dataWithContentsOfFile:fileName scale:scale];
}
這種方式有一個(gè)局限性, 就是圖片文件必須在.ipa的根目錄下或者在沙盒中. 在.ipa的根目錄下創(chuàng)建圖片文件僅僅只有一種方式, 就是通過(guò) Xcode 把圖片文件直接拖入工程中. 還有一種情況也會(huì)創(chuàng)建圖片文件, 就是當(dāng)工程支持低版本的 iOS 系統(tǒng)時(shí), 低版本的iOS系統(tǒng)并不支持 ImageAssets 打包文件的圖片讀取, 所以 Xcode 在編譯時(shí)候會(huì)自動(dòng)地將 ImageAssets 中的圖片復(fù)制一份到根目錄中. 此時(shí)也可以使用這個(gè)方法創(chuàng)建圖片.
Resource 的特性
在 Resource 的圖片管理方式中, 所有的圖片創(chuàng)建都是通過(guò)讀取文件數(shù)據(jù)得到的, 讀取一次文件數(shù)據(jù)就會(huì)產(chǎn)生一次NSData以及產(chǎn)生一個(gè)UIImage, 當(dāng)圖片創(chuàng)建好后銷毀對(duì)應(yīng)的NSData, 當(dāng)UIImage的引用計(jì)數(shù)器變?yōu)?的時(shí)候自動(dòng)銷毀UIImage. 這樣的話就可以保證圖片不會(huì)長(zhǎng)期地存在在內(nèi)存中.
Resource 的常用情景
由于這種方法的特性, 所以 Resource 的方法一般用在圖片數(shù)據(jù)很大, 圖片一般不需要多次使用的情況. 比如說(shuō)引導(dǎo)頁(yè)背景(圖片全屏, 有時(shí)候運(yùn)行APP會(huì)顯示, 有時(shí)候根本就用不到).
Resource 的優(yōu)點(diǎn)
圖片的生命周期可以得到管理無(wú)疑是 Resource 最大的優(yōu)點(diǎn), 當(dāng)我們需要圖片的時(shí)候就創(chuàng)建一個(gè), 當(dāng)我們不需要這個(gè)圖片的時(shí)候就讓他銷毀. 圖片不會(huì)長(zhǎng)期的保存在內(nèi)存當(dāng)中, 所以不會(huì)有很多的內(nèi)存浪費(fèi). 同時(shí), 大圖一般不會(huì)長(zhǎng)期使用, 而且大圖占用內(nèi)存一般比小圖多了好多倍, 所以在減少大圖的內(nèi)存占用中, Resource 做的非常好.
ImageAssets 與 "imageNamed:"
ImageAssets 的設(shè)計(jì)初衷主要是為了自動(dòng)適配 Retina 屏幕和非 Retina 屏幕, 也就是解決 iPhone 4 和 iPhone 3GS 以及以前機(jī)型的屏幕適配問(wèn)題. 現(xiàn)在 iPhone 3GS 以及之前的機(jī)型都已被淘汰, 非 Retina 屏幕已不再是開(kāi)發(fā)考慮的范圍. 但是 plus 機(jī)型的推出將 Retina 屏幕又提高了一個(gè)水平, ImageAssets 現(xiàn)在的主要功能則是區(qū)分 plus 屏幕和非 plus 屏幕, 也就是解決 2 倍 Retina 屏幕和 3 倍 Retina 屏幕的視屏問(wèn)題.
ImageAssets 的使用方式
iOS 開(kāi)發(fā)中一般在工程內(nèi)導(dǎo)入兩個(gè)到三個(gè)同內(nèi)容不同像素的圖片文件, 一般如下:
- image.png (30 x 30)
- image@2x.png (60 x 60)
- image@3x.png (90 x 90)
這三張圖片都是相同內(nèi)容, 而且圖片名稱的前綴相同, 區(qū)別在與圖片名以及圖片的分辨率. 開(kāi)發(fā)者將這三張圖片拉入 ImageAssets 后, Xcode 會(huì)以圖片前綴創(chuàng)建一個(gè)圖片組(這里也就是 "image"). 然后在代碼中寫(xiě):
UIImage *image = [UIImage imageNamed:@"image"];
就會(huì)根據(jù)不同屏幕來(lái)獲取對(duì)應(yīng)不同的圖片數(shù)據(jù)來(lái)創(chuàng)建圖片. 如果是 3GS 之前的機(jī)型就會(huì)讀取 "image.png", 普通 Retina 會(huì)讀取 "image@2x.png", plus Retina 會(huì)讀取 "image@3x.png", 如果某一個(gè)文件不存在, 就會(huì)用另一個(gè)分辨率的圖片代替之.
ImageAssets 的特性
與 Resources 相似, ImageAssets 也是從圖片文件中讀取圖片數(shù)據(jù)轉(zhuǎn)為 UIImage, 只不過(guò)這些圖片數(shù)據(jù)都打包在 ImageAssets 中. 還有一個(gè)最大的區(qū)別就是圖片緩存. 相當(dāng)于有一個(gè)字典, key 是圖片名, value是圖片對(duì)象. 調(diào)用imageNamed:方法時(shí)候先從這個(gè)字典里取, 如果取到就直接返回, 如果取不到再去文件中創(chuàng)建, 然后保存到這個(gè)字典后再返回. 由于字典的key和value都是強(qiáng)引用, 所以一旦創(chuàng)建后的圖片永不銷毀.
其內(nèi)部代碼相似于:
+ (NSMutableDictionary *)imageBuff {
static NSMutableDictionary *_imageBuff;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_imageBuff = [[NSMutableDictionary alloc] init];
});
return _imageBuff;
}
+ (instancetype)imageNamed:(NSString *)imageName {
if (!imageName) {
return nil;
}
UIImage *image = self.imageBuff[imageName];
if (image) {
return image;
}
NSString *path = @"this is the image path"http://這段邏輯忽略
image = [self imageWithContentsOfFile:path];
if (image) {
self.imageBuff[imageName] = image;
}
return image;
}
ImageAssets 的使用場(chǎng)景
ImageAssets 最主要的使用場(chǎng)景就是 icon 類的圖片, 一般 icon 類的圖片大小在 3kb 到 20 kb 不等, 都是一些小文件.
ImageAssets 的優(yōu)點(diǎn)
當(dāng)一個(gè) icon 在多個(gè)地方需要被顯示的時(shí)候, 其對(duì)應(yīng)的UIImage對(duì)象只會(huì)被創(chuàng)建一次, 而且多個(gè)地方的 icon 都將會(huì)共用一個(gè) UIImage 對(duì)象. 減少沙盒的讀取操作.
<del>YYImage 的內(nèi)存處理</del>
由于YYImage的目的并不是為了關(guān)閉緩存, 所以此段沒(méi)有分析的意義, 現(xiàn)已刪除.
<del>YYImage 的核心就是學(xué)習(xí)imageWithContentsOfFile:的方法原理去實(shí)現(xiàn)imageNamed:方法. 達(dá)到imageNamed:方法中沒(méi)有緩存功能, 最終使得不需要圖片的時(shí)候即可銷毀圖片對(duì)象. </del>
<del>imageWithContentsOfFile 代替 imageNamed</del>
<del>首先看 YYImage 的代碼:</del>
+ (YYImage *)imageNamed:(NSString *)name {
if (name.length == 0) return nil;
if ([name hasSuffix:@"/"]) return nil;
NSString *res = name.stringByDeletingPathExtension;
NSString *ext = name.pathExtension;
NSString *path = nil;
CGFloat scale = 1;
// If no extension, guess by system supported (same as UIImage).
NSArray *exts = ext.length > 0 ? @[ext] : @[@"", @"png", @"jpeg", @"jpg", @"gif", @"webp", @"apng"];
NSArray *scales = [NSBundle preferredScales];
for (int s = 0; s < scales.count; s++) {
scale = ((NSNumber *)scales[s]).floatValue;
NSString *scaledName = [res stringByAppendingNameScale:scale];
for (NSString *e in exts) {
path = [[NSBundle mainBundle] pathForResource:scaledName ofType:e];
if (path) break;
}
if (path) break;
}
if (path.length == 0) return nil;
NSData *data = [NSData dataWithContentsOfFile:path];
if (data.length == 0) return nil;
return [[self alloc] initWithData:data scale:scale];
}
<del>從代碼可以看出 [YYImage imageNamed:]這個(gè)方法底層是利用通過(guò)一定的計(jì)算獲取到最佳尺寸, 然后枚舉圖片匹配圖片文件名, 拼接成路徑后利用NSData創(chuàng)建出UIImage. 本質(zhì)上和imageWithContentsOfFile:沒(méi)有啥區(qū)別.</del>
UIImage <del>與 YYImage</del> 的內(nèi)存問(wèn)題
Resource 的缺點(diǎn)
當(dāng)我們需要圖片的時(shí)候就會(huì)去沙盒中讀取這個(gè)圖片文件, 轉(zhuǎn)換成UIImage對(duì)象來(lái)使用. 現(xiàn)在假設(shè)一種場(chǎng)景:
- image@2x.png 圖片占用 5kb 的內(nèi)存
- image@2x.png 在多個(gè)界面都用到, 且有7處會(huì)同時(shí)顯示這個(gè)圖片
通過(guò)代碼分析就可以知道 Resource 這個(gè)方式在這個(gè)情景下會(huì)占用 5kb/個(gè) X 7個(gè) = 35kb 內(nèi)存. 然而, 在 ImageAssets 方式下, 全部取自字典緩存中的UIImage, 無(wú)論有幾處顯示圖片, 都只會(huì)占用 5kb/個(gè) X 1個(gè) = 5kb 內(nèi)存. 此時(shí) Resource 占用內(nèi)存將會(huì)更大.
<del>由于 YYImage 的核心就是利用imageWithContentsOfFile:代替imageNamed:, 所以這也是 YYImage 的缺陷之處</del>
ImageAssets 的缺點(diǎn)
第一次讀取的圖片保存到緩沖區(qū), 然后永不銷毀. 如果這個(gè)圖片過(guò)大, 占用幾百 kb, 這一塊的內(nèi)存將不會(huì)釋放, 必然導(dǎo)致內(nèi)存的浪費(fèi), 而且這個(gè)浪費(fèi)的周期與APP的生命周期同步.
解決方案
為了解決 Resource 的多圖共存問(wèn)題, 可以學(xué)習(xí) ImageAssets 中的字典來(lái)形成鍵值對(duì), 當(dāng)字典中name對(duì)應(yīng)的image存在就不創(chuàng)建, 如果不存在就創(chuàng)建. 字典的存在必然導(dǎo)致 UIImage 永不銷毀, 所以還要考慮字典不會(huì)影響到 UIImage 的自動(dòng)銷毀問(wèn)題. 由此可以做出如下總結(jié):
- 需要一個(gè)字典存儲(chǔ)已經(jīng)創(chuàng)建的 Image 的 name-image 映射
- 當(dāng)除了這個(gè)字典外, 沒(méi)有別的對(duì)象持有 image, 則從這個(gè)字典中刪除對(duì)應(yīng) name-image 映射
第一個(gè)要求的實(shí)現(xiàn)方式很簡(jiǎn)單, 接下來(lái)探討第二個(gè)要求.
首先可以考慮如何判斷除了字典外沒(méi)有別的對(duì)象持有 image? 字典是強(qiáng)引用 key 和 value 的, 當(dāng) image 放入字典的時(shí)候, image 的引用計(jì)數(shù)器就會(huì) + 1. 我們可以判斷字典中的 image 的引用計(jì)數(shù)器是否為 1, 如果為 1 則可以判斷出目前只有字典持有這個(gè) image, 因此可以從這個(gè)字典里刪除這個(gè) image.
這樣即可提出一個(gè)方案 MRC+字典
我們還可以換一種思想, 字典是強(qiáng)引用容器, 字典存在必然導(dǎo)致內(nèi)部value的引用計(jì)數(shù)器大于等于1. 如果字典是一個(gè)弱引用容器, 字典的存在并不會(huì)影響到內(nèi)部value的引用計(jì)數(shù)器, 那么 image 的銷毀就不會(huì)因?yàn)樽值涠艿接绊?
于是又有一個(gè)方案 弱引用字典
接下來(lái)對(duì)這兩個(gè)方案作深入的分析和實(shí)現(xiàn):
方案一之 MRC+字典
該方案具體思路是: 找到一個(gè)合適的時(shí)機(jī), 遍歷所有 value 的 引用計(jì)數(shù)器, 當(dāng)某個(gè) value 的引用計(jì)數(shù)器為 1 時(shí)候(說(shuō)明只有字典持有這個(gè)image), 則刪除這個(gè)key-value對(duì).
第一步, 在ARC下獲取某個(gè)對(duì)象的引用計(jì)數(shù)器:
首先 ARC 下是不允許使用retainCount這個(gè)屬性的, 但是由于 ARC 的原理是編譯器自動(dòng)為我們管理引用計(jì)數(shù)器, 所以就算是 ARC 環(huán)境下, 引用計(jì)數(shù)器也是 Enable 狀態(tài), 并且仍然是利用引用計(jì)數(shù)器來(lái)管理內(nèi)存. 所以我們可以使用 KVC 來(lái)獲取引用計(jì)數(shù)器:
@implementation NSObject (MRC)
// 無(wú)法直接重寫(xiě) retainCount 的方法, 所以加了一個(gè)前綴
- (NSUInteger)obj_retainCount {
return [[self valueForKey:@"retainCount"] unsignedLongValue];
}
@end
第二步 遍歷 value的引用計(jì)數(shù)器
// 由于遍歷鍵值對(duì)時(shí)候不能做添加和刪除操作, 所以把要?jiǎng)h除的key放到一個(gè)數(shù)組中
NSMutableArray *keyArr = [NSMutableArray array];
[self.imageDic enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, NSObject * _Nonnull obj, BOOL * _Nonnull stop) {
NSInteger count = obj.obj_retainCount;
if(count == 2) {// 字典持有 + obj參數(shù)持有 = 2
[keyArr addObject:key];
}
}];
[keyArr enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[self.imageDic removeObjectForKey:obj];
}];
然后處理遍歷時(shí)機(jī). 選擇遍歷時(shí)機(jī)是一個(gè)很困難的, 不能因?yàn)楸闅v而大量占有系統(tǒng)資源. 可以在每一次通過(guò) name 創(chuàng)建(或者從字典中獲取)時(shí)候遍歷一次, 但這個(gè)方法有可能會(huì)長(zhǎng)時(shí)間不調(diào)用(比如一個(gè)用戶在某一個(gè)界面上呆很久). 所以我們可以在每一次 runloop 到來(lái)時(shí)候來(lái)做一次遍歷, 同時(shí)我們還需要標(biāo)記遍歷狀態(tài), 防止第二次 runloop 到來(lái)時(shí)候第一次的遍歷還沒(méi)結(jié)束就開(kāi)始新的遍歷了(此時(shí)應(yīng)該直接放棄第二次遍歷).代碼如下:
CFRunLoopObserverRef oberver= CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
if (activity == kCFRunLoopBeforeWaiting) {
static enuming = NO;
if (!enuming) {
enuming = YES;
// 這里是遍歷代碼
enuming = NO;
}
}
});
CFRunLoopAddObserver(CFRunLoopGetMain(), oberver, kCFRunLoopCommonModes);
具體實(shí)現(xiàn)請(qǐng)看代碼.
方案二之 弱引用字典
在上面那個(gè)方案中, 會(huì)在每一次 runloop 到來(lái)之時(shí)開(kāi)辟一個(gè)線程去遍歷鍵值對(duì). 通常來(lái)說(shuō), 每一個(gè) APP 創(chuàng)建的圖片個(gè)數(shù)很大, 所以遍歷鍵值對(duì)雖然不會(huì)阻塞主線程, 但仍然是一個(gè)非常耗時(shí)耗資源的工作.
弱引用容器是指基于NSArray, NSDictionary, NSSet的容器類, 該容器與這些類最大的區(qū)別在于, 將對(duì)象放入容器中并不會(huì)改變對(duì)象的引用計(jì)數(shù)器, 同時(shí)容器是以一個(gè)弱引用指針指向這個(gè)對(duì)象, 當(dāng)對(duì)象銷毀時(shí)自動(dòng)從容器中刪除, 無(wú)需額外的操作.
目前常用的弱引用容器的實(shí)現(xiàn)方式是block封裝解封
利用block封裝一個(gè)對(duì)象, 且block中對(duì)象的持有操作是一個(gè)弱引用指針. 而后將block當(dāng)做對(duì)象放入容器中. 容器直接持有block, 而不直接持有對(duì)象. 取對(duì)象時(shí)解包block即可得到對(duì)應(yīng)對(duì)象.
第一步 封裝與解封
typedef id (^WeakReference)(void);
WeakReference makeWeakReference(id object) {
__weak id weakref = object;
return ^{
return weakref;
};
}
id weakReferenceNonretainedObjectValue(WeakReference ref) {
return ref ? ref() : nil;
}
第二步 改造原容器
- (void)weak_setObject:(id)anObject forKey:(NSString *)aKey {
[self setObject:makeWeakReference(anObject) forKey:aKey];
}
- (void)weak_setObjectWithDictionary:(NSDictionary *)dic {
for (NSString *key in dic.allKeys) {
[self setObject:makeWeakReference(dic[key]) forKey:key];
}
}
- (id)weak_getObjectForKey:(NSString *)key {
return weakReferenceNonretainedObjectValue(self[key]);
}
這樣就實(shí)現(xiàn)了一個(gè)弱引用字典, 之后用弱引用字典代替imageNamed:中的強(qiáng)引用字典即可.