UIImage 記憶體優化

weixin_34290000發表於2017-03-01

這篇文章是筆者在開發App過程中發現的一些記憶體問題, 然後學習了YYKit框架時候也發現了圖片的快取處理的<del>不夠得當</del> (YYKit 作者聯絡了我, 說明了YYKit重寫imageNamed:的目的不是為了記憶體管理, 而是增加相容性, 同時也是為了YYKit中的動畫服務). 以下內容是筆者在開發中做了一些實驗以及總結. 如有錯誤望即時提出, 筆者會第一時間改正.

文章的前篇主要是對兩種不同的UIImage工廠方法的分析, <del>以及對 YYKit 中的 YYImage 的分析</del>. 羅列出這些工廠方法的記憶體管理的優缺點.

文章的後篇是本文要說明的重點, 如何結合兩種工廠方法的優點做更進一步的節約記憶體的管理.

PS

本文所說的 Resource 是指使用imageWithContentsOfFile:建立圖片的圖片管理方式.

ImageAssets 是指使用imageNamed:建立圖片的圖片管理方式.

如果你對這兩個方法已經瞭如指掌, 可以直接看UIImage 與 YYImage 的記憶體問題和後面的內容

UIImage 的記憶體處理

在實際的蘋果App開發中, 將圖片檔案匯入到工程中無非使用兩種方式. 一種是 Resource (我也不知道應該稱呼什麼,就這麼叫吧),還有一種是 ImageAssets 形式儲存在一個圖片資源管理檔案中. 這兩種方式都可以儲存任何形式的圖片檔案, 但是都有各自的優缺點在內. 接下來我們就來談談這兩種圖片資料管理方式的優缺點.

Resource 與 "imageWithContentsOfFile:"

Resource 的使用方式

將檔案直接拖入到工程目錄下, 並告訴Xcode打包專案時候把這些圖片檔案打包進去. 這樣在應用的".app"資料夾中就有這些圖片. 在專案中, 讀取這些圖片可以通過以下方式來獲取圖片檔案並封裝成UIImge物件:

NSString *path = [NSBundle.mainBundle pathForResource:@"image@2x" type:@"png"];
UIImage *image = [UIImage imageWithContentsOfFile:path];

而底層的實現原理近似是:

