圖像IO
潛伏期值得思考 - 凱文 帕薩特
在第13章“高效繪圖”中贞间,我們研究了和Core Graphics繪圖相關的性能問題贿条,以及如何修復。和繪圖性能相關緊密相關的是圖像性能增热。在這一章中整以,我們將研究如何優(yōu)化從閃存驅動器或者網絡中加載和顯示圖片。
加載和潛伏
繪圖實際消耗的時間通常并不是影響性能的因素峻仇。圖片消耗很大一部分內存公黑,而且不太可能把需要顯示的圖片都保留在內存中,所以需要在應用運行的時候周期性地加載和卸載圖片摄咆。
圖片文件的加載速度同時受到CPU及IO(輸入/輸出)延遲的影響凡蚜。iOS設備中的閃存已經比傳統(tǒng)硬盤快很多了,但仍然比RAM慢將近200倍左右吭从,這就需要謹慎地管理加載朝蜘,以避免延遲。
只要有可能影锈,就應當設法在程序生命周期中不易察覺的時候加載圖片芹务,例如啟動蝉绷,或者在屏幕切換的過程中。按下按鈕和按鈕響應事件之間最大的延遲大概是200ms枣抱,遠遠超過動畫幀切換所需要的16ms熔吗。你可以在程序首次啟動的時候加載圖片,但是如果20秒內無法啟動程序的話佳晶,iOS檢測計時器就會終止你的應用(而且如果啟動時間超出2或3秒的話桅狠,用戶就會抱怨)。
有些時候轿秧,提前加載所有的東西并不明智中跌。比如說包含上千張圖片的圖片傳送帶:用戶希望能夠平滑快速翻動圖片,所以就不可能提前預加載所有的圖片菇篡;那樣會消耗太多的時間和內存漩符。
有時候圖片也需要從遠程網絡連接中下載,這將會比從磁盤加載要消耗更多的時間驱还,甚至可能由于連接問題而加載失斒缺(在幾秒鐘嘗試之后)。你不能在主線程中加載網絡议蟆,并在屏幕凍結期間期望用戶去等待它闷沥,所以需要后臺線程。
線程加載
在第12章“性能調優(yōu)”我們的聯(lián)系人列表例子中咐容,圖片都非常小舆逃,所以可以在主線程同步加載。但是對于大圖來說戳粒,這樣做就不太合適了路狮,因為加載會消耗很長時間,造成滑動的不流暢享郊±雷妫滑動動畫會在主線程的run loop中更新,它們是在渲染服務進程中運行的炊琉,并因此更容易比CAAnimation遭受CPU相關的性能問題展蒂。
清單14.1顯示了一個通過UICollectionView
實現(xiàn)的基礎的圖片傳送器。圖片在主線程中-collectionView:cellForItemAtIndexPath:
方法中同步加載(見圖14.1)苔咪。
清單14.1 使用UICollectionView
實現(xiàn)的圖片傳送器
#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
傳送器中的圖片尺寸為800x600像素的PNG锰悼,對iPhone5來說,1/60秒要加載大概700KB左右的圖片团赏。當傳送器滾動的時候箕般,圖片也在實時加載,于是(預期中的)卡動就發(fā)生了舔清。時間分析工具(圖14.2)顯示了很多時間都消耗在了UIImage
的+imageWithContentsOfFile:
方法中了丝里。很明顯曲初,圖片加載造成了瓶頸。
這里提升性能唯一的方式就是在另一個線程中加載圖片杯聚。這并不能夠降低實際的加載時間(可能情況會更糟臼婆,因為系統(tǒng)可能要消耗CPU時間來處理加載的圖片數(shù)據(jù)),但是主線程能夠有時間做一些別的事情幌绍,比如響應用戶輸入颁褂,以及滑動動畫。
為了在后臺線程加載圖片傀广,我們可以使用GCD或者NSOperationQueue
創(chuàng)建自定義線程颁独,或者使用CATiledLayer
。為了從遠程網絡加載圖片伪冰,我們可以使用異步的NSURLConnection
脆丁,但是對本地存儲的圖片睁蕾,并不十分有效钳宪。
GCD和NSOperationQueue
GCD(Grand Central Dispatch)和NSOperationQueue
很類似忠烛,都給我們提供了隊列閉包塊來在線程中按一定順序來執(zhí)行。NSOperationQueue
有一個Objecive-C接口(而不是使用GCD的全局C函數(shù))寂汇,同樣在操作優(yōu)先級和依賴關系上提供了很好的粒度控制,但是需要更多地設置代碼捣染。
清單14.2顯示了在低優(yōu)先級的后臺隊列而不是主線程中使用GCD加載圖片的-collectionView:cellForItemAtIndexPath:
方法骄瓣,然后當需要加載圖片到視圖的時候切換到主線程,因為在后臺線程訪問視圖會有安全隱患耍攘。
由于視圖在UICollectionView
會被循環(huán)利用榕栏,我們加載圖片的時候不能確定是否被不同的索引重新復用。為了避免圖片加載到錯誤的視圖中蕾各,我們在加載前把單元格打上索引的標簽扒磁,然后在設置圖片的時候檢測標簽是否發(fā)生了改變。
清單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時間軌跡的最頂部钧排,所以我們的確修復了延遲加載的問題恨溜。問題在于我們假設傳送器的性能瓶頸在于圖片文件的加載符衔,但實際上并不是這樣找前。加載圖片數(shù)據(jù)到內存中只是問題的第一部分躺盛。
延遲解壓
一旦圖片文件被加載就必須要進行解碼颗品,解碼過程是一個相當復雜的任務沃缘,需要消耗非常長的時間槐臀。解碼后的圖片將同樣使用相當大的內存水慨。
用于加載的CPU時間相對于解碼來說根據(jù)圖片格式而不同晰洒。對于PNG圖片來說,加載會比JPEG更長治宣,因為文件可能更大侮邀,但是解碼會相對較快贝润,而且Xcode會把PNG圖片進行解碼優(yōu)化之后引入工程打掘。JPEG圖片更小尊蚁,加載更快枝誊,但是解壓的步驟要消耗更長的時間,因為JPEG解壓算法比基于zip的PNG算法更加復雜耐版。
當加載圖片的時候粪牲,iOS通常會延遲解壓圖片的時間腺阳,直到加載到內存之后穿香。這就會在準備繪制圖片的時候影響性能皮获,因為需要在繪制之前進行解壓(通常是消耗時間的問題所在)洒宝。
最簡單的方法就是使用UIImage
的+imageNamed:
方法避免延時加載雁歌。不像+imageWithContentsOfFile:
(和其他別的UIImage
加載方法)靠瞎,這個方法會在加載圖片之后立刻進行解壓(就和本章之前我們談到的好處一樣)较坛。問題在于+imageNamed:
只對從應用資源束中的圖片有效丑勤,所以對用戶生成的圖片內容或者是下載的圖片就沒法使用了法竞。
另一種立刻加載圖片的方法就是把它設置成圖層內容岔霸,或者是UIImageView
的image
屬性呆细。不幸的是絮爷,這又需要在主線程執(zhí)行坑夯,所以不會對性能有所提升柜蜈。
第三種方式就是繞過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
來創(chuàng)建圖片鳖谈,強制圖片立刻解壓缆娃,然后在圖片的生命周期保留解壓后的版本贯要。
最后一種方式就是使用UIKit加載圖片字逗,但是需要立刻將它繪制到CGContext
中去宅广。圖片必須要在繪制之前解壓跟狱,所以就要立即強制解壓驶臊。這樣的好處在于繪制圖片可以在后臺線程(例如加載本身)中執(zhí)行扛门,而不會阻塞UI论寨。
有兩種方式可以為強制解壓提前渲染圖片:
將圖片的一個像素繪制成一個像素大小的
CGContext
政基。這樣仍然會解壓整張圖片沮明,但是繪制本身并沒有消耗任何時間荐健。這樣的好處在于加載的圖片并不會在特定的設備上為繪制做優(yōu)化江场,所以可以在任何時間點繪制出來址否。同樣iOS也就可以丟棄解壓后的圖片來節(jié)省內存了佑附。將整張圖片繪制到
CGContext
中音同,丟棄原始的圖片权均,并且用一個從上下文內容中新的圖片來代替叽赊。這樣比繪制單一像素那樣需要更加復雜的計算必指,但是因此產生的圖片將會為繪制做優(yōu)化取劫,而且由于原始壓縮圖片被拋棄了谱邪,iOS就不能夠隨時丟棄任何解壓后的圖片來節(jié)省內存了惦银。
需要注意的是蘋果特別推薦了不要使用這些詭計來繞過標準圖片解壓邏輯(所以也是他們選擇用默認處理方式的原因)扯俱,但是如果你使用很多大圖來構建應用殊校,那如果想提升性能为流,就只能和系統(tǒng)博弈了敬察。
如果不使用+imageNamed:
莲祸,那么把整張圖片繪制到CGContext
可能是最佳的方式了锐帜。盡管你可能認為多余的繪制相較別的解壓技術而言性能不是很高抹估,但是新創(chuàng)建的圖片(在特定的設備上做過優(yōu)化)可能比原始圖片繪制的更快药蜻。
同樣,如果想顯示圖片到比原始尺寸小的容器中踱卵,那么一次性在后臺線程重新繪制到正確的尺寸會比每次顯示的時候都做縮放會更有效(盡管在這個例子中我們加載的圖片呈現(xiàn)正確的尺寸惋砂,所以不需要多余的優(yōu)化)西饵。
如果修改了-collectionView:cellForItemAtIndexPath:
方法來重繪圖片(清單14.3)眷柔,你會發(fā)現(xiàn)滑動更加平滑驯嘱。
清單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
中為每個表格創(chuàng)建分離的CATiledLayer
實例加載傳動器圖片淹朋,每個表格僅使用一個圖層础芍。
這樣使用CATiledLayer
有幾個潛在的弊端:
CATiledLayer
的隊列和緩存算法沒有暴露出來仑性,所以我們只能祈禱它能匹配我們的需求CATiledLayer
需要我們每次重繪圖片到CGContext
中,即使它已經解壓縮晨汹,而且和我們單元格尺寸一樣(因此可以直接用作圖層內容淘这,而不需要重繪)铝穷。
我們來看看這些弊端有沒有造成不同:清單14.4顯示了使用CATiledLayer
對圖片傳送器的重新實現(xiàn)曙聂。
清單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實現(xiàn)的代碼量差不多委煤。僅有一個問題在于圖片加載到屏幕上后有一個明顯的淡入(圖14.4)。
我們可以調整CATiledLayer
的fadeDuration
屬性來調整淡入的速度讥邻,或者直接將整個漸變移除系宜,但是這并沒有根本性地去除問題:在圖片加載到準備繪制的時候總會有一個延遲盹牧,這將會導致滑動時候新圖片的跳入汰寓。這并不是CATiledLayer
的問題有滑,使用GCD的版本也有這個問題。
即使使用上述我們討論的所有加載圖片和緩存的技術睛榄,有時候仍然會發(fā)現(xiàn)實時加載大圖還是有問題场靴。就和13章中提到的那樣,iPad上一整個視網膜屏圖片分辨率達到了2048x1536轨帜,而且會消耗12MB的RAM(未壓縮)蚌父。第三代iPad的硬件并不能支持1/60秒的幀率加載苟弛,解壓和顯示這種圖片。即使用后臺線程加載來避免動畫卡頓缤削,仍然解決不了問題亭敢。
我們可以在加載的同時顯示一個占位圖片吨拗,但這并沒有根本解決問題,我們可以做到更好娇妓。
分辨率交換
視網膜分辨率(根據(jù)蘋果營銷定義)代表了人的肉眼在正常視角距離能夠分辨的最小像素尺寸哈恰。但是這只能應用于靜態(tài)像素。當觀察一個移動圖片時荠医,你的眼睛就會對細節(jié)不敏感彬向,于是一個低分辨率的圖片和視網膜質量的圖片沒什么區(qū)別了娃胆。
如果需要快速加載和顯示移動大圖里烦,簡單的辦法就是欺騙人眼,在移動傳送器的時候顯示一個小圖(或者低分辨率)别厘,然后當停止的時候再換成大圖触趴。這意味著我們需要對每張圖片存儲兩份不同分辨率的副本爽冕,但是幸運的是,由于需要同時支持Retina和非Retina設備披蕉,本來這就是普遍要做到的颈畸。
如果從遠程源或者用戶的相冊加載沒有可用的低分辨率版本圖片,那就可以動態(tài)將大圖繪制到較小的CGContext
没讲,然后存儲到某處以備復用眯娱。
為了做到圖片交換,我們需要利用UIScrollView
的一些實現(xiàn)UIScrollViewDelegate
協(xié)議的委托方法(和其他類似于UITableView
和UICollectionView
基于滾動視圖的控件一樣):
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate;
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView;
你可以使用這幾個方法來檢測傳送器是否停止?jié)L動徙缴,然后加載高分辨率的圖片。只要高分辨率圖片和低分辨率圖片尺寸顏色保持一致嘁信,你會很難察覺到替換的過程(確保在同一臺機器使用相同的圖像程序或者腳本生成這些圖片)于样。
緩存
如果有很多張圖片要顯示,提前把它們全部都加載進去是不切實際的潘靖,但是穿剖,這并不意味著,你在遇到加載問題后卦溢,當其移出屏幕時就立刻將其銷毀携御。通過選擇性的緩存,你就可以避免來回滾動時圖片重復性的加載了既绕。
緩存其實很簡單:就是將昂貴計算后的結果(或者是從閃存或者網絡加載的文件)存儲到內存中啄刹,以便后續(xù)使用,這樣訪問起來很快凄贩。問題在于緩存本質上是一個權衡過程 - 為了提升性能而消耗了內存誓军,但是由于內存是一個非常寶貴的資源,所以不能把所有東西都做緩存疲扎。
何時將何物做緩存(做多久)并不總是很明顯昵时。幸運的是,大多情況下椒丧,iOS都為我們做好了圖片的緩存壹甥。
+imageNamed:
方法
之前我們提到使用[UIImage imageNamed:]
加載圖片有個好處在于可以立刻解壓圖片而不用等到繪制的時候。但是[UIImage imageNamed:]
方法有另一個非常顯著的好處:它在內存中自動緩存了解壓后的圖片壶熏,即使你自己沒有保留對它的任何引用句柠。
對于iOS應用那些主要的圖片(例如圖標,按鈕和背景圖片),使用[UIImage imageNamed:]
加載圖片是最簡單最有效的方式溯职。在nib文件中引用的圖片同樣也是這個機制精盅,所以你很多時候都在隱式的使用它。
但是[UIImage imageNamed:]
并不適用任何情況谜酒。它為用戶界面做了優(yōu)化叹俏,但是并不是對應用程序需要顯示的所有類型的圖片都適用。有些時候你還是要實現(xiàn)自己的緩存機制僻族,原因如下:
[UIImage imageNamed:]
方法僅僅適用于在應用程序資源束目錄下的圖片粘驰,但是大多數(shù)應用的許多圖片都要從網絡或者是用戶的相機中獲取,所以[UIImage imageNamed:]
就沒法用了述么。[UIImage imageNamed:]
緩存用來存儲應用界面的圖片(按鈕蝌数,背景等等)。如果對照片這種大圖也用這種緩存碉输,那么iOS系統(tǒng)就很可能會移除這些圖片來節(jié)省內存。那么在切換頁面時性能就會下降亭珍,因為這些圖片都需要重新加載敷钾。對傳送器的圖片使用一個單獨的緩存機制就可以把它和應用圖片的生命周期解耦。[UIImage imageNamed:]
緩存機制并不是公開的肄梨,所以你不能很好地控制它阻荒。例如,你沒法做到檢測圖片是否在加載之前就做了緩存众羡,不能夠設置緩存大小侨赡,當圖片沒用的時候也不能把它從緩存中移除。
自定義緩存
構建一個所謂的緩存系統(tǒng)非常困難粱侣。菲爾 卡爾頓曾經說過:“在計算機科學中只有兩件難事:緩存和命名”羊壹。
如果要寫自己的圖片緩存的話,那該如何實現(xiàn)呢齐婴?讓我們來看看要涉及哪些方面:
選擇一個合適的緩存鍵 - 緩存鍵用來做圖片的唯一標識油猫。如果實時創(chuàng)建圖片,通常不太好生成一個字符串來區(qū)分別的圖片柠偶。在我們的圖片傳送帶例子中就很簡單情妖,我們可以用圖片的文件名或者表格索引。
提前緩存 - 如果生成和加載數(shù)據(jù)的代價很大诱担,你可能想當?shù)谝淮涡枰玫降臅r候再去加載和緩存毡证。提前加載的邏輯是應用內在就有的,但是在我們的例子中蔫仙,這也非常好實現(xiàn)料睛,因為對于一個給定的位置和滾動方向,我們就可以精確地判斷出哪一張圖片將會出現(xiàn)。
緩存失效 - 如果圖片文件發(fā)生了變化秦效,怎樣才能通知到緩存更新呢雏蛮?這是個非常困難的問題(就像菲爾 卡爾頓提到的),但是幸運的是當從程序資源加載靜態(tài)圖片的時候并不需要考慮這些阱州。對用戶提供的圖片來說(可能會被修改或者覆蓋)挑秉,一個比較好的方式就是當圖片緩存的時候打上一個時間戳以便當文件更新的時候作比較。
緩存回收 - 當內存不夠的時候苔货,如何判斷哪些緩存需要清空呢犀概?這就需要到你寫一個合適的算法了。幸運的是夜惭,對緩存回收的問題姻灶,蘋果提供了一個叫做
NSCache
通用的解決方案
NSCache
NSCache
和NSDictionary
類似。你可以通過-setObject:forKey:
和-object:forKey:
方法分別來插入诈茧,檢索产喉。和字典不同的是,NSCache
在系統(tǒng)低內存的時候自動丟棄存儲的對象敢会。
NSCache
用來判斷何時丟棄對象的算法并沒有在文檔中給出曾沈,但是你可以使用-setCountLimit:
方法設置緩存大小,以及-setObject:forKey:cost:
來對每個存儲的對象指定消耗的值來提供一些暗示鸥昏。
指定消耗數(shù)值可以用來指定相對的重建成本塞俱。如果對大圖指定一個大的消耗值,那么緩存就知道這些物體的存儲更加昂貴吏垮,于是當有大的性能問題的時候才會丟棄這些物體障涯。你也可以用-setTotalCostLimit:
方法來指定全體緩存的尺寸。
NSCache
是一個普遍的緩存解決方案膳汪,我們創(chuàng)建一個比傳送器案例更好的自定義的緩存類唯蝶。(例如,我們可以基于不同的緩存圖片索引和當前中間索引來判斷哪些圖片需要首先被釋放)遗嗽。但是NSCache
對我們當前的緩存需求來說已經足夠了生棍;沒必要過早做優(yōu)化。
使用圖片緩存和提前加載的實現(xiàn)來擴展之前的傳送器案例媳谁,然后來看看是否效果更好(見清單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的圖片做到更快地解壓昭伸,但是由于閃存訪問的原因梧乘,這些加載的時間并沒有什么區(qū)別。
清單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徙融。
如結果所示洒缀,相對于不友好的PNG圖片瑰谜,相同像素的JPEG圖片總是比PNG加載更快,除非一些非常小的圖片树绩、但對于友好的PNG圖片萨脑,一些中大尺寸的圖效果還是很好的。
所以對于之前的圖片傳送器程序來說饺饭,JPEG會是個不錯的選擇渤早。如果用JPEG的話,一些多線程和緩存策略都沒必要了瘫俊。
但JPEG圖片并不是所有情況都適用鹊杖。如果圖片需要一些透明效果,或者壓縮之后細節(jié)損耗很多扛芽,那就該考慮用別的格式了骂蓖。蘋果在iOS系統(tǒng)中對PNG和JPEG都做了一些優(yōu)化,所以普通情況下都應該用這種格式川尖。也就是說在一些特殊的情況下才應該使用別的格式登下。
混合圖片
對于包含透明的圖片來說,最好是使用壓縮透明通道的PNG圖片和壓縮RGB部分的JPEG圖片混合起來加載。這就對任何格式都適用了被芳,而且無論從質量還是文件尺寸還是加載性能來說都和PNG和JPEG的圖片相近缰贝。相關分別加載顏色和遮罩圖片并在運行時合成的代碼見14.7。
清單14.7 從PNG遮罩和JPEG創(chuàng)建的混合圖片
#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)對這個技術提供了一個開源的可以復用的實現(xiàn)剩晴,并且添加了直接使用+imageNamed:
和+imageWithContentsOfFile:
方法的支持。
JPEG 2000
除了JPEG和PNG之外iOS還支持別的一些格式篓冲,例如TIFF和GIF李破,但是由于他們質量壓縮得更厲害,性能比JPEG和PNG糟糕的多壹将,所以大多數(shù)情況并不用考慮嗤攻。
但是iOS 5之后,蘋果低調添加了對JPEG 2000圖片格式的支持诽俯,所以大多數(shù)人并不知道妇菱。它甚至并不被Xcode很好的支持 - JPEG 2000圖片都沒在Interface Builder中顯示。
但是JPEG 2000圖片在(設備和模擬器)運行時會有效暴区,而且比JPEG質量更好闯团,同樣也對透明通道有很好的支持。但是JPEG 2000圖片在加載和顯示圖片方面明顯要比PNG和JPEG慢得多仙粱,所以對圖片大小比運行效率更敏感的時候房交,使用它是一個不錯的選擇。
但仍然要對JPEG 2000保持關注伐割,因為在后續(xù)iOS版本說不定就對它的性能做提升候味,但是在現(xiàn)階段,混合圖片對更小尺寸和質量的文件性能會更好隔心。
PVRTC
當前市場的每個iOS設備都使用了Imagination Technologies PowerVR圖像芯片作為GPU白群。PowerVR芯片支持一種叫做PVRTC(PowerVR Texture Compression)的標準圖片壓縮。
和iOS上可用的大多數(shù)圖片格式不同硬霍,PVRTC不用提前解壓就可以被直接繪制到屏幕上帜慢。這意味著在加載圖片之后不需要有解壓操作,所以內存中的圖片比其他圖片格式大大減少了(這取決于壓縮設置唯卖,大概只有1/60那么大)粱玲。
但是PVRTC仍然有一些弊端:
盡管加載的時候消耗了更少的RAM,PVRTC文件比JPEG要大拜轨,有時候甚至比PNG還要大(這取決于具體內容)抽减,因為壓縮算法是針對于性能,而不是文件尺寸撩轰。
PVRTC必須要是二維正方形胯甩,如果源圖片不滿足這些要求昧廷,那必須要在轉換成PVRTC的時候強制拉伸或者填充空白空間。
質量并不是很好偎箫,尤其是透明圖片木柬。通常看起來更像嚴重壓縮的JPEG文件淹办。
PVRTC不能用Core Graphics繪制眉枕,也不能在普通的
UIImageView
顯示,也不能直接用作圖層的內容怜森。你必須要用作OpenGL紋理加載PVRTC圖片速挑,然后映射到一對三角形中,并在CAEAGLLayer
或者GLKView
中顯示副硅。創(chuàng)建一個OpenGL紋理來繪制PVRTC圖片的開銷相當昂貴姥宝。除非你想把所有圖片繪制到一個相同的上下文,不然這完全不能發(fā)揮PVRTC的優(yōu)勢恐疲。
PVRTC使用了一個不對稱的壓縮算法腊满。盡管它幾乎立即解壓,但是壓縮過程相當漫長培己。在一個現(xiàn)代快速的桌面Mac電腦上碳蛋,它甚至要消耗一分鐘甚至更多來生成一個PVRTC大圖。因此在iOS設備上最好不要實時生成省咨。
如果你愿意使用OpenGL肃弟,而且即使提前生成圖片也能忍受得了,那么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
如你所見掀亥,非常不容易撞反,如果你對在常規(guī)應用中使用PVRTC圖片很感興趣的話(例如基于OpenGL的游戲),可以參考一下GLView
的庫(https://github.com/nicklockwood/GLView)搪花,它提供了一個簡單的GLImageView
類蝙云,重新實現(xiàn)了UIImageView
的各種功能,但同時提供了PVRTC圖片摔蓝,而不需要你寫任何OpenGL代碼倦踢。
總結
在這章中,我們研究了和圖片加載解壓相關的性能問題卖子,并延展了一系列解決方案。