iOS核心动画高级技巧 - 7 - 图1

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实现一个简单的绘图应用

  1. #import "DrawingView.h"
  2. @interface DrawingView ()
  3. @property (nonatomic, strong) UIBezierPath *path;
  4. @end
  5. @implementation DrawingView
  6. - (void)awakeFromNib
  7. {
  8. //create a mutable path
  9. self.path = [[UIBezierPath alloc] init];
  10. self.path.lineJoinStyle = kCGLineJoinRound;
  11. self.path.lineCapStyle = kCGLineCapRound;
  12. self.path.lineWidth = 5;
  13. }
  14. - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
  15. {
  16. //get the starting point
  17. CGPoint point = [[touches anyObject] locationInView:self];
  18. //move the path drawing cursor to the starting point
  19. [self.path moveToPoint:point];
  20. }
  21. - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
  22. {
  23. //get the current point
  24. CGPoint point = [[touches anyObject] locationInView:self];
  25. //add a new line segment to our path
  26. [self.path addLineToPoint:point];
  27. //redraw the view
  28. [self setNeedsDisplay];
  29. }
  30. - (void)drawRect:(CGRect)rect
  31. {
  32. //draw path
  33. [[UIColor clearColor] setFill];
  34. [[UIColor redColor] setStroke];
  35. [self.path stroke];
  36. }
  37. @end

13.3 脏矩形

脏矩形

有时候用CAShapeLayer或者其他矢量图形图层替代Core Graphics并不是那么切实可行。比如我们的绘图应用:我们用线条完美地完成了矢量绘制。但是设想一下如果我们能进一步提高应用的性能,让它就像一个黑板一样工作,然后用『粉笔』来绘制线条。模拟粉笔最简单的方法就是用一个『线刷』图片然后将它粘贴到用户手指碰触的地方,但是这个方法用CAShapeLayer没办法实现。

我们可以给每个『线刷』创建一个独立的图层,但是实现起来有很大的问题。屏幕上允许同时出现图层上线数量大约是几百,那样我们很快就会超出的。这种情况下我们没什么办法,就用Core Graphics吧(除非你想用OpenGL做一些更复杂的事情)。

我们的『黑板』应用的最初实现见清单13.3,我们更改了之前版本的DrawingView,用一个画刷位置的数组代替UIBezierPath。图13.2是运行结果

清单13.3 简单的类似黑板的应用

  1. #import "DrawingView.h"
  2. #import
  3. #define BRUSH_SIZE 32
  4. @interface DrawingView ()
  5. @property (nonatomic, strong) NSMutableArray *strokes;
  6. @end
  7. @implementation DrawingView
  8. - (void)awakeFromNib
  9. {
  10. //create array
  11. self.strokes = [NSMutableArray array];
  12. }
  13. - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
  14. {
  15. //get the starting point
  16. CGPoint point = [[touches anyObject] locationInView:self];
  17. //add brush stroke
  18. [self addBrushStrokeAtPoint:point];
  19. }
  20. - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
  21. {
  22. //get the touch point
  23. CGPoint point = [[touches anyObject] locationInView:self];
  24. //add brush stroke
  25. [self addBrushStrokeAtPoint:point];
  26. }
  27. - (void)addBrushStrokeAtPoint:(CGPoint)point
  28. {
  29. //add brush stroke to array
  30. [self.strokes addObject:[NSValue valueWithCGPoint:point]];
  31. //needs redraw
  32. [self setNeedsDisplay];
  33. }
  34. - (void)drawRect:(CGRect)rect
  35. {
  36. //redraw strokes
  37. for (NSValue *value in self.strokes) {
  38. //get point
  39. CGPoint point = [value CGPointValue];
  40. //get brush rect
  41. CGRect brushRect = CGRectMake(point.x - BRUSH_SIZE/2, point.y - BRUSH_SIZE/2, BRUSH_SIZE, BRUSH_SIZE);
  42. //draw brush stroke 
  43. [[UIImage imageNamed:@"Chalk.png"] drawInRect:brushRect];
  44. }
  45. }
  46. @end

[图片上传失败…(image-ce2038-1576648798432)]

