App的內存優化
這篇文章是筆者在開發App過程中發現的一些內存問題, 然后學習了YYKit框架時候也發現了圖片的緩存處理 (YYKit 作者聯系了我, 說明了YYKit重寫imageNamed:的目的不是為了內存管理, 而是增加兼容性, 同時也是為了YYKit中的動畫服務). 以下內容是筆者在開發中做了一些實驗以及總結. 如有錯誤望即時提出, 筆者會***時間改正.
文章的前篇主要是對兩種不同的UIImage工廠方法的分析, 羅列出這些工廠方法的內存管理的優缺點.
文章的后篇是本文要說明的重點, 如何結合兩種工廠方法的優點做更進一步的節約內存的管理.
PS
本文所說的 Resource 是指使用imageWithContentsOfFile:創建圖片的圖片管理方式.
ImageAssets 是指使用imageNamed:創建圖片的圖片管理方式.
如果你對這兩個方法已經了如指掌, 可以直接看UIImage 與 YYImage 的內存問題和后面的內容
[TOC]
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 開發中一般在工程內導入兩個到三個同內容不同像素的圖片文件, 一般如下:
- image.png (30 x 30)
- image@2x.png (60 x 60)
- 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:方法時候先從這個字典里取, 如果取到就直接返回, 如果取不到再去文件中創建, 然后保存到這個字典后再返回. 由于字典的key和value都是強引用, 所以一旦創建后的圖片永不銷毀.
其內部代碼相似于:
- + (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 對象. 減少沙盒的讀取操作.
- + (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 的內存問題
Resource 的缺點
當我們需要圖片的時候就會去沙盒中讀取這個圖片文件, 轉換成UIImage對象來使用. 現在假設一種場景:
- image@2x.png 圖片占用 5kb 的內存
- image@2x.png 在多個界面都用到, 且有7處會同時顯示這個圖片
通過代碼分析就可以知道 Resource 這個方式在這個情景下會占用 5kb/個 X 7個 = 35kb 內存. 然而, 在 ImageAssets 方式下, 全部取自字典緩存中的UIImage, 無論有幾處顯示圖片, 都只會占用 5kb/個 X 1個 = 5kb 內存. 此時 Resource 占用內存將會更大.
ImageAssets 的缺點
***次讀取的圖片保存到緩沖區, 然后永不銷毀. 如果這個圖片過大, 占用幾百 kb, 這一塊的內存將不會釋放, 必然導致內存的浪費, 而且這個浪費的周期與APP的生命周期同步.
解決方案
為了解決 Resource 的多圖共存問題, 可以學習 ImageAssets 中的字典來形成鍵值對, 當字典中name對應的image存在就不創建, 如果不存在就創建. 字典的存在必然導致 UIImage 永不銷毀, 所以還要考慮字典不會影響到 UIImage 的自動銷毀問題. 由此可以做出如下總結:
- 需要一個字典存儲已經創建的 Image 的 name-image 映射
- 當除了這個字典外, 沒有別的對象持有 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:中的強引用字典即可.