iOS 核心動畫的影象IO
潛伏期值得思考 - 凱文 帕薩特
在第13章“高效繪圖”中,我們研究了和Core Graphics繪圖相關的效能問題,以及如何修復。和繪圖效能相關緊密相關的是影象效能。在這一章中,我們將研究如何優化從快閃記憶體驅動器或者網路中載入和顯示圖片。
如果你依然在程式設計的世界裡迷茫,不知道自己的未來規劃,小編給大家推薦一個IOS高階交流群:458839238 裡面可以與大神一起交流並走出迷茫。小白可進群免費領取學習資料,看看前輩們是如何在程式設計的世界裡傲然前行!
群內提供資料結構與演算法、底層進階、swift、逆向、整合面試題等免費資料
附上一份收集的各大廠面試題(附答案) ! 群檔案直接獲取
各大廠面試題
載入和潛伏
繪圖實際消耗的時間通常並不是影響效能的因素。圖片消耗很大一部分記憶體,而且不太可能把需要顯示的圖片都保留在記憶體中,所以需要在應用執行的時候週期性地載入和解除安裝圖片。
圖片檔案載入的速度被CPU和IO(輸入/輸出)同時影響。iOS裝置中的快閃記憶體已經比傳統硬碟快很多了,但仍然比RAM慢將近200倍左右,這就需要很小心地管理載入,來避免延遲。
只要有可能,試著在程式生命週期不易察覺的時候來載入圖片,例如啟動,或者在螢幕切換的過程中。按下按鈕和按鈕響應事件之間最大的延遲大概是200ms,這比動畫每一幀切換的16ms小得多。你可以在程式首次啟動的時候載入圖片,但是如果20秒內無法啟動程式的話,iOS檢測計時器就會終止你的應用(而且如果啟動大於2,3秒的話使用者就會抱怨了)。
有些時候,提前載入所有的東西並不明智。比如說包含上千張圖片的圖片傳送帶:使用者希望能夠能夠平滑快速翻動圖片,所以就不可能提前預載入所有圖片;那樣會消耗太多的時間和記憶體。
有時候圖片也需要從遠端網路連線中下載,這將會比從磁碟載入要消耗更多的時間,甚至可能由於連線問題而載入失敗(在幾秒鐘嘗試之後)。你不能夠在主執行緒中載入網路造成等待,所以需要後臺執行緒。
執行緒載入
在第12章“效能調優”我們的聯絡人列表例子中,圖片都非常小,所以可以在主執行緒同步載入。但是對於大圖來說,這樣做就不太合適了,因為載入會消耗很長時間,造成滑動的不流暢。滑動動畫會在主執行緒的run loop中更新,所以會有更多執行在渲染服務程序中CPU相關的效能問題。
清單14.1顯示了一個通過 UICollectionView
實現的基礎的圖片傳送器。圖片在主執行緒中 -collectionView:cellForItemAtIndexPath:
方法中同步載入(見圖14.1)。
清單14.1 使用 UICollectionView
實現的圖片傳送器
#import "ViewController.h" @interface ViewController() <UICollectionViewDataSource> @property (nonatomic, copy) NSArray *imagePaths; @property (nonatomic, weak) IBOutlet UICollectionView *collectionView; @end @implementation ViewController - (void)viewDidLoad { //set up data self.imagePaths = [[NSBundle mainBundle] pathsForResourcesOfType:@"png" inDirectory:@"Vacation Photos"]; //register cell class [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"Cell"]; } - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { return [self.imagePaths count]; } - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { //dequeue cell UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath]; //add image view const NSInteger imageTag = 99; UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag]; if (!imageView) { imageView = [[UIImageView alloc] initWithFrame: cell.contentView.bounds]; imageView.tag = imageTag; [cell.contentView addSubview:imageView]; } //set image NSString *imagePath = self.imagePaths[indexPath.row]; imageView.image = [UIImage imageWithContentsOfFile:imagePath]; return cell; } @end

圖14.1
圖14.1 執行中的圖片傳送器
傳送器中的圖片尺寸為800x600畫素的PNG,對iPhone5來說,1/60秒要載入大概700KB左右的圖片。當傳送器滾動的時候,圖片也在實時載入,於是(預期中的)卡動就發生了。時間分析工具(圖14.2)顯示了很多時間都消耗在了 UIImage
的 +imageWithContentsOfFile:
方法中了。很明顯,圖片載入造成了瓶頸。