图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:来减少不必要的绘制

  1. - (void)addBrushStrokeAtPoint:(CGPoint)point
  2. {
  3. //add brush stroke to array
  4. [self.strokes addObject:[NSValue valueWithCGPoint:point]];
  5. //set dirty rect
  6. [self setNeedsDisplayInRect:[self brushRectForPoint:point]];
  7. }
  8. - (CGRect)brushRectForPoint:(CGPoint)point
  9. {
  10. return CGRectMake(point.x - BRUSH_SIZE/2, point.y - BRUSH_SIZE/2, BRUSH_SIZE, BRUSH_SIZE);
  11. }
  12. - (void)drawRect:(CGRect)rect
  13. {
  14. //redraw strokes
  15. for (NSValue *value in self.strokes) {
  16. //get point
  17. CGPoint point = [value CGPointValue];
  18. //get brush rect
  19. CGRect brushRect = [self brushRectForPoint:point];
  20. //only draw brush stroke if it intersects dirty rect
  21. if (CGRectIntersectsRect(rect, brushRect)) {
  22. //draw brush stroke
  23. [[UIImage imageNamed:@"Chalk.png"] drawInRect:brushRect];
  24. }
  25. }
  26. }

13.4 异步绘制

异步绘制

UIKit的单线程天性意味着寄宿图通畅要在主线程上更新,这意味着绘制会打断用户交互,甚至让整个app看起来处于无响应状态。我们对此无能为力,但是如果能避免用户等待绘制完成就好多了。

针对这个问题,有一些方法可以用到:一些情况下,我们可以推测性地提前在另外一个线程上绘制内容,然后将由此绘出的图片直接设置为图层的内容。这实现起来可能不是很方便,但是在特定情况下是可行的。Core Animation提供了一些选择:CATiledLayerdrawsAsynchronously属性。

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实现的图片传送器

  1. #import "ViewController.h"
  2. @interface ViewController()
  3. @property (nonatomic, copy) NSArray *imagePaths;
  4. @property (nonatomic, weak) IBOutlet UICollectionView *collectionView;
  5. @end
  6. @implementation ViewController
  7. - (void)viewDidLoad
  8. {
  9. //set up data
  10. self.imagePaths =
  11. [[NSBundle mainBundle] pathsForResourcesOfType:@"png" inDirectory:@"Vacation Photos"];
  12. //register cell class
  13. [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"Cell"];
  14. }
  15. - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
  16. {
  17. return [self.imagePaths count];
  18. }
  19. - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
  20. cellForItemAtIndexPath:(NSIndexPath *)indexPath
  21. {
  22. //dequeue cell
  23. UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
  24. //add image view
  25. const NSInteger imageTag = 99;
  26. UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
  27. if (!imageView) {
  28. imageView = [[UIImageView alloc] initWithFrame: cell.contentView.bounds];
  29. imageView.tag = imageTag;
  30. [cell.contentView addSubview:imageView];
  31. }
  32. //set image
  33. NSString *imagePath = self.imagePaths[indexPath.row];
  34. imageView.image = [UIImage imageWithContentsOfFile:imagePath];
  35. return cell;
  36. }
  37. @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加载传送图片

  1. - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
  2. cellForItemAtIndexPath:(NSIndexPath *)indexPath
  3. {
  4. //dequeue cell
  5. UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell"
  6. forIndexPath:indexPath];
  7. //add image view
  8. const NSInteger imageTag = 99;
  9. UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
  10. if (!imageView) {
  11. imageView = [[UIImageView alloc] initWithFrame: cell.contentView.bounds];
  12. imageView.tag = imageTag;
  13. [cell.contentView addSubview:imageView];
  14. }
  15. //tag cell with index and clear current image
  16. cell.tag = indexPath.row;
  17. imageView.image = nil;
  18. //switch to background thread
  19. dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
  20. //load image
  21. NSInteger index = indexPath.row;
  22. NSString *imagePath = self.imagePaths[index];
  23. UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
  24. //set image on main thread, but only if index still matches up
  25. dispatch_async(dispatch_get_main_queue(), ^{
  26. if (index == cell.tag) {
  27. imageView.image = image; }
  28. });
  29. });
  30. return cell;
  31. }

