本文轉載自:http://www.cocoachina.com/ios/20150106/10840.html? 為了防止cocochina以后刪除該文章,故轉載至此;
圖像IO
潛伏期值得思考 - 凱文 帕薩特
在第13章“高效繪圖”中,我們研究了和Core Graphics繪圖相關的性能問題,以及如何修復。和繪圖性能相關緊密相關的是圖像性能。在這一章中,我們將研究如何優化從閃存驅動器或者網絡中加載和顯示圖片。
加載和潛伏
繪圖實際消耗的時間通常并不是影響性能的因素。圖片消耗很大一部分內存,而且不太可能把需要顯示的圖片都保留在內存中,所以需要在應用運行的時候周期性地加載和卸載圖片。
圖片文件加載的速度被CPU和IO(輸入/輸出)同時影響。iOS設備中的閃存已經比傳統硬盤快很多了,但仍然比RAM慢將近200倍左右,這就需要很小心地管理加載,來避免延遲。
只要有可能,試著在程序生命周期不易察覺的時候來加載圖片,例如啟動,或者在屏幕切換的過程中。按下按鈕和按鈕響應事件之間最大的延遲大概是200ms,這比動畫每一幀切換的16ms小得多。你可以在程序首次啟動的時候加載圖片,但是如果20秒內無法啟動程序的話,iOS檢測計時器就會終止你的應用(而且如果啟動大于2,3秒的話用戶就會抱怨了)。
有些時候,提前加載所有的東西并不明智。比如說包含上千張圖片的圖片傳送帶:用戶希望能夠能夠平滑快速翻動圖片,所以就不可能提前預加載所有圖片;那樣會消耗太多的時間和內存。
有時候圖片也需要從遠程網絡連接中下載,這將會比從磁盤加載要消耗更多的時間,甚至可能由于連接問題而加載失?。ㄔ趲酌腌妵L試之后)。你不能夠在主線程中加載網絡造成等待,所以需要后臺線程。
線程加載
在第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];
returncell;
}
@end

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

圖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;?}
});
});
returncell;
}
當運行更新后的版本,性能比之前不用線程的版本好多了,但仍然并不完美(圖14.3)。
我們可以看到+imageWithContentsOfFile:方法并不在CPU時間軌跡的最頂部,所以我們的確修復了延遲加載的問題。問題在于我們假設傳送器的性能瓶頸在于圖片文件的加載,但實際上并不是這樣。加載圖片數據到內存中只是問題的第一部分。

圖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?==?imageView.tag)?{
imageView.image?=?image;
}
});
});
returncell;
}
CATiledLayer
如第6章“專用圖層”中的例子所示,CATiledLayer可以用來異步加載和顯示大型圖片,而不阻塞用戶輸入。但是我們同樣可以使用CATiledLayer在UICollectionView中為每個表格創建分離的CATiledLayer實例加載傳動器圖片,每個表格僅使用一個圖層。
這樣使用CATiledLayer有幾個潛在的弊端:
CATiledLayer的隊列和緩存算法沒有暴露出來,所以我們只能祈禱它能匹配我們的需求
CATiledLayer需要我們每次重繪圖片到CGContext中,即使它已經解壓縮,而且和我們單元格尺寸一樣(因此可以直接用作圖層內容,而不需要重繪)。
我們來看看這些弊端有沒有造成不同:清單14.4顯示了使用CATiledLayer對圖片傳送器的重新實現。
清單14.4 使用CATiledLayer的圖片傳送器
#import?"ViewController.h"
#import?@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:@"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];
returncell;
}
-?(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 加載圖片之后的淡入
我們可以調整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()?@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:[NSNullnull]?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
returnnil;
}
-?(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];?}
returncell;
}
@end
果然效果更好了!當滾動的時候雖然還有一些圖片進入的延遲,但是已經非常罕見了。緩存意味著我們做了更少的加載。這里提前加載邏輯非常粗暴,其實可以把滑動速度和方向也考慮進來,但這已經比之前沒做緩存的版本好很多了。
文件格式
圖片加載性能取決于加載大圖的時間和解壓小圖時間的權衡。很多蘋果的文檔都說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
{
[superviewDidLoad];
//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:?ims?JPG:?ims",?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];
returncell;
}
@end
PNG和JPEG壓縮算法作用于兩種不同的圖片類型:JPEG對于噪點大的圖片效果很好;但是PNG更適合于扁平顏色,鋒利的線條或者一些漸變色的圖片。為了讓測評的基準更加公平,我們用一些不同的圖片來做實驗:一張照片和一張彩虹色的漸變。JPEG版本的圖片都用默認的Photoshop60%“高質量”設置編碼。結果見圖片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
{
[superviewDidLoad];
//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的時候強制拉伸或者填充空白空間。
質量并不是很好,尤其是透明圖片。通??雌饋砀駠乐貕嚎s的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
#import?@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
{
[superviewDidLoad];
//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];
[superviewDidUnload];
}
-?(void)dealloc
{
[self?tearDownBuffers];
[EAGLContext?setCurrentContext:nil];
}
@end
如你所見,非常不容易,如果你對在常規應用中使用PVRTC圖片很感興趣的話(例如基于OpenGL的游戲),可以參考一下GLView的庫(https://github.com/nicklockwood/GLView),它提供了一個簡單的GLImageView類,重新實現了UIImageView的各種功能,但同時提供了PVRTC圖片,而不需要你寫任何OpenGL代碼。
總結
在這章中,我們研究了和圖片加載解壓相關的性能問題,并延展了一系列解決方案。
在第15章“圖層性能”中,我們將討論和圖層渲染和組合相關的性能問題。
--------------------------------------------------------------------------------------------------------------------------------------------------------