+ (instancetype)imageWithContentsOfFile:(NSString *)fileName {
    NSUInteger scale = 0;
    {
        scale = 2;//這一部分是取 fileName 中"@"符號後面那個數字, 如果不存在則為1, 這一部分的邏輯省略
    }
    return [[self alloc] initWithData:[NSData dataWithContentsOfFile:fileName scale:scale];
}

這種方式有一個侷限性, 就是圖片檔案必須在.ipa的根目錄下或者在沙盒中. 在.ipa的根目錄下建立圖片檔案僅僅只有一種方式, 就是通過 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 螢幕, 也就是解決 iPhone 4 和 iPhone 3GS 以及以前機型的螢幕適配問題. 現在 iPhone 3GS 以及之前的機型都已被淘汰, 非 Retina 螢幕已不再是開發考慮的範圍. 但是 plus 機型的推出將 Retina 螢幕又提高了一個水平, ImageAssets 現在的主要功能則是區分 plus 螢幕和非 plus 螢幕, 也就是解決 2 倍 Retina 螢幕和 3 倍 Retina 螢幕的視屏問題.

ImageAssets 的使用方式

iOS 開發中一般在工程內匯入兩個到三個同內容不同畫素的圖片檔案, 一般如下:

  1. image.png (30 x 30)
  2. image@2x.png (60 x 60)
  3. image@3x.png (90 x 90)

這三張圖片都是相同內容, 而且圖片名稱的字首相同, 區別在與圖片名以及圖片的解析度. 開發者將這三張圖片拉入 ImageAssets 後, Xcode 會以圖片字首建立一個圖片組(這裡也就是 "image"). 然後在程式碼中寫:

UIImage *image = [UIImage imageNamed:@"image"];

就會根據不同螢幕來獲取對應不同的圖片資料來建立圖片. 如果是 3GS 之前的機型就會讀取 "image.png", 普通 Retina 會讀取 "image@2x.png", plus Retina 會讀取 "image@3x.png", 如果某一個檔案不存在, 就會用另一個解析度的圖片代替之.

ImageAssets 的特性

與 Resources 相似, ImageAssets 也是從圖片檔案中讀取圖片資料轉為 UIImage, 只不過這些圖片資料都打包在 ImageAssets 中. 還有一個最大的區別就是圖片快取. 相當於有一個字典, key 是圖片名, value是圖片物件. 呼叫imageNamed:方法時候先從這個字典裡取, 如果取到就直接返回, 如果取不到再去檔案中建立, 然後儲存到這個字典後再返回. 由於字典的keyvalue都是強引用, 所以一旦建立後的圖片永不銷燬.

其內部程式碼相似於:


+ (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 的優點

當一個 icon 在多個地方需要被顯示的時候, 其對應的UIImage物件只會被建立一次, 而且多個地方的 icon 都將會共用一個 UIImage 物件. 減少沙盒的讀取操作.

<del>YYImage 的記憶體處理</del>

由於YYImage的目的並不是為了關閉快取, 所以此段沒有分析的意義, 現已刪除.

<del>YYImage 的核心就是學習imageWithContentsOfFile:的方法原理去實現imageNamed:方法. 達到imageNamed:方法中沒有快取功能, 最終使得不需要圖片的時候即可銷燬圖片物件. </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:]這個方法底層是利用通過一定的計算獲取到最佳尺寸, 然後列舉圖片匹配圖片檔名, 拼接成路徑後利用NSData建立出UIImage. 本質上和imageWithContentsOfFile:沒有啥區別.</del>

UIImage <del>與 YYImage</del> 的記憶體問題

Resource 的缺點

當我們需要圖片的時候就會去沙盒中讀取這個圖片檔案, 轉換成UIImage物件來使用. 現在假設一種場景:

  1. image@2x.png 圖片佔用 5kb 的記憶體
  2. image@2x.png 在多個介面都用到, 且有7處會同時顯示這個圖片

通過程式碼分析就可以知道 Resource 這個方式在這個情景下會佔用 5kb/個 X 7個 = 35kb 記憶體. 然而, 在 ImageAssets 方式下, 全部取自字典快取中的UIImage, 無論有幾處顯示圖片, 都只會佔用 5kb/個 X 1個 = 5kb 記憶體. 此時 Resource 佔用記憶體將會更大.

<del>由於 YYImage 的核心就是利用imageWithContentsOfFile:代替imageNamed:, 所以這也是 YYImage 的缺陷之處</del>

ImageAssets 的缺點

第一次讀取的圖片儲存到緩衝區, 然後永不銷燬. 如果這個圖片過大, 佔用幾百 kb, 這一塊的記憶體將不會釋放, 必然導致記憶體的浪費, 而且這個浪費的週期與APP的生命週期同步.

解決方案

為了解決 Resource 的多圖共存問題, 可以學習 ImageAssets 中的字典來形成鍵值對, 當字典中name對應的image存在就不建立, 如果不存在就建立. 字典的存在必然導致 UIImage 永不銷燬, 所以還要考慮字典不會影響到 UIImage 的自動銷燬問題. 由此可以做出如下總結:

  1. 需要一個字典儲存已經建立的 Image 的 name-image 對映
  2. 當除了這個字典外, 沒有別的物件持有 image, 則從這個字典中刪除對應 name-image 對映

第一個要求的實現方式很簡單, 接下來探討第二個要求.

首先可以考慮如何判斷除了字典外沒有別的物件持有 image? 字典是強引用 key 和 value 的, 當 image 放入字典的時候, image 的引用計數器就會 + 1. 我們可以判斷字典中的 image 的引用計數器是否為 1, 如果為 1 則可以判斷出目前只有字典持有這個 image, 因此可以從這個字典裡刪除這個 image.

這樣即可提出一個方案 MRC+字典

我們還可以換一種思想, 字典是強引用容器, 字典存在必然導致內部value的引用計數器大於等於1. 如果字典是一個弱引用容器, 字典的存在並不會影響到內部value的引用計數器, 那麼 image 的銷燬就不會因為字典而受到影響.

於是又有一個方案 弱引用字典

接下來對這兩個方案作深入的分析和實現:

方案一之 MRC+字典

該方案具體思路是: 找到一個合適的時機, 遍歷所有 value 的 引用計數器, 當某個 value 的引用計數器為 1 時候(說明只有字典持有這個image), 則刪除這個key-value對.

第一步, 在ARC下獲取某個物件的引用計數器:

首先 ARC 下是不允許使用retainCount這個屬性的, 但是由於 ARC 的原理是編譯器自動為我們管理引用計數器, 所以就算是 ARC 環境下, 引用計數器也是 Enable 狀態, 並且仍然是利用引用計數器來管理記憶體. 所以我們可以使用 KVC 來獲取引用計數器:

@implementation NSObject (MRC)

// 無法直接重寫 retainCount 的方法, 所以加了一個字首
- (NSUInteger)obj_retainCount {
    return [[self valueForKey:@"retainCount"] unsignedLongValue];
}

@end

第二步 遍歷 value的引用計數器

// 由於遍歷鍵值對時候不能做新增和刪除操作, 所以把要刪除的key放到一個陣列中
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引數持有 = 2
        [keyArr addObject:key];
    }
}];
[keyArr enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
    [self.imageDic removeObjectForKey:obj];
}];

然後處理遍歷時機. 選擇遍歷時機是一個很困難的, 不能因為遍歷而大量佔有系統資源. 可以在每一次通過 name 建立(或者從字典中獲取)時候遍歷一次, 但這個方法有可能會長時間不呼叫(比如一個使用者在某一個介面上呆很久). 所以我們可以在每一次 runloop 到來時候來做一次遍歷, 同時我們還需要標記遍歷狀態, 防止第二次 runloop 到來時候第一次的遍歷還沒結束就開始新的遍歷了(此時應該直接放棄第二次遍歷).程式碼如下:

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);

具體實現請看程式碼.

方案二之 弱引用字典

在上面那個方案中, 會在每一次 runloop 到來之時開闢一個執行緒去遍歷鍵值對. 通常來說, 每一個 APP 建立的圖片個數很大, 所以遍歷鍵值對雖然不會阻塞主執行緒, 但仍然是一個非常耗時耗資源的工作.

弱引用容器是指基於NSArray, NSDictionary, NSSet的容器類, 該容器與這些類最大的區別在於, 將物件放入容器中並不會改變物件的引用計數器, 同時容器是以一個弱引用指標指向這個物件, 當物件銷燬時自動從容器中刪除, 無需額外的操作.

目前常用的弱引用容器的實現方式是block封裝解封

利用block封裝一個物件, 且block中物件的持有操作是一個弱引用指標. 而後將block當做物件放入容器中. 容器直接持有block, 而不直接持有物件. 取物件時解包block即可得到對應物件.

第一步 封裝與解封


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]);
}

這樣就實現了一個弱引用字典, 之後用弱引用字典代替imageNamed:中的強引用字典即可.

相關文章