当运行更新后的版本,性能比之前不用线程的版本好多了,但仍然并不完美(图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

NSCacheNSDictionary类似。你可以通过-setObject:forKey:-object:forKey:方法分别来插入,检索。和字典不同的是,NSCache在系统低内存的时候自动丢弃存储的对象。

NSCache用来判断何时丢弃对象的算法并没有在文档中给出,但是你可以使用-setCountLimit:方法设置缓存大小,以及-setObject:forKey:cost:来对每个存储的对象指定消耗的值来提供一些暗示。

指定消耗数值可以用来指定相对的重建成本。如果对大图指定一个大的消耗值,那么缓存就知道这些物体的存储更加昂贵,于是当有大的性能问题的时候才会丢弃这些物体。你也可以用-setTotalCostLimit:方法来指定全体缓存的尺寸。

NSCache是一个普遍的缓存解决方案,我们创建一个比传送器案例更好的自定义的缓存类。(例如,我们可以基于不同的缓存图片索引和当前中间索引来判断哪些图片需要首先被释放)。但是NSCache对我们当前的缓存需求来说已经足够了;没必要过早做优化。

使用图片缓存和提前加载的实现来扩展之前的传送器案例,然后来看看是否效果更好(见清单14.5)。

清单14.5 添加缓存

  1. #import "ViewController.h"
  2. @interface ViewController()
  3. @property (nonatomic, copy) NSArray *imagePaths;
  4. @property (nonatomic, weak) IBOutlet UICollectionView *collectionView;
  5. @end
  6. @implementation ViewController
  7. - (void)viewDidLoad
  8. {
  9. //set up data
  10. self.imagePaths = [[NSBundle mainBundle] pathsForResourcesOfType:@"png" inDirectory:@"Vacation Photos"];
  11. //register cell class
  12. [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"Cell"];
  13. }
  14. - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
  15. {
  16. return [self.imagePaths count];
  17. }
  18. - (UIImage *)loadImageAtIndex:(NSUInteger)index
  19. {
  20. //set up cache
  21. static NSCache *cache = nil;
  22. if (!cache) {
  23. cache = [[NSCache alloc] init];
  24. }
  25. //if already cached, return immediately
  26. UIImage *image = [cache objectForKey:@(index)];
  27. if (image) {
  28. return [image isKindOfClass:[NSNull class]]? nil: image;
  29. }
  30. //set placeholder to avoid reloading image multiple times
  31. [cache setObject:[NSNull null] forKey:@(index)];
  32. //switch to background thread
  33. dispatch_async( dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
  34. //load image
  35. NSString *imagePath = self.imagePaths[index];
  36. UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
  37. //redraw image using device context
  38. UIGraphicsBeginImageContextWithOptions(image.size, YES, 0);
  39. [image drawAtPoint:CGPointZero];
  40. image = UIGraphicsGetImageFromCurrentImageContext();
  41. UIGraphicsEndImageContext();
  42. //set image for correct image view
  43. dispatch_async(dispatch_get_main_queue(), ^{ //cache the image
  44. [cache setObject:image forKey:@(index)];
  45. //display the image
  46. NSIndexPath *indexPath = [NSIndexPath indexPathForItem: index inSection:0]; UICollectionViewCell *cell = [self.collectionView cellForItemAtIndexPath:indexPath];
  47. UIImageView *imageView = [cell.contentView.subviews lastObject];
  48. imageView.image = image;
  49. });
  50. });
  51. //not loaded yet
  52. return nil;
  53. }
  54. - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
  55. {
  56. //dequeue cell
  57. UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
  58. //add image view
  59. UIImageView *imageView = [cell.contentView.subviews lastObject];
  60. if (!imageView) {
  61. imageView = [[UIImageView alloc] initWithFrame:cell.contentView.bounds];
  62. imageView.contentMode = UIViewContentModeScaleAspectFit;
  63. [cell.contentView addSubview:imageView];
  64. }
  65. //set or load image for this index
  66. imageView.image = [self loadImageAtIndex:indexPath.item];
  67. //preload image for previous and next index
  68. if (indexPath.item < [self.imagePaths count] - 1) {
  69. [self loadImageAtIndex:indexPath.item + 1]; }
  70. if (indexPath.item > 0) {
  71. [self loadImageAtIndex:indexPath.item - 1]; }
  72. return cell;
  73. }
  74. @end

果然效果更好了!当滚动的时候虽然还有一些图片进入的延迟,但是已经非常罕见了。缓存意味着我们做了更少的加载。这里提前加载逻辑非常粗暴,其实可以把滑动速度和方向也考虑进来,但这已经比之前没做缓存的版本好很多了。

14.3 文件格式

文件格式

图片加载性能取决于加载大图的时间和解压小图时间的权衡。很多苹果的文档都说PNG是iOS所有图片加载的最好格式。但这是极度误导的过时信息了。

PNG图片使用的无损压缩算法可以比使用JPEG的图片做到更快地解压,但是由于闪存访问的原因,这些加载的时间并没有什么区别。

