App的內(nèi)存優(yōu)化
這篇文章是筆者在開發(fā)App過程中發(fā)現(xiàn)的一些內(nèi)存問題, 然后學(xué)習(xí)了YYKit框架時候也發(fā)現(xiàn)了圖片的緩存處理 (YYKit 作者聯(lián)系了我, 說明了YYKit重寫imageNamed:的目的不是為了內(nèi)存管理, 而是增加兼容性, 同時也是為了YYKit中的動畫服務(wù)). 以下內(nèi)容是筆者在開發(fā)中做了一些實(shí)驗(yàn)以及總結(jié). 如有錯誤望即時提出, 筆者會***時間改正.
文章的前篇主要是對兩種不同的UIImage工廠方法的分析, 羅列出這些工廠方法的內(nèi)存管理的優(yōu)缺點(diǎn).
文章的后篇是本文要說明的重點(diǎn), 如何結(jié)合兩種工廠方法的優(yōu)點(diǎn)做更進(jìn)一步的節(jié)約內(nèi)存的管理.
PS
本文所說的 Resource 是指使用imageWithContentsOfFile:創(chuàng)建圖片的圖片管理方式.
ImageAssets 是指使用imageNamed:創(chuàng)建圖片的圖片管理方式.
如果你對這兩個方法已經(jīng)了如指掌, 可以直接看UIImage 與 YYImage 的內(nèi)存問題和后面的內(nèi)容
[TOC]
UIImage 的內(nèi)存處理
在實(shí)際的蘋果App開發(fā)中, 將圖片文件導(dǎo)入到工程中無非使用兩種方式. 一種是 Resource (我也不知道應(yīng)該稱呼什么,就這么叫吧),還有一種是 ImageAssets 形式存儲在一個圖片資源管理文件中. 這兩種方式都可以存儲任何形式的圖片文件, 但是都有各自的優(yōu)缺點(diǎn)在內(nèi). 接下來我們就來談?wù)勥@兩種圖片數(shù)據(jù)管理方式的優(yōu)缺點(diǎn).
Resource 與 “imageWithContentsOfFile:”
Resource 的使用方式
將文件直接拖入到工程目錄下, 并告訴Xcode打包項(xiàng)目時候把這些圖片文件打包進(jìn)去. 這樣在應(yīng)用的”.app”文件夾中就有這些圖片. 在項(xiàng)目中, 讀取這些圖片可以通過以下方式來獲取圖片文件并封裝成UIImge對象:
- 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 中"@"符號后面那個數(shù)字, 如果不存在則為1, 這一部分的邏輯省略
- }
- return [[self alloc] initWithData:[NSData dataWithContentsOfFile:fileName scale:scale];
- }
這種方式有一個局限性, 就是圖片文件必須在.ipa的根目錄下或者在沙盒中. 在.ipa的根目錄下創(chuàng)建圖片文件僅僅只有一種方式, 就是通過 Xcode 把圖片文件直接拖入工程中. 還有一種情況也會創(chuàng)建圖片文件, 就是當(dāng)工程支持低版本的 iOS 系統(tǒng)時, 低版本的iOS系統(tǒng)并不支持 ImageAssets 打包文件的圖片讀取, 所以 Xcode 在編譯時候會自動地將 ImageAssets 中的圖片復(fù)制一份到根目錄中. 此時也可以使用這個方法創(chuàng)建圖片.
Resource 的特性
在 Resource 的圖片管理方式中, 所有的圖片創(chuàng)建都是通過讀取文件數(shù)據(jù)得到的, 讀取一次文件數(shù)據(jù)就會產(chǎn)生一次NSData以及產(chǎn)生一個UIImage, 當(dāng)圖片創(chuàng)建好后銷毀對應(yīng)的NSData, 當(dāng)UIImage的引用計(jì)數(shù)器變?yōu)?的時候自動銷毀UIImage. 這樣的話就可以保證圖片不會長期地存在在內(nèi)存中.
Resource 的常用情景
由于這種方法的特性, 所以 Resource 的方法一般用在圖片數(shù)據(jù)很大, 圖片一般不需要多次使用的情況. 比如說引導(dǎo)頁背景(圖片全屏, 有時候運(yùn)行APP會顯示, 有時候根本就用不到).
Resource 的優(yōu)點(diǎn)
圖片的生命周期可以得到管理無疑是 Resource ***的優(yōu)點(diǎn), 當(dāng)我們需要圖片的時候就創(chuàng)建一個, 當(dāng)我們不需要這個圖片的時候就讓他銷毀. 圖片不會長期的保存在內(nèi)存當(dāng)中, 所以不會有很多的內(nèi)存浪費(fèi). 同時, 大圖一般不會長期使用, 而且大圖占用內(nèi)存一般比小圖多了好多倍, 所以在減少大圖的內(nèi)存占用中, Resource 做的非常好.
ImageAssets 與 “imageNamed:”
ImageAssets 的設(shè)計(jì)初衷主要是為了自動適配 Retina 屏幕和非 Retina 屏幕, 也就是解決 iPhone 4 和 iPhone 3GS 以及以前機(jī)型的屏幕適配問題. 現(xiàn)在 iPhone 3GS 以及之前的機(jī)型都已被淘汰, 非 Retina 屏幕已不再是開發(fā)考慮的范圍. 但是 plus 機(jī)型的推出將 Retina 屏幕又提高了一個水平, ImageAssets 現(xiàn)在的主要功能則是區(qū)分 plus 屏幕和非 plus 屏幕, 也就是解決 2 倍 Retina 屏幕和 3 倍 Retina 屏幕的視屏問題.
ImageAssets 的使用方式
iOS 開發(fā)中一般在工程內(nèi)導(dǎo)入兩個到三個同內(nèi)容不同像素的圖片文件, 一般如下:
- image.png (30 x 30)
- image@2x.png (60 x 60)
- image@3x.png (90 x 90)
這三張圖片都是相同內(nèi)容, 而且圖片名稱的前綴相同, 區(qū)別在與圖片名以及圖片的分辨率. 開發(fā)者將這三張圖片拉入 ImageAssets 后, Xcode 會以圖片前綴創(chuàng)建一個圖片組(這里也就是 “image”). 然后在代碼中寫:
- UIImage *image = [UIImage imageNamed:@"image"];
就會根據(jù)不同屏幕來獲取對應(yīng)不同的圖片數(shù)據(jù)來創(chuàng)建圖片. 如果是 3GS 之前的機(jī)型就會讀取 “image.png”, 普通 Retina 會讀取 “image@2x.png“, plus Retina 會讀取 “image@3x.png“, 如果某一個文件不存在, 就會用另一個分辨率的圖片代替之.
ImageAssets 的特性
與 Resources 相似, ImageAssets 也是從圖片文件中讀取圖片數(shù)據(jù)轉(zhuǎn)為 UIImage, 只不過這些圖片數(shù)據(jù)都打包在 ImageAssets 中. 還有一個***的區(qū)別就是圖片緩存. 相當(dāng)于有一個字典, key 是圖片名, value是圖片對象. 調(diào)用imageNamed:方法時候先從這個字典里取, 如果取到就直接返回, 如果取不到再去文件中創(chuàng)建, 然后保存到這個字典后再返回. 由于字典的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"//這段邏輯忽略
- image = [self imageWithContentsOfFile:path];
- if (image) {
- self.imageBuff[imageName] = image;
- }
- return image;
- }
ImageAssets 的使用場景
ImageAssets 最主要的使用場景就是 icon 類的圖片, 一般 icon 類的圖片大小在 3kb 到 20 kb 不等, 都是一些小文件.
ImageAssets 的優(yōu)點(diǎn)
當(dāng)一個 icon 在多個地方需要被顯示的時候, 其對應(yīng)的UIImage對象只會被創(chuàng)建一次, 而且多個地方的 icon 都將會共用一個 UIImage 對象. 減少沙盒的讀取操作.
- + (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 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];
- }
UIImage 的內(nèi)存問題
Resource 的缺點(diǎn)
當(dāng)我們需要圖片的時候就會去沙盒中讀取這個圖片文件, 轉(zhuǎn)換成UIImage對象來使用. 現(xiàn)在假設(shè)一種場景:
- image@2x.png 圖片占用 5kb 的內(nèi)存
- image@2x.png 在多個界面都用到, 且有7處會同時顯示這個圖片
通過代碼分析就可以知道 Resource 這個方式在這個情景下會占用 5kb/個 X 7個 = 35kb 內(nèi)存. 然而, 在 ImageAssets 方式下, 全部取自字典緩存中的UIImage, 無論有幾處顯示圖片, 都只會占用 5kb/個 X 1個 = 5kb 內(nèi)存. 此時 Resource 占用內(nèi)存將會更大.
ImageAssets 的缺點(diǎn)
***次讀取的圖片保存到緩沖區(qū), 然后永不銷毀. 如果這個圖片過大, 占用幾百 kb, 這一塊的內(nèi)存將不會釋放, 必然導(dǎo)致內(nèi)存的浪費(fèi), 而且這個浪費(fèi)的周期與APP的生命周期同步.
解決方案
為了解決 Resource 的多圖共存問題, 可以學(xué)習(xí) ImageAssets 中的字典來形成鍵值對, 當(dāng)字典中name對應(yīng)的image存在就不創(chuàng)建, 如果不存在就創(chuàng)建. 字典的存在必然導(dǎo)致 UIImage 永不銷毀, 所以還要考慮字典不會影響到 UIImage 的自動銷毀問題. 由此可以做出如下總結(jié):
- 需要一個字典存儲已經(jīng)創(chuàng)建的 Image 的 name-image 映射
- 當(dāng)除了這個字典外, 沒有別的對象持有 image, 則從這個字典中刪除對應(yīng) name-image 映射
***個要求的實(shí)現(xiàn)方式很簡單, 接下來探討第二個要求.
首先可以考慮如何判斷除了字典外沒有別的對象持有 image? 字典是強(qiáng)引用 key 和 value 的, 當(dāng) image 放入字典的時候, image 的引用計(jì)數(shù)器就會 + 1. 我們可以判斷字典中的 image 的引用計(jì)數(shù)器是否為 1, 如果為 1 則可以判斷出目前只有字典持有這個 image, 因此可以從這個字典里刪除這個 image.
這樣即可提出一個方案 MRC+字典
我們還可以換一種思想, 字典是強(qiáng)引用容器, 字典存在必然導(dǎo)致內(nèi)部value的引用計(jì)數(shù)器大于等于1. 如果字典是一個弱引用容器, 字典的存在并不會影響到內(nèi)部value的引用計(jì)數(shù)器, 那么 image 的銷毀就不會因?yàn)樽值涠艿接绊?
于是又有一個方案 弱引用字典
接下來對這兩個方案作深入的分析和實(shí)現(xiàn):
方案一之 MRC+字典
該方案具體思路是: 找到一個合適的時機(jī), 遍歷所有 value 的 引用計(jì)數(shù)器, 當(dāng)某個 value 的引用計(jì)數(shù)器為 1 時候(說明只有字典持有這個image), 則刪除這個key-value對.
***步, 在ARC下獲取某個對象的引用計(jì)數(shù)器:
首先 ARC 下是不允許使用retainCount這個屬性的, 但是由于 ARC 的原理是編譯器自動為我們管理引用計(jì)數(shù)器, 所以就算是 ARC 環(huán)境下, 引用計(jì)數(shù)器也是 Enable 狀態(tài), 并且仍然是利用引用計(jì)數(shù)器來管理內(nèi)存. 所以我們可以使用 KVC 來獲取引用計(jì)數(shù)器:
- @implementation NSObject (MRC)
- // 無法直接重寫 retainCount 的方法, 所以加了一個前綴
- - (NSUInteger)obj_retainCount {
- return [[self valueForKey:@"retainCount"] unsignedLongValue];
- }
- @end
第二步 遍歷 value的引用計(jì)數(shù)器
- // 由于遍歷鍵值對時候不能做添加和刪除操作, 所以把要刪除的key放到一個數(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];
- }];
然后處理遍歷時機(jī). 選擇遍歷時機(jī)是一個很困難的, 不能因?yàn)楸闅v而大量占有系統(tǒng)資源. 可以在每一次通過 name 創(chuàng)建(或者從字典中獲取)時候遍歷一次, 但這個方法有可能會長時間不調(diào)用(比如一個用戶在某一個界面上呆很久). 所以我們可以在每一次 runloop 到來時候來做一次遍歷, 同時我們還需要標(biāo)記遍歷狀態(tài), 防止第二次 runloop 到來時候***次的遍歷還沒結(jié)束就開始新的遍歷了(此時應(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)請看代碼.
方案二之 弱引用字典
在上面那個方案中, 會在每一次 runloop 到來之時開辟一個線程去遍歷鍵值對. 通常來說, 每一個 APP 創(chuàng)建的圖片個數(shù)很大, 所以遍歷鍵值對雖然不會阻塞主線程, 但仍然是一個非常耗時耗資源的工作.
弱引用容器是指基于NSArray, NSDictionary, NSSet的容器類, 該容器與這些類***的區(qū)別在于, 將對象放入容器中并不會改變對象的引用計(jì)數(shù)器, 同時容器是以一個弱引用指針指向這個對象, 當(dāng)對象銷毀時自動從容器中刪除, 無需額外的操作.
目前常用的弱引用容器的實(shí)現(xiàn)方式是block封裝解封
利用block封裝一個對象, 且block中對象的持有操作是一個弱引用指針. 而后將block當(dāng)做對象放入容器中. 容器直接持有block, 而不直接持有對象. 取對象時解包block即可得到對應(yīng)對象.
***步 封裝與解封
- 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)了一個弱引用字典, 之后用弱引用字典代替imageNamed:中的強(qiáng)引用字典即可.