13. 高效繪圖 高效繪圖 不必要的效率考慮往往是性能問題的萬惡之源。 ——William Allan Wulf 在第12章『速度的曲率』我們學習如何用Instruments來診斷Core Animation性能問題。在構建一個iOS app的時候會遇到很多潛在的性能陷阱,但是在本章我們將著眼於有關 ...
13. 高效繪圖
高效繪圖
不必要的效率考慮往往是性能問題的萬惡之源。 ——William Allan Wulf
在第12章『速度的曲率』我們學習如何用Instruments來診斷Core Animation性能問題。在構建一個iOS app的時候會遇到很多潛在的性能陷阱,但是在本章我們將著眼於有關繪製的性能問題。
13.1 軟體繪圖
軟體繪圖
術語繪圖通常在Core Animation的上下文中指代軟體繪圖(意即:不由GPU協助的繪圖)。在iOS中,軟體繪圖通常是由Core Graphics框架完成來完成。但是,在一些必要的情況下,相比Core Animation和OpenGL,Core Graphics要慢了不少。
軟體繪圖不僅效率低,還會消耗可觀的記憶體。CALayer
只需要一些與自己相關的記憶體:只有它的寄宿圖會消耗一定的記憶體空間。即使直接賦給contents
屬性一張圖片,也不需要增加額外的照片存儲大小。如果相同的一張圖片被多個圖層作為contents
屬性,那麼他們將會共用同一塊記憶體,而不是複製記憶體塊。
一個開發者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個我的iOS交流群:1012951431, 分享BAT,阿裡面試題、面試經驗,討論技術, 大家一起交流學習成長!希望幫助開發者少走彎路。
但是一旦你實現了CALayerDelegate
協議中的-drawLayer:inContext:
方法或者UIView
中的-drawRect:
方法(其實就是前者的包裝方法),圖層就創建了一個繪製上下文,這個上下文需要的大小的記憶體可從這個算式得出:圖層寬圖層高4位元組,寬高的單位均為像素。對於一個在Retina iPad上的全屏圖層來說,這個記憶體量就是 2048 15264位元組,相當於12MB記憶體,圖層每次重繪的時候都需要重新抹掉記憶體然後重新分配。
軟體繪圖的代價昂貴,除非絕對必要,你應該避免重繪你的視圖。提高繪製性能的秘訣就在於儘量避免去繪製。
13.2 矢量圖形
矢量圖形
我們用Core Graphics來繪圖的一個通常原因就是只是用圖片或是圖層效果不能輕易地繪製出矢量圖形。矢量繪圖包含一下這些:
-
任意多邊形(不僅僅是一個矩形)
-
斜線或曲線
-
文本
-
漸變
舉個例子,清單13.1 展示了一個基本的畫線應用。這個應用將用戶的觸摸手勢轉換成一個UIBezierPath
上的點,然後繪製成視圖。我們在一個UIView
子類DrawingView
中實現了所有的繪製邏輯,這個情況下我們沒有用上view controller。但是如果你喜歡你可以在view controller中實現觸摸事件處理。圖13.1是代碼運行結果。
清單13.1 用Core Graphics實現一個簡單的繪圖應用
#import "DrawingView.h" @interface DrawingView () @property (nonatomic, strong) UIBezierPath *path; @end @implementation DrawingView - (void)awakeFromNib { //create a mutable path self.path = [[UIBezierPath alloc] init]; self.path.lineJoinStyle = kCGLineJoinRound; self.path.lineCapStyle = kCGLineCapRound;  self.path.lineWidth = 5; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { //get the starting point CGPoint point = [[touches anyObject] locationInView:self]; //move the path drawing cursor to the starting point [self.path moveToPoint:point]; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { //get the current point CGPoint point = [[touches anyObject] locationInView:self]; //add a new line segment to our path [self.path addLineToPoint:point]; //redraw the view [self setNeedsDisplay]; } - (void)drawRect:(CGRect)rect { //draw path [[UIColor clearColor] setFill]; [[UIColor redColor] setStroke]; [self.path stroke]; } @end
13.3 臟矩形
臟矩形
有時候用CAShapeLayer
或者其他矢量圖形圖層替代Core Graphics並不是那麼切實可行。比如我們的繪圖應用:我們用線條完美地完成了矢量繪製。但是設想一下如果我們能進一步提高應用的性能,讓它就像一個黑板一樣工作,然後用『粉筆』來繪製線條。模擬粉筆最簡單的方法就是用一個『線刷』圖片然後將它粘貼到用戶手指碰觸的地方,但是這個方法用CAShapeLayer
沒辦法實現。
我們可以給每個『線刷』創建一個獨立的圖層,但是實現起來有很大的問題。屏幕上允許同時出現圖層上線數量大約是幾百,那樣我們很快就會超出的。這種情況下我們沒什麼辦法,就用Core Graphics吧(除非你想用OpenGL做一些更複雜的事情)。
我們的『黑板』應用的最初實現見清單13.3,我們更改了之前版本的DrawingView
,用一個畫刷位置的數組代替UIBezierPath
。圖13.2是運行結果
清單13.3 簡單的類似黑板的應用
#import "DrawingView.h" #import #define BRUSH_SIZE 32 @interface DrawingView () @property (nonatomic, strong) NSMutableArray *strokes; @end @implementation DrawingView - (void)awakeFromNib { //create array self.strokes = [NSMutableArray array]; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { //get the starting point CGPoint point = [[touches anyObject] locationInView:self]; //add brush stroke [self addBrushStrokeAtPoint:point]; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { //get the touch point CGPoint point = [[touches anyObject] locationInView:self]; //add brush stroke [self addBrushStrokeAtPoint:point]; } - (void)addBrushStrokeAtPoint:(CGPoint)point { //add brush stroke to array [self.strokes addObject:[NSValue valueWithCGPoint:point]]; //needs redraw [self setNeedsDisplay]; } - (void)drawRect:(CGRect)rect { //redraw strokes for (NSValue *value in self.strokes) { //get point CGPoint point = [value CGPointValue]; //get brush rect CGRect brushRect = CGRectMake(point.x - BRUSH_SIZE/2, point.y - BRUSH_SIZE/2, BRUSH_SIZE, BRUSH_SIZE); //draw brush stroke  [[UIImage imageNamed:@"Chalk.png"] drawInRect:brushRect]; } } @end
圖13.3 幀率和線條質量會隨時間下降。
為了減少不必要的繪製,Mac OS和iOS設備將會把屏幕區分為需要重繪的區域和不需要重繪的區域。那些需要重繪的部分被稱作『臟區域』。在實際應用中,鑒於非矩形區域邊界裁剪和混合的複雜性,通常會區分出包含指定視圖的矩形位置,而這個位置就是『臟矩形』。
當一個視圖被改動過了,TA可能需要重繪。但是很多情況下,只是這個視圖的一部分被改變了,所以重繪整個寄宿圖就太浪費了。但是Core Animation通常並不瞭解你的自定義繪圖代碼,它也不能自己計算出臟區域的位置。然而,你的確可以提供這些信息。
當你檢測到指定視圖或圖層的指定部分需要被重繪,你直接調用-setNeedsDisplayInRect:
來標記它,然後將影響到的矩形作為參數傳入。這樣就會在一次視圖刷新時調用視圖的-drawRect:
(或圖層代理的-drawLayer:inContext:方法
)。
傳入-drawLayer:inContext:
的CGContext
參數會自動被裁切以適應對應的矩形。為了確定矩形的尺寸大小,你可以用CGContextGetClipBoundingBox()
方法來從上下文獲得大小。調用-drawRect()
會更簡單,因為CGRect
會作為參數直接傳入。
你應該將你的繪製工作限制在這個矩形中。任何在此區域之外的繪製都將被自動無視,但是這樣CPU花在計算和拋棄上的時間就浪費了,實在是太不值得了。
相比依賴於Core Graphics為你重繪,裁剪出自己的繪製區域可能會讓你避免不必要的操作。那就是說,如果你的裁剪邏輯相當複雜,那還是讓Core Graphics來代勞吧,記住:當你能高效完成的時候才這樣做。
清單13.4 展示了一個-addBrushStrokeAtPoint:
方法的升級版,它只重繪當前線刷的附近區域。另外也會刷新之前線刷的附近區域,我們也可以用CGRectIntersectsRect()
來避免重繪任何舊的線刷以不至於覆蓋已更新過的區域。這樣做會顯著地提高繪製效率(見圖13.4)
清單13.4 用-setNeedsDisplayInRect:
來減少不必要的繪製
- (void)addBrushStrokeAtPoint:(CGPoint)point { //add brush stroke to array [self.strokes addObject:[NSValue valueWithCGPoint:point]]; //set dirty rect [self setNeedsDisplayInRect:[self brushRectForPoint:point]]; } - (CGRect)brushRectForPoint:(CGPoint)point { return CGRectMake(point.x - BRUSH_SIZE/2, point.y - BRUSH_SIZE/2, BRUSH_SIZE, BRUSH_SIZE); } - (void)drawRect:(CGRect)rect { //redraw strokes for (NSValue *value in self.strokes) { //get point CGPoint point = [value CGPointValue]; //get brush rect CGRect brushRect = [self brushRectForPoint:point];  //only draw brush stroke if it intersects dirty rect if (CGRectIntersectsRect(rect, brushRect)) { //draw brush stroke [[UIImage imageNamed:@"Chalk.png"] drawInRect:brushRect]; } } }
13.4 非同步繪製
非同步繪製
UIKit的單線程天性意味著寄宿圖通暢要在主線程上更新,這意味著繪製會打斷用戶交互,甚至讓整個app看起來處於無響應狀態。我們對此無能為力,但是如果能避免用戶等待繪製完成就好多了。
針對這個問題,有一些方法可以用到:一些情況下,我們可以推測性地提前在另外一個線程上繪製內容,然後將由此繪出的圖片直接設置為圖層的內容。這實現起來可能不是很方便,但是在特定情況下是可行的。Core Animation提供了一些選擇:CATiledLayer
和drawsAsynchronously
屬性。
CATiledLayer
我們在第六章簡單探索了一下CATiledLayer
。除了將圖層再次分割成獨立更新的小塊(類似於臟矩形自動更新的概念),CATiledLayer
還有一個有趣的特性:在多個線程中為每個小塊同時調用-drawLayer:inContext:
方法。這就避免了阻塞用戶交互而且能夠利用多核心新片來更快地繪製。只有一個小塊的CATiledLayer
是實現非同步更新圖片視圖的簡單方法。
drawsAsynchronously
iOS 6中,蘋果為CALayer
引入了這個令人好奇的屬性,drawsAsynchronously
屬性對傳入-drawLayer:inContext:
的CGContext進行改動,允許CGContext延緩繪製命令的執行以至於不阻塞用戶交互。
它與CATiledLayer
使用的非同步繪製並不相同。它自己的-drawLayer:inContext:
方法只會在主線程調用,但是CGContext並不等待每個繪製命令的結束。相反地,它會將命令加入隊列,當方法返回時,在後臺線程逐個執行真正的繪製。
根據蘋果的說法。這個特性在需要頻繁重繪的視圖上效果最好(比如我們的繪圖應用,或者諸如UITableViewCell
之類的),對那些只繪製一次或很少重繪的圖層內容來說沒什麼太大的幫助。
13.5 總結
總結
本章我們主要圍繞用Core Graphics軟體繪製討論了一些性能挑戰,然後探索了一些改進方法:比如提高繪製性能或者減少需要繪製的數量。第14章,『圖像IO』,我們將討論圖片的載入性能。
14. 圖像IO
圖像IO
潛伏期值得思考 - 凱文 帕薩特
在第13章“高效繪圖”中,我們研究了和Core Graphics繪圖相關的性能問題,以及如何修複。和繪圖性能相關緊密相關的是圖像性能。在這一章中,我們將研究如何優化從快閃記憶體驅動器或者網路中載入和顯示圖片。
14.1 載入和潛伏
載入和潛伏
繪圖實際消耗的時間通常並不是影響性能的因素。圖片消耗很大一部分記憶體,而且不太可能把需要顯示的圖片都保留在記憶體中,所以需要在應用運行的時候周期性地載入和卸載圖片。
圖片文件載入的速度被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() @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.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.2 緩存
緩存
如果有很多張圖片要顯示,最好不要提前把所有都載入進來,而是應該當移出屏幕之後立刻銷毀。通過選擇性的緩存,你就可以避免來回滾動時圖片重覆性的載入了。
緩存其實很簡單:就是存儲昂貴計算後的結果(或者是從快閃記憶體或者網路載入的文件)在記憶體中,以便後續使用,這樣訪問起來很快。問題在於緩存本質上是一個權衡過程 - 為了提升性能而消耗了記憶體,但是由於記憶體是一個非常寶貴的資源,所以不能把所有東西都做緩存。
何時將何物做緩存(做多久)並不總是很明顯。幸運的是,大多情況下,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() @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
果然效果更好了!當滾動的時候雖然還有一些圖片進入的延遲,但是已經非常罕見了。緩存意味著我們做了更少的載入。這裡提前載入邏輯非常粗暴,其實可以把滑動速度和方向也考慮進來,但這已經比之前沒做緩存的版本好很多了。
14.3 文件格式
文件格式
圖片載入性能取決於載入大圖的時間和解壓小圖時間的權衡。很多蘋果的文檔都說PNG是iOS所有圖片載入的最好格式。但這是極度誤導的過時信息了。
PNG圖片使用的無損壓縮演算法可以比使用JPEG的圖片做到更快地解壓,但是由於快閃記憶體訪問的原因,這些載入的時間並沒有什麼區別。
清單14.6展示了標準的應用程式載入不同尺寸圖片所需要時間的一些代碼。為了保證實驗的準確性,我們會測量每張圖片的載入和繪製時間來確保考慮到解壓性能的因素。另外每隔一秒重覆載入和繪製圖片,這樣就可以取到平均時間,使得結果更加準確。
清單14.6
#import "ViewController.h" static NSString *const ImageFolder = @"Coast Photos"; @interface ViewController () @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.4 總結
總結
在這章中,我們研究了和圖片載入解壓相關的性能問題,並延展了一系列解決方案。
在第15章“圖層性能”中,我們將討論和圖層渲染和組合相關的性能問題。