清单14.6展示了标准的应用程序加载不同尺寸图片所需要时间的一些代码。为了保证实验的准确性,我们会测量每张图片的加载和绘制时间来确保考虑到解压性能的因素。另外每隔一秒重复加载和绘制图片,这样就可以取到平均时间,使得结果更加准确。

清单14.6

  1. #import "ViewController.h"
  2. static NSString *const ImageFolder = @"Coast Photos";
  3. @interface ViewController ()
  4. @property (nonatomic, copy) NSArray *items;
  5. @property (nonatomic, weak) IBOutlet UITableView *tableView;
  6. @end
  7. @implementation ViewController
  8. - (void)viewDidLoad
  9. {
  10. [super viewDidLoad];
  11. //set up image names
  12. self.items = @[@"2048x1536", @"1024x768", @"512x384", @"256x192", @"128x96", @"64x48", @"32x24"];
  13. }
  14. - (CFTimeInterval)loadImageForOneSec:(NSString *)path
  15. {
  16. //create drawing context to use for decompression
  17. UIGraphicsBeginImageContext(CGSizeMake(1, 1));
  18. //start timing
  19. NSInteger imagesLoaded = 0;
  20. CFTimeInterval endTime = 0;
  21. CFTimeInterval startTime = CFAbsoluteTimeGetCurrent();
  22. while (endTime - startTime < 1) {
  23. //load image
  24. UIImage *image = [UIImage imageWithContentsOfFile:path];
  25. //decompress image by drawing it
  26. [image drawAtPoint:CGPointZero];
  27. //update totals
  28. imagesLoaded ++;
  29. endTime = CFAbsoluteTimeGetCurrent();
  30. }
  31. //close context
  32. UIGraphicsEndImageContext();
  33. //calculate time per image
  34. return (endTime - startTime) / imagesLoaded;
  35. }
  36. - (void)loadImageAtIndex:(NSUInteger)index
  37. {
  38. //load on background thread so as not to
  39. //prevent the UI from updating between runs dispatch_async(
  40. dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
  41. //setup
  42. NSString *fileName = self.items[index];
  43. NSString *pngPath = [[NSBundle mainBundle] pathForResource:filename
  44. ofType:@"png"
  45. inDirectory:ImageFolder];
  46. NSString *jpgPath = [[NSBundle mainBundle] pathForResource:filename
  47. ofType:@"jpg"
  48. inDirectory:ImageFolder];
  49. //load
  50. NSInteger pngTime = [self loadImageForOneSec:pngPath] * 1000;
  51. NSInteger jpgTime = [self loadImageForOneSec:jpgPath] * 1000;
  52. //updated UI on main thread
  53. dispatch_async(dispatch_get_main_queue(), ^{
  54. //find table cell and update
  55. NSIndexPath *indexPath = [NSIndexPath indexPathForRow:index inSection:0];
  56. UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
  57. cell.detailTextLabel.text = [NSString stringWithFormat:@"PNG: %03ims JPG: %03ims", pngTime, jpgTime];
  58. });
  59. });
  60. }
  61. - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
  62. {
  63. return [self.items count];
  64. }
  65. - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
  66. {
  67. //dequeue cell
  68. UITableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"Cell"];
  69. if (!cell) {
  70. cell = [[UITableViewCell alloc] initWithStyle: UITableViewCellStyleValue1 reuseIdentifier:@"Cell"];
  71. }
  72. //set up cell
  73. NSString *imageName = self.items[indexPath.row];
  74. cell.textLabel.text = imageName;
  75. cell.detailTextLabel.text = @"Loading...";
  76. //load image
  77. [self loadImageAtIndex:indexPath.row];
  78. return cell;
  79. }
  80. @end

PNG和JPEG压缩算法作用于两种不同的图片类型:JPEG对于噪点大的图片效果很好;但是PNG更适合于扁平颜色,锋利的线条或者一些渐变色的图片。为了让测评的基准更加公平,我们用一些不同的图片来做实验:一张照片和一张彩虹色的渐变。JPEG版本的图片都用默认的Photoshop60%“高质量”设置编码。结果见图片14.5。

14.4 总结

总结

另外,如果你想一起进阶,不妨添加一下交流群1012951431,选择加入一起交流,一起学习。期待你的加入!
iOS核心动画高级技巧 - 7 - 图2

在这章中,我们研究了和图片加载解压相关的性能问题,并延展了一系列解决方案。

在第15章“图层性能”中,我们将讨论和图层渲染和组合相关的性能问题。