這篇文章是博主學習了幾位大牛的關于圖片處理和顯示的文章后,結合自己的總結和實踐后的記錄。幾位大牛的文章鏈接會在后面參上。因為水平有限,可能會有錯誤。
圖片的加載
在iOS中從磁盤讀取一張圖片并顯示在屏幕上,大概需要下面幾個步驟
- 從磁盤拷貝數據到內核緩沖區
- 從內核緩沖區復制數據到用戶空間
- 生成UIImageView,把圖像數據賦值給UIImageView
- 如果圖像數據為未解碼的PNG/JPG,解碼為位圖數據
- CATransaction捕獲到UIImageView layer樹的變化
- 主線程Runloop提交CATransaction,開始進行圖像渲染
6.1. 果數據沒有字節對齊,Core Animation會再拷貝一份數據,進行字節對齊。
6.2. GPU處理位圖數據,進行渲染。
簡單理解上面幾部的話大概就是把圖片從磁盤讀入內存,然后解碼,然后渲染。其中讀入內存的操作可以由子線程執行,解碼和渲染的過程系統默認是一定要在主線程執行的。這也是很多時候顯示圖片發生卡頓的原因。不過在開發中多數情況下我們都是使用圖片處理庫來處理圖片,這些庫已經解決了主線程解碼的問題(將解碼操作放在子線程)。渲染是一定要在主線程的,這個不需要解釋了。
關于卡頓的產生
說到卡頓的產生就不得不提到圖像的顯示原理,關于這部分內容在ibireme大神寫的文章中有很詳細的講解,這里我只簡單描述下(湊個字)。
在計算機系統中顯示器要想顯示畫面首先需要CPU計算好顯示內容,然后將計算好的結果交給GPU進行渲染,在雙緩存+垂直同步機制下(iOS始終是雙緩存+垂直同步)GPU會等到顯示器發送垂直同步信號(VSync)后將渲染后的結果更新到幀緩沖區等待視頻控制器讀取數據。
解釋了圖像顯示原理后再描述產生卡頓的原因就很好理解了,iOS設備的屏幕大概每秒刷新60次(這個值取決設備硬件,比如 iPhone 真機上通常是 59.97),也就是在這1/60秒內要完成CPU執行計算,GPU執行渲染變換等等然后提交幀緩存這些操作,等待下一次VSync信號到來后把結果顯示在屏幕上。
如果在1/60秒內這些操作沒有執行完成呢?這踏馬就尷尬了。那么這一幀將會被丟棄,等待下一次VSync信號。體現在屏幕上就是界面什么都沒做,還是顯示上一幀的內容。這在肉眼看來就是卡了。
我嘗試著在工程里存入了幾張平均大小在1.5m的png圖片,然后把這些圖片放在tableView里面以每個cell一張圖片的方式顯示。在這里還需要提一下系統加載jpeg圖片時速度會比png圖片要快,但是因為XCode會在引入png圖片時對png圖片進行解碼優化,所以解碼操作上png要比jpeg更快,因為jpeg圖片的解壓算法更復雜。主要代碼如下:
#define Width [UIScreen mainScreen].bounds.size.width
#define Height [UIScreen mainScreen].bounds.size.height
@interface TableViewController ()<UITableViewDelegate, UITableViewDataSource>
@property (nonatomic, strong) UITableView *table;
@property (nonatomic, strong) NSMutableArray *dataArr;
@property (nonatomic, strong) UIScrollView *scroll;
@end
@implementation TableViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
self.dataArr = [NSMutableArray arrayWithCapacity:0];
for (NSInteger i = 1; i < 6; i++) {
NSString *path = [[NSBundle mainBundle] pathForResource:[NSString stringWithFormat:@"b%ld", i] ofType:@"png"];
[self.dataArr addObject:path];
}
[self.view addSubview:self.table];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
static NSString *identifier = @"cell";
const NSInteger imageTag = 99;
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
if (!cell) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:identifier];
}
UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
if (!imageView) {
imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, Width, Height)];
imageView.tag = imageTag;
[cell.contentView addSubview:imageView];
}
cell.tag = indexPath.row;
NSString *imagePath = self.dataArr[indexPath.row];
imageView.image = [UIImage imageWithContentsOfFile:imagePath];
return cell;
}
運行這段代碼發現已經發生了很嚴重的卡頓,原因在于這里將圖片的加載,解碼和渲染操作全部放在了主線程。在1/60秒內根本無法完成這些操作。所以需要將任務分散來緩解主線程壓力。至于為什么使用imageWithContentsOfFile:方法而不是imageNamed:,為了演示效果,我不希望系統將解碼后的圖片進行緩存所以沒有使用imageNamed方法。
關于UIImage的這兩個方法,他們的相同點是都只是將數據讀入內存而不進行解碼,只有當圖片將要顯示之前才會被解碼(事實上UIImage的幾個創建方法都是這樣的)。不同點是imageNamed:方法會在第一次解碼顯示之后將解碼后的位圖進行全局緩存,只有在程序退入后臺或者接收到內存警告時才會將位圖釋放。這也是為什么在第一次滑動tableVIew的時候會卡,之后再反復滑動就不會卡頓的原因。imageWithContentsOfFile:方法雖然在64位設備是默認也會緩存(緩存到CGImage內部),但是一旦圖片被釋放,緩存的數據也會被釋放。
imageWithContentsOfFile:這個方法的底層實現是調用了ImageIO框架的CGImageSourceCreateWithData()方法,該方法在有一個ShouldCache參數,64位設備上參數值默認是YES。(這句話99.9%抄襲自ibireme的文章)。
當我在看《iOS Core Animation: Advanced Techniques》這本書的時候,書中提到可以使用CGImageSourceCreateWithURL()方法生成image來避免延時解碼,不知道是我看的這版年代太久還是理解有誤。使用這個方法實質上和CGImageSourceCreateWithData()沒有什么區別,都達不到解碼的效果。并且在實際的代碼中測試發現確實沒有解決延時解碼的問題。測試代碼如下:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
static NSString *identifier = @"cell";
const NSInteger imageTag = 99;
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
if (!cell) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:identifier];
}
UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
if (!imageView) {
imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, Width, Height)];
imageView.tag = imageTag;
[cell.contentView addSubview:imageView];
}
cell.tag = indexPath.row;
cell.tag = indexPath.row;
imageView.image = nil;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSInteger index = indexPath.row;
NSURL *imageURL = [NSURL fileURLWithPath:self.dataArr[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);
dispatch_async(dispatch_get_main_queue(), ^{
if (index == cell.tag) {
imageView.image = image;
}
});
});
return cell;
}
將代碼修改為這樣后卡頓依然存在。
這里可以簡單實現一下異步解碼的操作,將cellForRow方法里面的部分代碼改成這樣:
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
NSInteger index = indexPath.row;
NSString *imagePath = self.dataArr[index];
UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
//redraw image using device context
UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 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;
}
});
});
將圖片繪制到畫布上,然后從畫布取出圖片。這樣做的好處是圖像的繪制完全可以放在后臺執行,我們只需在繪制完成后取出圖片,然后在主線程上賦值給UIImageView就可以,這個時候的圖片因為是程序繪制的就已經不再是jpg或者png或者其他格式了,所以自然也不需要解碼操作。這只是簡單地實現,具體的可是學習YYWebImage或者SDWebImageDecoder。
緩存和異步解碼只是緩解CPU壓力的方法之一,除此之外還有很多地方可以優化CPU和GPU資源,比如之前提到的對于圓角和陰影的處理。
另外還有一種比較有意思的方式用來顯示很大的圖片,就是使用CATiledLayer,在iOS6以前系統的地圖就是使用它來實現的。這個類的出現也是為了解決加載大圖造成的性能問題,它會將一張大圖分解成多張小圖碎片,然后分開顯示。關于CATiledLayer的使用我也是從《iOS Core Animation: Advanced Techniques》這本書中看到的,書中給了一個例子,將一張20482048的圖片分割成64張小圖,然后將CATiledLayer添加在一個大小是256 * 256的UIScrollView上,contentSize為20482048,開始的時候顯示第一片圖片,然后根據手勢的滑動方向以及當前的位置,CATiledLayer的代理方法- (void)drawLayer: inContext: 會加載相應的圖片碎片,就像是地圖應用中地圖會一塊一塊的加載出來一樣。
這種方法也可以使用到前面的例子當中,我們把代碼改成這樣:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
static NSString *identifier = @"cell";
const NSInteger imageTag = 99;
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
if (!cell) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:identifier];
}
UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
if (!imageView) {
imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, Width, Height)];
imageView.tag = imageTag;
[cell.contentView addSubview:imageView];
}
cell.tag = indexPath.row;
CATiledLayer *tileLayer = (CATiledLayer *)[cell.contentView.layer.sublayers lastObject];
if (!tileLayer) {
tileLayer = [CATiledLayer layer];
tileLayer.frame = CGRectMake(0, 0, Width, Height);
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];
}
tileLayer.contents = nil;
[tileLayer setValue:@(indexPath.row) forKey:@"index"];
[tileLayer setNeedsDisplay];
return cell;
}
可以看到當滑動屏幕的時候圖片的顯示會呈現碎片式的淡入淡出效果。
參考的文章:
iOS圖片加載速度極限優化—FastImageCache解析
iOS 處理圖片的一些小 Tip
iOS 保持界面流暢的技巧
《iOS Core Animation: Advanced Techniques》