圖14.2
圖14.2 時間分析工具展示了CPU瓶頸
這裡提升效能唯一的方式就是在另一個執行緒中載入圖片。這並不能夠降低實際的載入時間(可能情況會更糟,因為系統可能要消耗CPU時間來處理載入的圖片資料),但是主執行緒能夠有時間做一些別的事情,比如響應使用者輸入,以及滑動動畫。
為了在後臺執行緒載入圖片,我們可以使用GCD或者 NSOperationQueue
建立自定義執行緒,或者使用 CATiledLayer
。為了從遠端網路載入圖片,我們可以使用非同步的 NSURLConnection
,但是對本地儲存的圖片,並不十分有效。
GCD和 NSOperationQueue
GCD(Grand Central Dispatch)和 NSOperationQueue
很類似,都給我們提供了佇列閉包塊來線上程中按一定順序來執行。 NSOperationQueue
有一個Objecive-C介面(而不是使用GCD的全域性C函式),同樣在操作優先順序和依賴關係上提供了很好的粒度控制,但是需要更多地設定程式碼。
清單14.2顯示了在低優先順序的後臺佇列而不是主執行緒使用GCD載入圖片的 -collectionView:cellForItemAtIndexPath:
方法,然後當需要載入圖片到檢視的時候切換到主執行緒,因為在後臺執行緒訪問檢視會有安全隱患。
由於檢視在 UICollectionView
會被迴圈利用,我們載入圖片的時候不能確定是否被不同的索引重新複用。為了避免圖片載入到錯誤的檢視中,我們在載入前把單元格打上索引的標籤,然後在設定圖片的時候檢測標籤是否發生了改變。
清單14.2 使用GCD載入傳送圖片
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { //dequeue cell UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath]; //add image view const NSInteger imageTag = 99; UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag]; if (!imageView) { imageView = [[UIImageView alloc] initWithFrame: cell.contentView.bounds]; imageView.tag = imageTag; [cell.contentView addSubview:imageView]; } //tag cell with index and clear current image cell.tag = indexPath.row; imageView.image = nil; //switch to background thread dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ //load image NSInteger index = indexPath.row; NSString *imagePath = self.imagePaths[index]; UIImage *image = [UIImage imageWithContentsOfFile:imagePath]; //set image on main thread, but only if index still matches up dispatch_async(dispatch_get_main_queue(), ^{ if (index == cell.tag) { imageView.image = image; } }); }); return cell; }
當執行更新後的版本,效能比之前不用執行緒的版本好多了,但仍然並不完美(圖14.3)。
我們可以看到 +imageWithContentsOfFile:
方法並不在CPU時間軌跡的最頂部,所以我們的確修復了延遲載入的問題。問題在於我們假設傳送器的效能瓶頸在於圖片檔案的載入,但實際上並不是這樣。載入圖片資料到記憶體中只是問題的第一部分。

圖14.3
圖14.3 使用後臺執行緒載入圖片來提升效能
延遲解壓
一旦圖片檔案被載入就必須要進行解碼,解碼過程是一個相當複雜的任務,需要消耗非常長的時間。解碼後的圖片將同樣使用相當大的記憶體。
用於載入的CPU時間相對於解碼來說根據圖片格式而不同。對於PNG圖片來說,載入會比JPEG更長,因為檔案可能更大,但是解碼會相對較快,而且Xcode會把PNG圖片進行解碼優化之後引入工程。JPEG圖片更小,載入更快,但是解壓的步驟要消耗更長的時間,因為JPEG解壓演算法比基於zip的PNG演算法更加複雜。
當載入圖片的時候,iOS通常會延遲解壓圖片的時間,直到載入到記憶體之後。這就會在準備繪製圖片的時候影響效能,因為需要在繪製之前進行解壓(通常是消耗時間的問題所在)。
最簡單的方法就是使用 UIImage
的 +imageNamed:
方法避免延時載入。不像 +imageWithContentsOfFile:
(和其他別的 UIImage
載入方法),這個方法會在載入圖片之後立刻進行解壓(就和本章之前我們談到的好處一樣)。問題在於 +imageNamed:
只對從應用資源束中的圖片有效,所以對使用者生成的圖片內容或者是下載的圖片就沒法使用了。
另一種立刻載入圖片的方法就是把它設定成圖層內容,或者是 UIImageView
的 image
屬性。不幸的是,這又需要在主執行緒執行,所以不會對效能有所提升。
第三種方式就是繞過 UIKit
,像下面這樣使用ImageIO框架:
NSInteger index = indexPath.row; NSURL *imageURL = [NSURL fileURLWithPath:self.imagePaths[index]]; NSDictionary *options = @{(__bridge id)kCGImageSourceShouldCache: @YES}; CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)imageURL, NULL); CGImageRef imageRef = CGImageSourceCreateImageAtIndex(source, 0,(__bridge CFDictionaryRef)options); UIImage *image = [UIImage imageWithCGImage:imageRef]; CGImageRelease(imageRef); CFRelease(source);
這樣就可以使用 kCGImageSourceShouldCache
來建立圖片,強制圖片立刻解壓,然後在圖片的生命週期保留解壓後的版本。
最後一種方式就是使用UIKit載入圖片,但是立刻會知道 CGContext
中去。圖片必須要在繪製之前解壓,所以就強制瞭解壓的及時性。這樣的好處在於繪製圖片可以再後臺執行緒(例如載入本身)執行,而不會阻塞UI。
有兩種方式可以為強制解壓提前渲染圖片:
-
將圖片的一個畫素繪製成一個畫素大小的
CGContext
。這樣仍然會解壓整張圖片,但是繪製本身並沒有消耗任何時間。這樣的好處在於載入的圖片並不會在特定的裝置上為繪製做優化,所以可以在任何時間點繪製出來。同樣iOS也就可以丟棄解壓後的圖片來節省記憶體了。 -
將整張圖片繪製到
CGContext
中,丟棄原始的圖片,並且用一個從上下文內容中新的圖片來代替。這樣比繪製單一畫素那樣需要更加複雜的計算,但是因此產生的圖片將會為繪製做優化,而且由於原始壓縮圖片被拋棄了,iOS就不能夠隨時丟棄任何解壓後的圖片來節省記憶體了。
需要注意的是蘋果特別推薦了不要使用這些詭計來繞過標準圖片解壓邏輯(所以也是他們選擇用預設處理方式的原因),但是如果你使用很多大圖來構建應用,那如果想提升效能,就只能和系統博弈了。
如果不使用 +imageNamed:
,那麼把整張圖片繪製到 CGContext
可能是最佳的方式了。儘管你可能認為多餘的繪製相較別的解壓技術而言效能不是很高,但是新建立的圖片(在特定的裝置上做過優化)可能比原始圖片繪製的更快。
同樣,如果想顯示圖片到比原始尺寸小的容器中,那麼一次性在後臺執行緒重新繪製到正確的尺寸會比每次顯示的時候都做縮放會更有效(儘管在這個例子中我們載入的圖片呈現正確的尺寸,所以不需要多餘的優化)。
如果修改了 -collectionView:cellForItemAtIndexPath:
方法來重繪圖片(清單14.3),你會發現滑動更加平滑。
清單14.3 強制圖片解壓顯示
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { //dequeue cell UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath]; ... //switch to background thread dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ //load image NSInteger index = indexPath.row; NSString *imagePath = self.imagePaths[index]; UIImage *image = [UIImage imageWithContentsOfFile:imagePath]; //redraw image using device context UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, YES, 0); [image drawInRect:imageView.bounds]; image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); //set image on main thread, but only if index still matches up dispatch_async(dispatch_get_main_queue(), ^{ if (index == cell.tag) { imageView.image = image; } }); }); return cell; }
CATiledLayer
如第6章“專用圖層”中的例子所示, CATiledLayer
可以用來非同步載入和顯示大型圖片,而不阻塞使用者輸入。但是我們同樣可以使用 CATiledLayer
在 UICollectionView
中為每個表格建立分離的 CATiledLayer
例項載入傳動器圖片,每個表格僅使用一個圖層。
這樣使用 CATiledLayer
有幾個潛在的弊端:
-
CATiledLayer
的佇列和快取演算法沒有暴露出來,所以我們只能祈禱它能匹配我們的需求 -
CATiledLayer
需要我們每次重繪圖片到CGContext
中,即使它已經解壓縮,而且和我們單元格尺寸一樣(因此可以直接用作圖層內容,而不需要重繪)。
我們來看看這些弊端有沒有造成不同:清單14.4顯示了使用 CATiledLayer
對圖片傳送器的重新實現。
清單14.4 使用 CATiledLayer
的圖片傳送器
#import "ViewController.h" #import <QuartzCore/QuartzCore.h> @interface ViewController() <UICollectionViewDataSource> @property (nonatomic, copy) NSArray *imagePaths; @property (nonatomic, weak) IBOutlet UICollectionView *collectionView; @end @implementation ViewController - (void)viewDidLoad { //set up data self.imagePaths = [[NSBundle mainBundle] pathsForResourcesOfType:@"jpg" inDirectory:@"Vacation Photos"]; [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"Cell"]; } - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { return [self.imagePaths count]; } - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { //dequeue cell UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath]; //add the tiled layer CATiledLayer *tileLayer = [cell.contentView.layer.sublayers lastObject]; if (!tileLayer) { tileLayer = [CATiledLayer layer]; tileLayer.frame = cell.bounds; tileLayer.contentsScale = [UIScreen mainScreen].scale; tileLayer.tileSize = CGSizeMake(cell.bounds.size.width * [UIScreen mainScreen].scale, cell.bounds.size.height * [UIScreen mainScreen].scale); tileLayer.delegate = self; [tileLayer setValue:@(indexPath.row) forKey:@"index"]; [cell.contentView.layer addSublayer:tileLayer]; } //tag the layer with the correct index and reload tileLayer.contents = nil; [tileLayer setValue:@(indexPath.row) forKey:@"index"]; [tileLayer setNeedsDisplay]; return cell; } - (void)drawLayer:(CATiledLayer *)layer inContext:(CGContextRef)ctx { //get image index NSInteger index = [[layer valueForKey:@"index"] integerValue]; //load tile image NSString *imagePath = self.imagePaths[index]; UIImage *tileImage = [UIImage imageWithContentsOfFile:imagePath]; //calculate image rect CGFloat aspectRatio = tileImage.size.height / tileImage.size.width; CGRect imageRect = CGRectZero; imageRect.size.width = layer.bounds.size.width; imageRect.size.height = layer.bounds.size.height * aspectRatio; imageRect.origin.y = (layer.bounds.size.height - imageRect.size.height)/2; //draw tile UIGraphicsPushContext(ctx); [tileImage drawInRect:imageRect]; UIGraphicsPopContext(); } @end
需要解釋幾點:
-
CATiledLayer
的tileSize
屬性單位是畫素,而不是點,所以為了保證瓦片和表格尺寸一致,需要乘以螢幕比例因子。 -
在
-drawLayer:inContext:
方法中,我們需要知道圖層屬於哪一個indexPath
以載入正確的圖片。這裡我們利用了CALayer
的KVC來儲存和檢索任意的值,將圖層和索引打標籤。
結果 CATiledLayer
工作的很好,效能問題解決了,而且和用GCD實現的程式碼量差不多。僅有一個問題在於圖片載入到螢幕上後有一個明顯的淡入(圖14.4)。

圖14.4
圖14.4 載入圖片之後的淡入
我們可以調整 CATiledLayer
的 fadeDuration
屬性來調整淡入的速度,或者直接將整個漸變移除,但是這並沒有根本性地去除問題:在圖片載入到準備繪製的時候總會有一個延遲,這將會導致滑動時候新圖片的跳入。這並不是 CATiledLayer
的問題,使用GCD的版本也有這個問題。
即使使用上述我們討論的所有載入圖片和快取的技術,有時候仍然會發現實時載入大圖還是有問題。就和13章中提到的那樣,iPad上一整個視網膜屏圖片解析度達到了2048x1536,而且會消耗12MB的RAM(未壓縮)。第三代iPad的硬體並不能支援1/60秒的幀率載入,解壓和顯示這種圖片。即使用後臺執行緒載入來避免動畫卡頓,仍然解決不了問題。
我們可以在載入的同時顯示一個佔位圖片,但這並沒有根本解決問題,我們可以做到更好。
解析度交換
視網膜解析度(根據蘋果市場定義)代表了人的肉眼在正常視角距離能夠分辨的最小畫素尺寸。但是這隻能應用於靜態畫素。當觀察一個移動圖片時,你的眼睛就會對細節不敏感,於是一個低解析度的圖片和視網膜質量的圖片沒什麼區別了。
如果需要快速載入和顯示移動大圖,簡單的辦法就是欺騙人眼,在移動傳送器的時候顯示一個小圖(或者低解析度),然後當停止的時候再換成大圖。這意味著我們需要對每張圖片儲存兩份不同解析度的副本,但是幸運的是,由於需要同時支援Retina和非Retina裝置,本來這就是普遍要做到的。
如果從遠端源或者使用者的相簿載入沒有可用的低解析度版本圖片,那就可以動態將大圖繪製到較小的 CGContext
,然後儲存到某處以備複用。
為了做到圖片交換,我們需要利用 UIScrollView
的一些實現 UIScrollViewDelegate
協議的委託方法(和其他類似於 UITableView
和 UICollectionView
基於滾動檢視的控制元件一樣):
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate; - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView;
你可以使用這幾個方法來檢測傳送器是否停止滾動,然後載入高解析度的圖片。只要高解析度圖片和低解析度圖片尺寸顏色保持一致,你會很難察覺到替換的過程(確保在同一臺機器使用相同的影象程式或者指令碼生成這些圖片)。
快取
如果有很多張圖片要顯示,最好不要提前把所有都載入進來,而是應該當移出螢幕之後立刻銷燬。通過選擇性的快取,你就可以避免來回滾動時圖片重複性的載入了。
快取其實很簡單:就是儲存昂貴計算後的結果(或者是從快閃記憶體或者網路載入的檔案)在記憶體中,以便後續使用,這樣訪問起來很快。問題在於快取本質上是一個權衡過程 - 為了提升效能而消耗了記憶體,但是由於記憶體是一個非常寶貴的資源,所以不能把所有東西都做快取。
何時將何物做快取(做多久)並不總是很明顯。幸運的是,大多情況下,iOS都為我們做好了圖片的快取。
+imageNamed:
方法
之前我們提到使用 [UIImage imageNamed:]
載入圖片有個好處在於可以立刻解壓圖片而不用等到繪製的時候。但是 [UIImage imageNamed:]
方法有另一個非常顯著的好處:它在記憶體中自動快取瞭解壓後的圖片,即使你自己沒有保留對它的任何引用。
對於iOS應用那些主要的圖片(例如圖示,按鈕和背景圖片),使用 [UIImage imageNamed:]
載入圖片是最簡單最有效的方式。在nib檔案中引用的圖片同樣也是這個機制,所以你很多時候都在隱式的使用它。
但是 [UIImage imageNamed:]
並不適用任何情況。它為使用者介面做了優化,但是並不是對應用程式需要顯示的所有型別的圖片都適用。有些時候你還是要實現自己的快取機制,原因如下:
-
[UIImage imageNamed:]
方法僅僅適用於在應用程式資源束目錄下的圖片,但是大多數應用的許多圖片都要從網路或者是使用者的相機中獲取,所以[UIImage imageNamed:]
就沒法用了。 -
[UIImage imageNamed:]
快取用來儲存應用介面的圖片(按鈕,背景等等)。如果對照片這種大圖也用這種快取,那麼iOS系統就很可能會移除這些圖片來節省記憶體。那麼在切換頁面時效能就會下降,因為這些圖片都需要重新載入。對傳送器的圖片使用一個單獨的快取機制就可以把它和應用圖片的生命週期解耦。 -
[UIImage imageNamed:]
快取機制並不是公開的,所以你不能很好地控制它。例如,你沒法做到檢測圖片是否在載入之前就做了快取,不能夠設定快取大小,當圖片沒用的時候也不能把它從快取中移除。
自定義快取
構建一個所謂的快取系統非常困難。菲爾 卡爾頓曾經說過:“在電腦科學中只有兩件難事:快取和命名”。
如果要寫自己的圖片快取的話,那該如何實現呢?讓我們來看看要涉及哪些方面:
-
選擇一個合適的快取鍵 - 快取鍵用來做圖片的唯一標識。如果實時建立圖片,通常不太好生成一個字串來區分別的圖片。在我們的圖片傳送帶例子中就很簡單,我們可以用圖片的檔名或者表格索引。
-
提前快取 - 如果生成和載入資料的代價很大,你可能想當第一次需要用到的時候再去載入和快取。提前載入的邏輯是應用內在就有的,但是在我們的例子中,這也非常好實現,因為對於一個給定的位置和滾動方向,我們就可以精確地判斷出哪一張圖片將會出現。
-
快取失效 - 如果圖片檔案發生了變化,怎樣才能通知到快取更新呢?這是個非常困難的問題(就像菲爾 卡爾頓提到的),但是幸運的是當從程式資源載入靜態圖片的時候並不需要考慮這些。對使用者提供的圖片來說(可能會被修改或者覆蓋),一個比較好的方式就是當圖片快取的時候打上一個時間戳以便當檔案更新的時候作比較。
-
快取回收 - 當記憶體不夠的時候,如何判斷哪些快取需要清空呢?這就需要到你寫一個合適的演算法了。幸運的是,對快取回收的問題,蘋果提供了一個叫做
NSCache
通用的解決方案
NSCache
NSCache
和 NSDictionary
類似。你可以通過 -setObject:forKey:
和 -object:forKey:
方法分別來插入,檢索。和字典不同的是, NSCache
在系統低記憶體的時候自動丟棄儲存的物件。
NSCache
用來判斷何時丟棄物件的演算法並沒有在文件中給出,但是你可以使用 -setCountLimit:
方法設定快取大小,以及 -setObject:forKey:cost:
來對每個儲存的物件指定消耗的值來提供一些暗示。
指定消耗數值可以用來指定相對的重建成本。如果對大圖指定一個大的消耗值,那麼快取就知道這些物體的儲存更加昂貴,於是當有大的效能問題的時候才會丟棄這些物體。你也可以用 -setTotalCostLimit:
方法來指定全體快取的尺寸。
NSCache
是一個普遍的快取解決方案,我們建立一個比傳送器案例更好的自定義的快取類。(例如,我們可以基於不同的快取圖片索引和當前中間索引來判斷哪些圖片需要首先被釋放)。但是 NSCache
對我們當前的快取需求來說已經足夠了;沒必要過早做優化。
使用圖片快取和提前載入的實現來擴充套件之前的傳送器案例,然後來看看是否效果更好(見清單14.5)。
清單14.5 新增快取
#import "ViewController.h" @interface ViewController() <UICollectionViewDataSource> @property (nonatomic, copy) NSArray *imagePaths; @property (nonatomic, weak) IBOutlet UICollectionView *collectionView; @end @implementation ViewController - (void)viewDidLoad { //set up data self.imagePaths = [[NSBundle mainBundle] pathsForResourcesOfType:@"png" inDirectory:@"Vacation Photos"]; //register cell class [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"Cell"]; } - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { return [self.imagePaths count]; } - (UIImage *)loadImageAtIndex:(NSUInteger)index { //set up cache static NSCache *cache = nil; if (!cache) { cache = [[NSCache alloc] init]; } //if already cached, return immediately UIImage *image = [cache objectForKey:@(index)]; if (image) { return [image isKindOfClass:[NSNull class]]? nil: image; } //set placeholder to avoid reloading image multiple times [cache setObject:[NSNull null] forKey:@(index)]; //switch to background thread dispatch_async( dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ //load image NSString *imagePath = self.imagePaths[index]; UIImage *image = [UIImage imageWithContentsOfFile:imagePath]; //redraw image using device context UIGraphicsBeginImageContextWithOptions(image.size, YES, 0); [image drawAtPoint:CGPointZero]; image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); //set image for correct image view dispatch_async(dispatch_get_main_queue(), ^{ //cache the image [cache setObject:image forKey:@(index)]; //display the image NSIndexPath *indexPath = [NSIndexPath indexPathForItem: index inSection:0]; UICollectionViewCell *cell = [self.collectionView cellForItemAtIndexPath:indexPath]; UIImageView *imageView = [cell.contentView.subviews lastObject]; imageView.image = image; }); }); //not loaded yet return nil; } - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { //dequeue cell UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath]; //add image view UIImageView *imageView = [cell.contentView.subviews lastObject]; if (!imageView) { imageView = [[UIImageView alloc] initWithFrame:cell.contentView.bounds]; imageView.contentMode = UIViewContentModeScaleAspectFit; [cell.contentView addSubview:imageView]; } //set or load image for this index imageView.image = [self loadImageAtIndex:indexPath.item]; //preload image for previous and next index if (indexPath.item < [self.imagePaths count] - 1) { [self loadImageAtIndex:indexPath.item + 1]; } if (indexPath.item > 0) { [self loadImageAtIndex:indexPath.item - 1]; } return cell; } @end
果然效果更好了!當滾動的時候雖然還有一些圖片進入的延遲,但是已經非常罕見了。快取意味著我們做了更少的載入。這裡提前載入邏輯非常粗暴,其實可以把滑動速度和方向也考慮進來,但這已經比之前沒做快取的版本好很多了。
檔案格式
圖片載入效能取決於載入大圖的時間和解壓小圖時間的權衡。很多蘋果的文件都說PNG是iOS所有圖片載入的最好格式。但這是極度誤導的過時資訊了。
PNG圖片使用的無失真壓縮演算法可以比使用JPEG的圖片做到更快地解壓,但是由於快閃記憶體訪問的原因,這些載入的時間並沒有什麼區別。
清單14.6展示了標準的應用程式載入不同尺寸圖片所需要時間的一些程式碼。為了保證實驗的準確性,我們會測量每張圖片的載入和繪製時間來確保考慮到解壓效能的因素。另外每隔一秒重複載入和繪製圖片,這樣就可以取到平均時間,使得結果更加準確。
清單14.6
#import "ViewController.h" static NSString *const ImageFolder = @"Coast Photos"; @interface ViewController () <UITableViewDataSource> @property (nonatomic, copy) NSArray *items; @property (nonatomic, weak) IBOutlet UITableView *tableView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //set up image names self.items = @[@"2048x1536", @"1024x768", @"512x384", @"256x192", @"128x96", @"64x48", @"32x24"]; } - (CFTimeInterval)loadImageForOneSec:(NSString *)path { //create drawing context to use for decompression UIGraphicsBeginImageContext(CGSizeMake(1, 1)); //start timing NSInteger imagesLoaded = 0; CFTimeInterval endTime = 0; CFTimeInterval startTime = CFAbsoluteTimeGetCurrent(); while (endTime - startTime < 1) { //load image UIImage *image = [UIImage imageWithContentsOfFile:path]; //decompress image by drawing it [image drawAtPoint:CGPointZero]; //update totals imagesLoaded ++; endTime = CFAbsoluteTimeGetCurrent(); } //close context UIGraphicsEndImageContext(); //calculate time per image return (endTime - startTime) / imagesLoaded; } - (void)loadImageAtIndex:(NSUInteger)index { //load on background thread so as not to //prevent the UI from updating between runs dispatch_async( dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ //setup NSString *fileName = self.items[index]; NSString *pngPath = [[NSBundle mainBundle] pathForResource:filename ofType:@"png" inDirectory:ImageFolder]; NSString *jpgPath = [[NSBundle mainBundle] pathForResource:filename ofType:@"jpg" inDirectory:ImageFolder]; //load NSInteger pngTime = [self loadImageForOneSec:pngPath] * 1000; NSInteger jpgTime = [self loadImageForOneSec:jpgPath] * 1000; //updated UI on main thread dispatch_async(dispatch_get_main_queue(), ^{ //find table cell and update NSIndexPath *indexPath = [NSIndexPath indexPathForRow:index inSection:0]; UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; cell.detailTextLabel.text = [NSString stringWithFormat:@"PNG: %03ims JPG: %03ims", pngTime, jpgTime]; }); }); } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return [self.items count]; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { //dequeue cell UITableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"Cell"]; if (!cell) { cell = [[UITableViewCell alloc] initWithStyle: UITableViewCellStyleValue1 reuseIdentifier:@"Cell"]; } //set up cell NSString *imageName = self.items[indexPath.row]; cell.textLabel.text = imageName; cell.detailTextLabel.text = @"Loading..."; //load image [self loadImageAtIndex:indexPath.row]; return cell; } @end
PNG和JPEG壓縮演算法作用於兩種不同的圖片型別:JPEG對於噪點大的圖片效果很好;但是PNG更適合於扁平顏色,鋒利的線條或者一些漸變色的圖片。為了讓測評的基準更加公平,我們用一些不同的圖片來做實驗:一張照片和一張彩虹色的漸變。JPEG版本的圖片都用預設的Photoshop60%“高質量”設定編碼。結果見圖片14.5。

圖14.5
圖14.5 不同型別圖片的相對載入效能
如結果所示,相對於不友好的PNG圖片,相同畫素的JPEG圖片總是比PNG載入更快,除非一些非常小的圖片、但對於友好的PNG圖片,一些中大尺寸的圖效果還是很好的。
所以對於之前的圖片傳送器程式來說,JPEG會是個不錯的選擇。如果用JPEG的話,一些多執行緒和快取策略都沒必要了。
但JPEG圖片並不是所有情況都適用。如果圖片需要一些透明效果,或者壓縮之後細節損耗很多,那就該考慮用別的格式了。蘋果在iOS系統中對PNG和JPEG都做了一些優化,所以普通情況下都應該用這種格式。也就是說在一些特殊的情況下才應該使用別的格式。
混合圖片
對於包含透明的圖片來說,最好是使用壓縮透明通道的PNG圖片和壓縮RGB部分的JPEG圖片混合起來載入。這就對任何格式都適用了,而且無論從質量還是檔案尺寸還是載入效能來說都和PNG和JPEG的圖片相近。相關分別載入顏色和遮罩圖片並在執行時合成的程式碼見14.7。
清單14.7 從PNG遮罩和JPEG建立的混合圖片
#import "ViewController.h" @interface ViewController () @property (nonatomic, weak) IBOutlet UIImageView *imageView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //load color image UIImage *image = [UIImage imageNamed:@"Snowman.jpg"]; //load mask image UIImage *mask = [UIImage imageNamed:@"SnowmanMask.png"]; //convert mask to correct format CGColorSpaceRef graySpace = CGColorSpaceCreateDeviceGray(); CGImageRef maskRef = CGImageCreateCopyWithColorSpace(mask.CGImage, graySpace); CGColorSpaceRelease(graySpace); //combine images CGImageRef resultRef = CGImageCreateWithMask(image.CGImage, maskRef); UIImage *result = [UIImage imageWithCGImage:resultRef]; CGImageRelease(resultRef); CGImageRelease(maskRef); //display result self.imageView.image = result; } @end
對每張圖片都使用兩個獨立的檔案確實有些累贅。JPNG的庫( https://github.com/nicklockwood/JPNG )對這個技術提供了一個開源的可以複用的實現,並且添加了直接使用 +imageNamed:
和 +imageWithContentsOfFile:
方法的支援。
JPEG 2000
除了JPEG和PNG之外iOS還支援別的一些格式,例如TIFF和GIF,但是由於他們質量壓縮得更厲害,效能比JPEG和PNG糟糕的多,所以大多數情況並不用考慮。
但是iOS之後,蘋果低調添加了對JPEG 2000圖片格式的支援,所以大多數人並不知道。它甚至並不被Xcode很好的支援 - JPEG 2000圖片都沒在Interface Builder中顯示。
但是JPEG 2000圖片在(裝置和模擬器)執行時會有效,而且比JPEG質量更好,同樣也對透明通道有很好的支援。但是JPEG 2000圖片在載入和顯示圖片方面明顯要比PNG和JPEG慢得多,所以對圖片大小比執行效率更敏感的時候,使用它是一個不錯的選擇。
但仍然要對JPEG 2000保持關注,因為在後續iOS版本說不定就對它的效能做提升,但是在現階段,混合圖片對更小尺寸和質量的檔案效能會更好。
PVRTC
當前市場的每個iOS裝置都使用了Imagination Technologies PowerVR影象晶片作為GPU。PowerVR晶片支援一種叫做PVRTC(PowerVR Texture Compression)的標準圖片壓縮。
和iOS上可用的大多數圖片格式不同,PVRTC不用提前解壓就可以被直接繪製到螢幕上。這意味著在載入圖片之後不需要有解壓操作,所以記憶體中的圖片比其他圖片格式大大減少了(這取決於壓縮設定,大概只有1/60那麼大)。
但是PVRTC仍然有一些弊端:
-
儘管載入的時候消耗了更少的RAM,PVRTC檔案比JPEG要大,有時候甚至比PNG還要大(這取決於具體內容),因為壓縮演算法是針對於效能,而不是檔案尺寸。
-
PVRTC必須要是二維正方形,如果源圖片不滿足這些要求,那必須要在轉換成PVRTC的時候強制拉伸或者填充空白空間。
-
質量並不是很好,尤其是透明圖片。通常看起來更像嚴重壓縮的JPEG檔案。
-
PVRTC不能用Core Graphics繪製,也不能在普通的
UIImageView
顯示,也不能直接用作圖層的內容。你必須要用作OpenGL紋理載入PVRTC圖片,然後對映到一對三角板來在CAEAGLLayer
或者GLKView
中顯示。 -
建立一個OpenGL紋理來繪製PVRTC圖片的開銷相當昂貴。除非你想把所有圖片繪製到一個相同的上下文,不然這完全不能發揮PVRTC的優勢。
-
PVRTC使用了一個不對稱的壓縮演算法。儘管它幾乎立即解壓,但是壓縮過程相當漫長。在一個現代快速的桌面Mac電腦上,它甚至要消耗一分鐘甚至更多來生成一個PVRTC大圖。因此在iOS裝置上最好不要實時生成。
如果你願意使用OpehGL,而且即使提前生成圖片也能忍受得了,那麼PVRTC將會提供相對於別的可用格式來說非常高效的載入效能。比如,可以在主執行緒1/60秒之內載入並顯示一張2048×2048的PVRTC圖片(這已經足夠大來填充一個視網膜螢幕的iPad了),這就避免了很多使用執行緒或者快取等等複雜的技術難度。
Xcode包含了一些命令列工具例如 texturetool 來生成PVRTC圖片,但是用起來很不方便(它存在於Xcode應用程式束中),而且很受限制。一個更好的方案就是使用Imagination Technologies PVRTexTool ,可以從 http://www.imgtec.com/powervr/insider/sdkdownloads 免費獲得。
安裝了PVRTexTool之後,就可以使用如下命令在終端中把一個合適大小的PNG圖片轉換成PVRTC檔案:
/Applications/Imagination/PowerVR/GraphicsSDK/PVRTexTool/CL/OSX_x86/PVRTexToolCL -i {input_file_name}.png -o {output_file_name}.pvr -legacypvr -p -f PVRTC1_4 -q pvrtcbest
清單14.8的程式碼展示了載入和顯示PVRTC圖片的步驟(第6章 CAEAGLLayer
例子程式碼改動而來)。
清單14.8 載入和顯示PVRTC圖片
#import "ViewController.h" #import <QuartzCore/QuartzCore.h> #import <GLKit/GLKit.h> @interface ViewController () @property (nonatomic, weak) IBOutlet UIView *glView; @property (nonatomic, strong) EAGLContext *glContext; @property (nonatomic, strong) CAEAGLLayer *glLayer; @property (nonatomic, assign) GLuint framebuffer; @property (nonatomic, assign) GLuint colorRenderbuffer; @property (nonatomic, assign) GLint framebufferWidth; @property (nonatomic, assign) GLint framebufferHeight; @property (nonatomic, strong) GLKBaseEffect *effect; @property (nonatomic, strong) GLKTextureInfo *textureInfo; @end @implementation ViewController - (void)setUpBuffers { //set up frame buffer glGenFramebuffers(1, &_framebuffer); glBindFramebuffer(GL_FRAMEBUFFER, _framebuffer); //set up color render buffer glGenRenderbuffers(1, &_colorRenderbuffer); glBindRenderbuffer(GL_RENDERBUFFER, _colorRenderbuffer); glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, _colorRenderbuffer); [self.glContext renderbufferStorage:GL_RENDERBUFFER fromDrawable:self.glLayer]; glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &_framebufferWidth); glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &_framebufferHeight); //check success if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { NSLog(@"Failed to make complete framebuffer object: %i", glCheckFramebufferStatus(GL_FRAMEBUFFER)); } } - (void)tearDownBuffers { if (_framebuffer) { //delete framebuffer glDeleteFramebuffers(1, &_framebuffer); _framebuffer = 0; } if (_colorRenderbuffer) { //delete color render buffer glDeleteRenderbuffers(1, &_colorRenderbuffer); _colorRenderbuffer = 0; } } - (void)drawFrame { //bind framebuffer & set viewport glBindFramebuffer(GL_FRAMEBUFFER, _framebuffer); glViewport(0, 0, _framebufferWidth, _framebufferHeight); //bind shader program [self.effect prepareToDraw]; //clear the screen glClear(GL_COLOR_BUFFER_BIT); glClearColor(0.0, 0.0, 0.0, 0.0); //set up vertices GLfloat vertices[] = { -1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f, 1.0f, -1.0f }; //set up colors GLfloat texCoords[] = { 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f }; //draw triangle glEnableVertexAttribArray(GLKVertexAttribPosition); glEnableVertexAttribArray(GLKVertexAttribTexCoord0); glVertexAttribPointer(GLKVertexAttribPosition, 2, GL_FLOAT, GL_FALSE, 0, vertices); glVertexAttribPointer(GLKVertexAttribTexCoord0, 2, GL_FLOAT, GL_FALSE, 0, texCoords); glDrawArrays(GL_TRIANGLE_FAN, 0, 4); //present render buffer glBindRenderbuffer(GL_RENDERBUFFER, _colorRenderbuffer); [self.glContext presentRenderbuffer:GL_RENDERBUFFER]; } - (void)viewDidLoad { [super viewDidLoad]; //set up context self.glContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2]; [EAGLContext setCurrentContext:self.glContext]; //set up layer self.glLayer = [CAEAGLLayer layer]; self.glLayer.frame = self.glView.bounds; self.glLayer.opaque = NO; [self.glView.layer addSublayer:self.glLayer]; self.glLayer.drawableProperties = @{kEAGLDrawablePropertyRetainedBacking: @NO, kEAGLDrawablePropertyColorFormat: kEAGLColorFormatRGBA8}; //load texture glActiveTexture(GL_TEXTURE0); NSString *imageFile = [[NSBundle mainBundle] pathForResource:@"Snowman" ofType:@"pvr"]; self.textureInfo = [GLKTextureLoader textureWithContentsOfFile:imageFile options:nil error:NULL]; //create texture GLKEffectPropertyTexture *texture = [[GLKEffectPropertyTexture alloc] init]; texture.enabled = YES; texture.envMode = GLKTextureEnvModeDecal; texture.name = self.textureInfo.name; //set up base effect self.effect = [[GLKBaseEffect alloc] init]; self.effect.texture2d0.name = texture.name; //set up buffers [self setUpBuffers]; //draw frame [self drawFrame]; } - (void)viewDidUnload { [self tearDownBuffers]; [super viewDidUnload]; } - (void)dealloc { [self tearDownBuffers]; [EAGLContext setCurrentContext:nil]; } @end
如你所見,非常不容易,如果你對在常規應用中使用PVRTC圖片很感興趣的話(例如基於OpenGL的遊戲),可以參考一下 GLView
的庫( https://github.com/nicklockwood/GLView ),它提供了一個簡單的 GLImageView
類,重新實現了 UIImageView
的各種功能,但同時提供了PVRTC圖片,而不需要你寫任何OpenGL程式碼。
總結
在這章中,我們研究了和圖片載入解壓相關的效能問題,並延展了一系列解決方案。
在第15章“圖層效能”中,我們將討論和圖層渲染和組合相關的效能問題。