潜伏期值得思考 - 凯文 帕萨特git
在第13章“高效绘图”中,咱们研究了和Core Graphics绘图相关的性能问题,以及如何修复。和绘图性能相关紧密相关的是图像性能。在这一章中,咱们将研究如何优化从闪存驱动器或者网络中加载和显示图片。github
绘图实际消耗的时间一般并非影响性能的因素。图片消耗很大一部份内存,并且不太可能把须要显示的图片都保留在内存中,因此须要在应用运行的时候周期性地加载和卸载图片。objective-c
图片文件加载的速度被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 运行中的图片传送器
传送器中的图片尺寸为800x600像素的PNG,对iPhone5来讲,1/60秒要加载大概700KB左右的图片。当传送器滚动的时候,图片也在实时加载,因而(预期中的)卡动就发生了。时间分析工具(图14.2)显示了不少时间都消耗在了 UIImage 的 +imageWithContentsOfFile: 方法中了。很明显,图片加载形成了瓶颈。
图14.2 时间分析工具展现了CPU瓶颈
这里提高性能惟一的方式就是在另外一个线程中加载图片。这并不可以下降实际的加载时间(可能状况会更糟,由于系统可能要消耗CPU时间来处理加载的图片数据),可是主线程可以有时间作一些别的事情,好比响应用户输入,以及滑动动画。
为了在后台线程加载图片,咱们可使用GCD或者NSOperationQueue
建立自定义线程,或者使用CATiledLayer
。为了从远程网络加载图片,咱们可使用异步的NSURLConnection
,可是对本地存储的图片,并不十分有效。
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 使用后台线程加载图片来提高性能
一旦图片文件被加载就必需要进行解码,解码过程是一个至关复杂的任务,须要消耗很是长的时间。解码后的图片将一样使用至关大的内存。
用于加载的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 加载图片以后的淡入
咱们能够调整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
和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 不一样类型图片的相对加载性能
如结果所示,相对于不友好的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和PNG以外iOS还支持别的一些格式,例如TIFF和GIF,可是因为他们质量压缩得更厉害,性能比JPEG和PNG糟糕的多,因此大多数状况并不用考虑。
可是iOS以后,苹果低调添加了对JPEG 2000图片格式的支持,因此大多数人并不知道。它甚至并不被Xcode很好的支持 - JPEG 2000图片都没在Interface Builder中显示。
可是JPEG 2000图片在(设备和模拟器)运行时会有效,并且比JPEG质量更好,一样也对透明通道有很好的支持。可是JPEG 2000图片在加载和显示图片方面明显要比PNG和JPEG慢得多,因此对图片大小比运行效率更敏感的时候,使用它是一个不错的选择。
但仍然要对JPEG 2000保持关注,由于在后续iOS版本说不定就对它的性能作提高,可是在现阶段,混合图片对更小尺寸和质量的文件性能会更好。
当前市场的每一个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的游戏),能够参考一下的库(https://github.com/nicklockwood/GLView),它提供了一个简单的类,从新实现了的各类功能,但同时提供了PVRTC图片,而不须要你写任何OpenGL代码。GLViewGLImageViewUIImageView
在这章中,咱们研究了和图片加载解压相关的性能问题,并延展了一系列解决方案。
在第15章“图层性能”中,咱们将讨论和图层渲染和组合相关的性能问题。