15.5 总结

最后更新于:2022-04-01 10:11:18

# 总结     本章学习了使用Core Animation图层可能遇到的性能瓶颈,并讨论了如何避免或减小压力。你学习了如何管理包含上千虚拟图层的场景(事实上只创建了几百个)。同时也学习了一些有用的技巧,选择性地选取光栅化或者绘制图层内容在合适的时候重新分配给CPU和GPU。这些就是我们要讲的关于Core Animation的全部了(至少可以等到苹果发明什么新的玩意儿)。
';

15.4 减少图层数量

最后更新于:2022-04-01 10:11:16

# 减少图层数量     初始化图层,处理图层,打包通过IPC发给渲染引擎,转化成OpenGL几何图形,这些是一个图层的大致资源开销。事实上,一次性能够在屏幕上显示的最大图层数量也是有限的。     确切的限制数量取决于iOS设备,图层类型,图层内容和属性等。但是总得说来可以容纳上百或上千个,下面我们将演示即使图层本身并没有做什么也会遇到的性能问题。 ## 裁切     在对图层做任何优化之前,你需要确定你不是在创建一些不可见的图层,图层在以下几种情况下回事不可见的: * 图层在屏幕边界之外,或是在父图层边界之外。 * 完全在一个不透明图层之后。 * 完全透明     Core Animation非常擅长处理对视觉效果无意义的图层。但是经常性地,你自己的代码会比Core Animation更早地想知道一个图层是否是有用的。理想状况下,在图层对象在创建之前就想知道,以避免创建和配置不必要图层的额外工作。     举个例子。清单15.3 的代码展示了一个简单的滚动3D图层矩阵。这看上去很酷,尤其是图层在移动的时候(见图15.1),但是绘制他们并不是很麻烦,因为这些图层就是一些简单的矩形色块。 清单15.3 绘制3D图层矩阵 ~~~ #import "ViewController.h" #import #define WIDTH 10 #define HEIGHT 10 #define DEPTH 10 #define SIZE 100 #define SPACING 150 #define CAMERA_DISTANCE 500 @interface ViewController ()  @property (nonatomic, strong) IBOutlet UIScrollView *scrollView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //set content size self.scrollView.contentSize = CGSizeMake((WIDTH - 1)*SPACING, (HEIGHT - 1)*SPACING); //set up perspective transform CATransform3D transform = CATransform3DIdentity; transform.m34 = -1.0 / CAMERA_DISTANCE; self.scrollView.layer.sublayerTransform = transform; //create layers for (int z = DEPTH - 1; z >= 0; z--) { for (int y = 0; y < HEIGHT; y++) { for (int x = 0; x < WIDTH; x++) { //create layer CALayer *layer = [CALayer layer]; layer.frame = CGRectMake(0, 0, SIZE, SIZE); layer.position = CGPointMake(x*SPACING, y*SPACING); layer.zPosition = -z*SPACING; //set background color layer.backgroundColor = [UIColor colorWithWhite:1-z*(1.0/DEPTH) alpha:1].CGColor; //attach to scroll view [self.scrollView.layer addSublayer:layer]; } } }  //log NSLog(@"displayed: %i", DEPTH*HEIGHT*WIDTH); } @end ~~~ ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc22a3282d.png) 图15.1 滚动的3D图层矩阵     `WIDTH`,`HEIGHT`和`DEPTH`常量控制着图层的生成。在这个情况下,我们得到的是10*10*10个图层,总量为1000个,不过一次性显示在屏幕上的大约就几百个。     如果把`WIDTH`和`HEIGHT`常量增加到100,我们的程序就会慢得像龟爬了。这样我们有了100000个图层,性能下降一点儿也不奇怪。     但是显示在屏幕上的图层数量并没有增加,那么根本没有额外的东西需要绘制。程序慢下来的原因其实是因为在管理这些图层上花掉了不少功夫。他们大部分对渲染的最终结果没有贡献,但是在丢弃这么图层之前,Core Animation要强制计算每个图层的位置,就这样,我们的帧率就慢了下来。     我们的图层是被安排在一个均匀的栅格中,我们可以计算出哪些图层会被最终显示在屏幕上,根本不需要对每个图层的位置进行计算。这个计算并不简单,因为我们还要考虑到透视的问题。如果我们直接这样做了,Core Animation就不用费神了。     既然这样,让我们来重构我们的代码吧。改造后,随着视图的滚动动态地实例化图层而不是事先都分配好。这样,在创造他们之前,我们就可以计算出是否需要他。接着,我们增加一些代码去计算可视区域这样就可以排除区域之外的图层了。清单15.4是改造后的结果。 清单15.4 排除可视区域之外的图层 ~~~ #import "ViewController.h" #import #define WIDTH 100 #define HEIGHT 100 #define DEPTH 10 #define SIZE 100 #define SPACING 150 #define CAMERA_DISTANCE 500 #define PERSPECTIVE(z) (float)CAMERA_DISTANCE/(z + CAMERA_DISTANCE) @interface ViewController () @property (nonatomic, weak) IBOutlet UIScrollView *scrollView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //set content size self.scrollView.contentSize = CGSizeMake((WIDTH - 1)*SPACING, (HEIGHT - 1)*SPACING); //set up perspective transform CATransform3D transform = CATransform3DIdentity; transform.m34 = -1.0 / CAMERA_DISTANCE; self.scrollView.layer.sublayerTransform = transform; }  - (void)viewDidLayoutSubviews { [self updateLayers]; } - (void)scrollViewDidScroll:(UIScrollView *)scrollView { [self updateLayers]; } - (void)updateLayers { //calculate clipping bounds CGRect bounds = self.scrollView.bounds; bounds.origin = self.scrollView.contentOffset; bounds = CGRectInset(bounds, -SIZE/2, -SIZE/2); //create layers NSMutableArray *visibleLayers = [NSMutableArray array]; for (int z = DEPTH - 1; z >= 0; z--) { //increase bounds size to compensate for perspective CGRect adjusted = bounds; adjusted.size.width /= PERSPECTIVE(z*SPACING); adjusted.size.height /= PERSPECTIVE(z*SPACING); adjusted.origin.x -= (adjusted.size.width - bounds.size.width) / 2; adjusted.origin.y -= (adjusted.size.height - bounds.size.height) / 2; for (int y = 0; y < HEIGHT; y++) { //check if vertically outside visible rect if (y*SPACING < adjusted.origin.y || y*SPACING >= adjusted.origin.y + adjusted.size.height) { continue; } for (int x = 0; x < WIDTH; x++) { //check if horizontally outside visible rect if (x*SPACING < adjusted.origin.x ||x*SPACING >= adjusted.origin.x + adjusted.size.width) { continue; }  //create layer CALayer *layer = [CALayer layer]; layer.frame = CGRectMake(0, 0, SIZE, SIZE); layer.position = CGPointMake(x*SPACING, y*SPACING); layer.zPosition = -z*SPACING; //set background color layer.backgroundColor = [UIColor colorWithWhite:1-z*(1.0/DEPTH) alpha:1].CGColor; //attach to scroll view [visibleLayers addObject:layer]; } } } //update layers self.scrollView.layer.sublayers = visibleLayers; //log NSLog(@"displayed: %i/%i", [visibleLayers count], DEPTH*HEIGHT*WIDTH); } @end ~~~     这个计算机制并不具有普适性,但是原则上是一样。(当你用一个`UITableView`或者`UICollectionView`时,系统做了类似的事情)。这样做的结果?我们的程序可以处理成百上千个『虚拟』图层而且完全没有性能问题!因为它不需要一次性实例化几百个图层。 ## 对象回收     处理巨大数量的相似视图或图层时还有一个技巧就是回收他们。对象回收在iOS颇为常见;`UITableView`和`UICollectionView`都有用到,`MKMapView`中的动画pin码也有用到,还有其他很多例子。     对象回收的基础原则就是你需要创建一个相似对象池。当一个对象的指定实例(本例子中指的是图层)结束了使命,你把它添加到对象池中。每次当你需要一个实例时,你就从池中取出一个。当且仅当池中为空时再创建一个新的。     这样做的好处在于避免了不断创建和释放对象(相当消耗资源,因为涉及到内存的分配和销毁)而且也不必给相似实例重复赋值。     好了,让我们再次更新代码吧(见清单15.5) 清单15.5 通过回收减少不必要的分配 ~~~ @interface ViewController () @property (nonatomic, weak) IBOutlet UIScrollView *scrollView; @property (nonatomic, strong) NSMutableSet *recyclePool; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create recycle pool self.recyclePool = [NSMutableSet set]; //set content size self.scrollView.contentSize = CGSizeMake((WIDTH - 1)*SPACING, (HEIGHT - 1)*SPACING); //set up perspective transform CATransform3D transform = CATransform3DIdentity; transform.m34 = -1.0 / CAMERA_DISTANCE; self.scrollView.layer.sublayerTransform = transform; } - (void)viewDidLayoutSubviews { [self updateLayers]; } - (void)scrollViewDidScroll:(UIScrollView *)scrollView { [self updateLayers]; } - (void)updateLayers {  //calculate clipping bounds CGRect bounds = self.scrollView.bounds; bounds.origin = self.scrollView.contentOffset; bounds = CGRectInset(bounds, -SIZE/2, -SIZE/2); //add existing layers to pool [self.recyclePool addObjectsFromArray:self.scrollView.layer.sublayers]; //disable animation [CATransaction begin]; [CATransaction setDisableActions:YES]; //create layers NSInteger recycled = 0; NSMutableArray *visibleLayers = [NSMutableArray array]; for (int z = DEPTH - 1; z >= 0; z--) { //increase bounds size to compensate for perspective CGRect adjusted = bounds; adjusted.size.width /= PERSPECTIVE(z*SPACING); adjusted.size.height /= PERSPECTIVE(z*SPACING); adjusted.origin.x -= (adjusted.size.width - bounds.size.width) / 2; adjusted.origin.y -= (adjusted.size.height - bounds.size.height) / 2; for (int y = 0; y < HEIGHT; y++) { //check if vertically outside visible rect if (y*SPACING < adjusted.origin.y || y*SPACING >= adjusted.origin.y + adjusted.size.height) { continue; } for (int x = 0; x < WIDTH; x++) { //check if horizontally outside visible rect if (x*SPACING < adjusted.origin.x || x*SPACING >= adjusted.origin.x + adjusted.size.width) { continue; } //recycle layer if available CALayer *layer = [self.recyclePool anyObject]; if (layer) {  recycled ++; [self.recyclePool removeObject:layer]; } else { layer = [CALayer layer]; layer.frame = CGRectMake(0, 0, SIZE, SIZE); } //set position layer.position = CGPointMake(x*SPACING, y*SPACING); layer.zPosition = -z*SPACING; //set background color layer.backgroundColor = [UIColor colorWithWhite:1-z*(1.0/DEPTH) alpha:1].CGColor; //attach to scroll view [visibleLayers addObject:layer]; } } } [CATransaction commit]; //update layers self.scrollView.layer.sublayers = visibleLayers; //log NSLog(@"displayed: %i/%i recycled: %i", [visibleLayers count], DEPTH*HEIGHT*WIDTH, recycled); } @end ~~~     本例中,我们只有图层对象这一种类型,但是UIKit有时候用一个标识符字符串来区分存储在不同对象池中的不同的可回收对象类型。     你可能注意到当设置图层属性时我们用了一个`CATransaction`来抑制动画效果。在之前并不需要这样做,因为在显示之前我们给所有图层设置一次属性。但是既然图层正在被回收,禁止隐式动画就有必要了,不然当属性值改变时,图层的隐式动画就会被触发。 ## Core Graphics绘制     当排除掉对屏幕显示没有任何贡献的图层或者视图之后,长远看来,你可能仍然需要减少图层的数量。例如,如果你正在使用多个`UILabel`或者`UIImageView`实例去显示固定内容,你可以把他们全部替换成一个单独的视图,然后用`-drawRect:`方法绘制出那些复杂的视图层级。     这个提议看上去并不合理因为大家都知道软件绘制行为要比GPU合成要慢而且还需要更多的内存空间,但是在因为图层数量而使得性能受限的情况下,软件绘制很可能提高性能呢,因为它避免了图层分配和操作问题。     你可以自己实验一下这个情况,它包含了性能和栅格化的权衡,但是意味着你可以从图层树上去掉子图层(用`shouldRasterize`,与完全遮挡图层相反)。 ## -renderInContext: 方法     用Core Graphics去绘制一个静态布局有时候会比用层级的`UIView`实例来得快,但是使用`UIView`实例要简单得多而且比用手写代码写出相同效果要可靠得多,更边说Interface Builder来得直接明了。为了性能而舍弃这些便利实在是不应该。     幸好,你不必这样,如果大量的视图或者图层真的关联到了屏幕上将会是一个大问题。没有与图层树相关联的图层不会被送到渲染引擎,也没有性能问题(在他们被创建和配置之后)。     使用`CALayer`的`-renderInContext:`方法,你可以将图层及其子图层快照进一个Core Graphics上下文然后得到一个图片,它可以直接显示在`UIImageView`中,或者作为另一个图层的`contents`。不同于`shouldRasterize` —— 要求图层与图层树相关联 —— ,这个方法没有持续的性能消耗。     当图层内容改变时,刷新这张图片的机会取决于你(不同于`shouldRasterize`,它自动地处理缓存和缓存验证),但是一旦图片被生成,相比于让Core Animation处理一个复杂的图层树,你节省了相当客观的性能。
';

15.3 混合和过度绘制

最后更新于:2022-04-01 10:11:14

#混合和过度绘制     在第12章有提到,GPU每一帧可以绘制的像素有一个最大限制(就是所谓的fill rate),这个情况下可以轻易地绘制整个屏幕的所有像素。但是如果由于重叠图层的关系需要不停地重绘同一区域的话,掉帧就可能发生了。     GPU会放弃绘制那些完全被其他图层遮挡的像素,但是要计算出一个图层是否被遮挡也是相当复杂并且会消耗处理器资源。同样,合并不同图层的透明重叠像素(即混合)消耗的资源也是相当客观的。所以为了加速处理进程,不到必须时刻不要使用透明图层。任何情况下,你应该这样做: * 给视图的`backgroundColor`属性设置一个固定的,不透明的颜色 * 设置`opaque`属性为YES     这样做减少了混合行为(因为编译器知道在图层之后的东西都不会对最终的像素颜色产生影响)并且计算得到了加速,避免了过度绘制行为因为Core Animation可以舍弃所有被完全遮盖住的图层,而不用每个像素都去计算一遍。     如果用到了图像,尽量避免透明除非非常必要。如果图像要显示在一个固定的背景颜色或是固定的背景图之前,你没必要相对前景移动,你只需要预填充背景图片就可以避免运行时混色了。     如果是文本的话,一个白色背景的`UILabel`(或者其他颜色)会比透明背景要更高效。     最后,明智地使用`shouldRasterize`属性,可以将一个固定的图层体系折叠成单张图片,这样就不需要每一帧重新合成了,也就不会有因为子图层之间的混合和过度绘制的性能问题了。
';

15.2 离屏渲染

最后更新于:2022-04-01 10:11:11

# 离屏渲染     当图层属性的混合体被指定为在未预合成之前不能直接在屏幕中绘制时,屏幕外渲染就被唤起了。屏幕外渲染并不意味着软件绘制,但是它意味着图层必须在被显示之前在一个屏幕外上下文中被渲染(不论CPU还是GPU)。图层的以下属性将会触发屏幕外绘制: * 圆角(当和`maskToBounds`一起使用时) * 图层蒙板 * 阴影     屏幕外渲染和我们启用光栅化时相似,除了它并没有像光栅化图层那么消耗大,子图层并没有被影响到,而且结果也没有被缓存,所以不会有长期的内存占用。但是,如果太多图层在屏幕外渲染依然会影响到性能。     有时候我们可以把那些需要屏幕外绘制的图层开启光栅化以作为一个优化方式,前提是这些图层并不会被频繁地重绘。     对于那些需要动画而且要在屏幕外渲染的图层来说,你可以用`CAShapeLayer`,`contentsCenter`或者`shadowPath`来获得同样的表现而且较少地影响到性能。 ## CAShapeLayer     `cornerRadius`和`maskToBounds`独立作用的时候都不会有太大的性能问题,但是当他俩结合在一起,就触发了屏幕外渲染。有时候你想显示圆角并沿着图层裁切子图层的时候,你可能会发现你并不需要沿着圆角裁切,这个情况下用`CAShapeLayer`就可以避免这个问题了。     你想要的只是圆角且沿着矩形边界裁切,同时还不希望引起性能问题。其实你可以用现成的`UIBezierPath`的构造器`+bezierPathWithRoundedRect:cornerRadius:`(见清单15.1).这样做并不会比直接用`cornerRadius`更快,但是它避免了性能问题。 清单15.1 用`CAShapeLayer`画一个圆角矩形 ~~~ #import "ViewController.h" #import @interface ViewController () @property (nonatomic, weak) IBOutlet UIView *layerView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create shape layer CAShapeLayer *blueLayer = [CAShapeLayer layer]; blueLayer.frame = CGRectMake(50, 50, 100, 100); blueLayer.fillColor = [UIColor blueColor].CGColor; blueLayer.path = [UIBezierPath bezierPathWithRoundedRect: CGRectMake(0, 0, 100, 100) cornerRadius:20].CGPath;  //add it to our view [self.layerView.layer addSublayer:blueLayer]; } @end ~~~ ## 可伸缩图片     另一个创建圆角矩形的方法就是用一个圆形内容图片并结合第二章『寄宿图』提到的`contensCenter`属性去创建一个可伸缩图片(见清单15.2).理论上来说,这个应该比用`CAShapeLayer`要快,因为一个可拉伸图片只需要18个三角形(一个图片是由一个3*3网格渲染而成),然而,许多都需要渲染成一个顺滑的曲线。在实际应用上,二者并没有太大的区别。 清单15.2 用可伸缩图片绘制圆角矩形 ~~~ @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create layer CALayer *blueLayer = [CALayer layer]; blueLayer.frame = CGRectMake(50, 50, 100, 100); blueLayer.contentsCenter = CGRectMake(0.5, 0.5, 0.0, 0.0); blueLayer.contentsScale = [UIScreen mainScreen].scale; blueLayer.contents = (__bridge id)[UIImage imageNamed:@"Circle.png"].CGImage; //add it to our view [self.layerView.layer addSublayer:blueLayer]; } @end ~~~     使用可伸缩图片的优势在于它可以绘制成任意边框效果而不需要额外的性能消耗。举个例子,可伸缩图片甚至还可以显示出矩形阴影的效果。 ## shadowPath     在第2章我们有提到`shadowPath`属性。如果图层是一个简单几何图形如矩形或者圆角矩形(假设不包含任何透明部分或者子图层),创建出一个对应形状的阴影路径就比较容易,而且Core Animation绘制这个阴影也相当简单,避免了屏幕外的图层部分的预排版需求。这对性能来说很有帮助。     如果你的图层是一个更复杂的图形,生成正确的阴影路径可能就比较难了,这样子的话你可以考虑用绘图软件预先生成一个阴影背景图。
';

15.1 隐式绘制

最后更新于:2022-04-01 10:11:09

# 隐式绘制     寄宿图可以通过Core Graphics直接绘制,也可以直接载入一个图片文件并赋值给`contents`属性,或事先绘制一个屏幕之外的`CGContext`上下文。在之前的两章中我们讨论了这些场景下的优化。但是除了常见的显式创建寄宿图,你也可以通过以下三种方式创建隐式的:1,使用特性的图层属性。2,特定的视图。3,特定的图层子类。     了解这个情况为什么发生何时发生是很重要的,它能够让你避免引入不必要的软件绘制行为。 ## 文本     `CATextLayer`和`UILabel`都是直接将文本绘制在图层的寄宿图中。事实上这两种方式用了完全不同的渲染方式:在iOS 6及之前,`UILabel`用WebKit的HTML渲染引擎来绘制文本,而`CATextLayer`用的是Core Text.后者渲染更迅速,所以在所有需要绘制大量文本的情形下都优先使用它吧。但是这两种方法都用了软件的方式绘制,因此他们实际上要比硬件加速合成方式要慢。     不论如何,尽可能地避免改变那些包含文本的视图的frame,因为这样做的话文本就需要重绘。例如,如果你想在图层的角落里显示一段静态的文本,但是这个图层经常改动,你就应该把文本放在一个子图层中。 ##光栅化     在第四章『视觉效果』中我们提到了`CALayer`的`shouldRasterize`属性,它可以解决重叠透明图层的混合失灵问题。同样在第12章『速度的曲调』中,它也是作为绘制复杂图层树结构的优化方法。     启用`shouldRasterize`属性会将图层绘制到一个屏幕之外的图像。然后这个图像将会被缓存起来并绘制到实际图层的`contents`和子图层。如果有很多的子图层或者有复杂的效果应用,这样做就会比重绘所有事务的所有帧划得来得多。但是光栅化原始图像需要时间,而且还会消耗额外的内存。     当我们使用得当时,光栅化可以提供很大的性能优势(如你在第12章所见),但是一定要避免作用在内容不断变动的图层上,否则它缓存方面的好处就会消失,而且会让性能变的更糟。     为了检测你是否正确地使用了光栅化方式,用Instrument查看一下Color Hits Green和Misses Red项目,是否已光栅化图像被频繁地刷新(这样就说明图层并不是光栅化的好选择,或则你无意间触发了不必要的改变导致了重绘行为)。
';

15. 图层性能

最后更新于:2022-04-01 10:11:07

# 图层性能 > 要更快性能,也要做对正确的事情。 ——Stephen R. Covey     在第14章『图像IO』讨论如何高效地载入和显示图像,通过视图来避免可能引起动画帧率下降的性能问题。在最后一章,我们将着重图层树本身,以发掘最好的性能。
';

14.4 总结

最后更新于:2022-04-01 10:11:04

## 总结     在这章中,我们研究了和图片加载解压相关的性能问题,并延展了一系列解决方案。     在第15章“图层性能”中,我们将讨论和图层渲染和组合相关的性能问题。
';

14.3 文件格式

最后更新于:2022-04-01 10:11:02

# 文件格式     图片加载性能取决于加载大图的时间和解压小图时间的权衡。很多苹果的文档都说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 { [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。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc227728de.png) 图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](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的时候强制拉伸或者填充空白空间。 * 质量并不是很好,尤其是透明图片。通常看起来更像严重压缩的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免费获得。](http://www.imgtec.com/powervr/insider/sdkdownloads%E5%85%8D%E8%B4%B9%E8%8E%B7%E5%BE%97%E3%80%82)     安装了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 { [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的游戏),可以参考一下`GLView`的库([https://github.com/nicklockwood/GLView](https://github.com/nicklockwood/GLView)),它提供了一个简单的`GLImageView`类,重新实现了`UIImageView`的各种功能,但同时提供了PVRTC图片,而不需要你写任何OpenGL代码。
';

14.2 缓存

最后更新于:2022-04-01 10:11:00

# 缓存     如果有很多张图片要显示,最好不要提前把所有都加载进来,而是应该当移出屏幕之后立刻销毁。通过选择性的缓存,你就可以避免来回滚动时图片重复性的加载了。     缓存其实很简单:就是存储昂贵计算后的结果(或者是从闪存或者网络加载的文件)在内存中,以便后续使用,这样访问起来很快。问题在于缓存本质上是一个权衡过程 - 为了提升性能而消耗了内存,但是由于内存是一个非常宝贵的资源,所以不能把所有东西都做缓存。     何时将何物做缓存(做多久)并不总是很明显。幸运的是,大多情况下,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:[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 ~~~     果然效果更好了!当滚动的时候虽然还有一些图片进入的延迟,但是已经非常罕见了。缓存意味着我们做了更少的加载。这里提前加载逻辑非常粗暴,其实可以把滑动速度和方向也考虑进来,但这已经比之前没做缓存的版本好很多了。
';

14.1 加载和潜伏

最后更新于:2022-04-01 10:10:57

# 加载和潜伏     绘图实际消耗的时间通常并不是影响性能的因素。图片消耗很大一部分内存,而且不太可能把需要显示的图片都保留在内存中,所以需要在应用运行的时候周期性地加载和卸载图片。     图片文件加载的速度被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() @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 ~~~ ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc22350631.png) 图14.1 运行中的图片传送器     传送器中的图片尺寸为800x600像素的PNG,对iPhone5来说,1/60秒要加载大概700KB左右的图片。当传送器滚动的时候,图片也在实时加载,于是(预期中的)卡动就发生了。时间分析工具(图14.2)显示了很多时间都消耗在了`UIImage`的`+imageWithContentsOfFile:`方法中了。很明显,图片加载造成了瓶颈。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc2239dc98.png) 图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; } }); }); return cell; } ~~~     当运行更新后的版本,性能比之前不用线程的版本好多了,但仍然并不完美(图14.3)。     我们可以看到`+imageWithContentsOfFile:`方法并不在CPU时间轨迹的最顶部,所以我们的确修复了延迟加载的问题。问题在于我们假设传送器的性能瓶颈在于图片文件的加载,但实际上并不是这样。加载图片数据到内存中只是问题的第一部分。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc2241a6c2.png) 图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 @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]; 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)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc2249a22e.png) 图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; ~~~     你可以使用这几个方法来检测传送器是否停止滚动,然后加载高分辨率的图片。只要高分辨率图片和低分辨率图片尺寸颜色保持一致,你会很难察觉到替换的过程(确保在同一台机器使用相同的图像程序或者脚本生成这些图片)。
';

14. 图像IO

最后更新于:2022-04-01 10:10:55

# 图像IO > *潜伏期值得思考* - 凯文 帕萨特     在第13章“高效绘图”中,我们研究了和Core Graphics绘图相关的性能问题,以及如何修复。和绘图性能相关紧密相关的是图像性能。在这一章中,我们将研究如何优化从闪存驱动器或者网络中加载和显示图片。
';

13.5 总结

最后更新于:2022-04-01 10:10:53

# 总结 本章我们主要围绕用Core Graphics软件绘制讨论了一些性能挑战,然后探索了一些改进方法:比如提高绘制性能或者减少需要绘制的数量。 第14章,『图像IO』,我们将讨论图片的载入性能。
';

13.4 异步绘制

最后更新于:2022-04-01 10:10:51

# 异步绘制     UIKit的单线程天性意味着寄宿图通畅要在主线程上更新,这意味着绘制会打断用户交互,甚至让整个app看起来处于无响应状态。我们对此无能为力,但是如果能避免用户等待绘制完成就好多了。     针对这个问题,有一些方法可以用到:一些情况下,我们可以推测性地提前在另外一个线程上绘制内容,然后将由此绘出的图片直接设置为图层的内容。这实现起来可能不是很方便,但是在特定情况下是可行的。Core Animation提供了一些选择:`CATiledLayer`和`drawsAsynchronously`属性。 ### CATiledLayer     我们在第六章简单探索了一下`CATiledLayer`。除了将图层再次分割成独立更新的小块(类似于脏矩形自动更新的概念),`CATiledLayer`还有一个有趣的特性:在多个线程中为每个小块同时调用`-drawLayer:inContext:`方法。这就避免了阻塞用户交互而且能够利用多核心新片来更快地绘制。只有一个小块的`CATiledLayer`是实现异步更新图片视图的简单方法。 ### drawsAsynchronously     iOS 6中,苹果为`CALayer`引入了这个令人好奇的属性,`drawsAsynchronously`属性对传入`-drawLayer:inContext:`的CGContext进行改动,允许CGContext延缓绘制命令的执行以至于不阻塞用户交互。     它与`CATiledLayer`使用的异步绘制并不相同。它自己的`-drawLayer:inContext:`方法只会在主线程调用,但是CGContext并不等待每个绘制命令的结束。相反地,它会将命令加入队列,当方法返回时,在后台线程逐个执行真正的绘制。     根据苹果的说法。这个特性在需要频繁重绘的视图上效果最好(比如我们的绘图应用,或者诸如`UITableViewCell`之类的),对那些只绘制一次或很少重绘的图层内容来说没什么太大的帮助。
';

13.3 脏矩形

最后更新于:2022-04-01 10:10:48

# 脏矩形     有时候用`CAShapeLayer`或者其他矢量图形图层替代Core Graphics并不是那么切实可行。比如我们的绘图应用:我们用线条完美地完成了矢量绘制。但是设想一下如果我们能进一步提高应用的性能,让它就像一个黑板一样工作,然后用『粉笔』来绘制线条。模拟粉笔最简单的方法就是用一个『线刷』图片然后将它粘贴到用户手指碰触的地方,但是这个方法用`CAShapeLayer`没办法实现。     我们可以给每个『线刷』创建一个独立的图层,但是实现起来有很大的问题。屏幕上允许同时出现图层上线数量大约是几百,那样我们很快就会超出的。这种情况下我们没什么办法,就用Core Graphics吧(除非你想用OpenGL做一些更复杂的事情)。     我们的『黑板』应用的最初实现见清单13.3,我们更改了之前版本的`DrawingView`,用一个画刷位置的数组代替`UIBezierPath`。图13.2是运行结果 清单13.3 简单的类似黑板的应用 ~~~ #import "DrawingView.h" #import #define BRUSH_SIZE 32 @interface DrawingView () @property (nonatomic, strong) NSMutableArray *strokes; @end @implementation DrawingView - (void)awakeFromNib { //create array self.strokes = [NSMutableArray array]; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { //get the starting point CGPoint point = [[touches anyObject] locationInView:self]; //add brush stroke [self addBrushStrokeAtPoint:point]; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { //get the touch point CGPoint point = [[touches anyObject] locationInView:self]; //add brush stroke [self addBrushStrokeAtPoint:point]; } - (void)addBrushStrokeAtPoint:(CGPoint)point { //add brush stroke to array [self.strokes addObject:[NSValue valueWithCGPoint:point]]; //needs redraw [self setNeedsDisplay]; } - (void)drawRect:(CGRect)rect { //redraw strokes for (NSValue *value in self.strokes) { //get point CGPoint point = [value CGPointValue]; //get brush rect CGRect brushRect = CGRectMake(point.x - BRUSH_SIZE/2, point.y - BRUSH_SIZE/2, BRUSH_SIZE, BRUSH_SIZE); //draw brush stroke  [[UIImage imageNamed:@"Chalk.png"] drawInRect:brushRect]; } } @end ~~~ ![图13.2](https://zsisme.gitbooks.io/ios-/content/chapter15/13.2.png) 图13.2 用程序绘制一个简单的『素描』     这个实现在模拟器上表现还不错,但是在真实设备上就没那么好了。问题在于每次手指移动的时候我们就会重绘之前的线刷,即使场景的大部分并没有改变。我们绘制地越多,就会越慢。随着时间的增加每次重绘需要更多的时间,帧数也会下降(见图13.3),如何提高性能呢? ![图13.3](https://zsisme.gitbooks.io/ios-/content/chapter15/13.3.png) 图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:`来减少不必要的绘制 ~~~ - (void)addBrushStrokeAtPoint:(CGPoint)point { //add brush stroke to array [self.strokes addObject:[NSValue valueWithCGPoint:point]]; //set dirty rect [self setNeedsDisplayInRect:[self brushRectForPoint:point]]; } - (CGRect)brushRectForPoint:(CGPoint)point { return CGRectMake(point.x - BRUSH_SIZE/2, point.y - BRUSH_SIZE/2, BRUSH_SIZE, BRUSH_SIZE); } - (void)drawRect:(CGRect)rect { //redraw strokes for (NSValue *value in self.strokes) { //get point CGPoint point = [value CGPointValue]; //get brush rect CGRect brushRect = [self brushRectForPoint:point];  //only draw brush stroke if it intersects dirty rect if (CGRectIntersectsRect(rect, brushRect)) { //draw brush stroke [[UIImage imageNamed:@"Chalk.png"] drawInRect:brushRect]; } } } ~~~ ![图13.4](https://zsisme.gitbooks.io/ios-/content/chapter15/13.4.png) 图13.4 更好的帧率和顺滑线条
';

13.2 矢量图形

最后更新于:2022-04-01 10:10:46

# 矢量图形     我们用Core Graphics来绘图的一个通常原因就是只是用图片或是图层效果不能轻易地绘制出矢量图形。矢量绘图包含一下这些: * 任意多边形(不仅仅是一个矩形) * 斜线或曲线 * 文本 * 渐变     举个例子,清单13.1 展示了一个基本的画线应用。这个应用将用户的触摸手势转换成一个`UIBezierPath`上的点,然后绘制成视图。我们在一个`UIView`子类`DrawingView`中实现了所有的绘制逻辑,这个情况下我们没有用上view controller。但是如果你喜欢你可以在view controller中实现触摸事件处理。图13.1是代码运行结果。 清单13.1 用Core Graphics实现一个简单的绘图应用 ~~~ #import "DrawingView.h" @interface DrawingView () @property (nonatomic, strong) UIBezierPath *path; @end @implementation DrawingView - (void)awakeFromNib { //create a mutable path self.path = [[UIBezierPath alloc] init]; self.path.lineJoinStyle = kCGLineJoinRound; self.path.lineCapStyle = kCGLineCapRound;  self.path.lineWidth = 5; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { //get the starting point CGPoint point = [[touches anyObject] locationInView:self]; //move the path drawing cursor to the starting point [self.path moveToPoint:point]; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { //get the current point CGPoint point = [[touches anyObject] locationInView:self]; //add a new line segment to our path [self.path addLineToPoint:point]; //redraw the view [self setNeedsDisplay]; } - (void)drawRect:(CGRect)rect { //draw path [[UIColor clearColor] setFill]; [[UIColor redColor] setStroke]; [self.path stroke]; } @end ~~~ ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc21ff2f8e.png) 图13.1 用Core Graphics做一个简单的『素描』     这样实现的问题在于,我们画得越多,程序就会越慢。因为每次移动手指的时候都会重绘整个贝塞尔路径(`UIBezierPath`),随着路径越来越复杂,每次重绘的工作就会增加,直接导致了帧数的下降。看来我们需要一个更好的方法了。     Core Animation为这些图形类型的绘制提供了专门的类,并给他们提供硬件支持(第六章『专有图层』有详细提到)。`CAShapeLayer`可以绘制多边形,直线和曲线。`CATextLayer`可以绘制文本。`CAGradientLayer`用来绘制渐变。这些总体上都比Core Graphics更快,同时他们也避免了创造一个寄宿图。     如果稍微将之前的代码变动一下,用`CAShapeLayer`替代Core Graphics,性能就会得到提高(见清单13.2).虽然随着路径复杂性的增加,绘制性能依然会下降,但是只有当非常非常浮躁的绘制时才会感到明显的帧率差异。 清单13.2 用`CAShapeLayer`重新实现绘图应用 ~~~ #import "DrawingView.h" #import @interface DrawingView () @property (nonatomic, strong) UIBezierPath *path; @end  @implementation DrawingView + (Class)layerClass { //this makes our view create a CAShapeLayer //instead of a CALayer for its backing layer return [CAShapeLayer class]; } - (void)awakeFromNib { //create a mutable path self.path = [[UIBezierPath alloc] init]; //configure the layer CAShapeLayer *shapeLayer = (CAShapeLayer *)self.layer; shapeLayer.strokeColor = [UIColor redColor].CGColor; shapeLayer.fillColor = [UIColor clearColor].CGColor; shapeLayer.lineJoin = kCALineJoinRound; shapeLayer.lineCap = kCALineCapRound; shapeLayer.lineWidth = 5; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { //get the starting point CGPoint point = [[touches anyObject] locationInView:self]; //move the path drawing cursor to the starting point [self.path moveToPoint:point]; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { //get the current point CGPoint point = [[touches anyObject] locationInView:self]; //add a new line segment to our path [self.path addLineToPoint:point]; //update the layer with a copy of the path ((CAShapeLayer *)self.layer).path = self.path.CGPath; } @end ~~~
';

13.1 软件绘图

最后更新于:2022-04-01 10:10:44

#软件绘图     术语*绘图*通常在Core Animation的上下文中指代软件绘图(意即:不由GPU协助的绘图)。在iOS中,软件绘图通常是由Core Graphics框架完成来完成。但是,在一些必要的情况下,相比Core Animation和OpenGL,Core Graphics要慢了不少。     软件绘图不仅效率低,还会消耗可观的内存。`CALayer`只需要一些与自己相关的内存:只有它的寄宿图会消耗一定的内存空间。即使直接赋给`contents`属性一张图片,也不需要增加额外的照片存储大小。如果相同的一张图片被多个图层作为`contents`属性,那么他们将会共用同一块内存,而不是复制内存块。     但是一旦你实现了`CALayerDelegate`协议中的`-drawLayer:inContext:`方法或者`UIView`中的`-drawRect:`方法(其实就是前者的包装方法),图层就创建了一个绘制上下文,这个上下文需要的大小的内存可从这个算式得出:图层宽*图层高*4字节,宽高的单位均为像素。对于一个在Retina iPad上的全屏图层来说,这个内存量就是 2048*1526*4字节,相当于12MB内存,图层每次重绘的时候都需要重新抹掉内存然后重新分配。     软件绘图的代价昂贵,除非绝对必要,你应该避免重绘你的视图。提高绘制性能的秘诀就在于尽量避免去绘制。
';

13. 高效绘图

最后更新于:2022-04-01 10:10:41

# 高效绘图 > 不必要的效率考虑往往是性能问题的万恶之源。 ——William Allan Wulf     在第12章『速度的曲率』我们学习如何用Instruments来诊断Core Animation性能问题。在构建一个iOS app的时候会遇到很多潜在的性能陷阱,但是在本章我们将着眼于有关*绘制*的性能问题。
';

12.4 总结

最后更新于:2022-04-01 10:10:39

# 总结     在这章中,我们学习了Core Animation是如何渲染,以及我们可能出现的瓶颈所在。你同样学习了如何使用Instruments来检测和修复性能问题。     在下三章中,我们将对每个普通程序的性能陷阱进行详细讨论,然后学习如何修复。
';

12.3 Instruments

最后更新于:2022-04-01 10:10:37

# Instruments     Instruments是Xcode套件中没有被充分利用的一个工具。很多iOS开发者从没用过Instruments,或者只是用Leaks工具检测循环引用。实际上有很多Instruments工具,包括为动画性能调优的东西。     你可以通过在菜单中选择Profile选项来打开Instruments(在这之前,记住要把目标设置成iOS设备,而不是模拟器)。然后将会显示出图12.1(如果没有看到所有选项,你可能设置成了模拟器选项)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc219e8900.png) 图12.1 Instruments工具选项窗口     就像之前提到的那样,你应该始终将程序设置成发布选项。幸运的是,配置文件默认就是发布选项,所以你不需要在分析的时候调整编译策略。 我们将讨论如下几个工具: * 时间分析器 - 用来测量被方法/函数打断的CPU使用情况。 * Core Animation - 用来调试各种Core Animation性能问题。 * OpenGL ES驱动 - 用来调试GPU性能问题。这个工具在编写Open GL代码的时候很有用,但有时也用来处理Core Animation的工作。     Instruments的一个很棒的功能在于它可以创建我们自定义的工具集。除了你初始选择的工具之外,如果在Instruments中打开Library窗口,你可以拖拽别的工具到左侧边栏。我们将创建以上我们提到的三个工具,然后就可以并行使用了(见图12.2)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc21a54901.png) 图12.2 添加额外的工具到Instruments侧边栏 ## 时间分析器     时间分析器工具用来检测CPU的使用情况。它可以告诉我们程序中的哪个方法正在消耗大量的CPU时间。使用大量的CPU并*不一定*是个问题 - 你可能期望动画路径对CPU非常依赖,因为动画往往是iOS设备中最苛刻的任务。     但是如果你有性能问题,查看CPU时间对于判断性能是不是和CPU相关,以及定位到函数都很有帮助(见图12.3)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc21abf610.png) 图12.3 时间分析器工具     时间分析器有一些选项来帮助我们定位到我们关心的的方法。可以使用左侧的复选框来打开。其中最有用的是如下几点: * 通过线程分离 - 这可以通过执行的线程进行分组。如果代码被多线程分离的话,那么就可以判断到底是哪个线程造成了问题。 * 隐藏系统库 - 可以隐藏所有苹果的框架代码,来帮助我们寻找哪一段代码造成了性能瓶颈。由于我们不能优化框架方法,所以这对定位到我们能实际修复的代码很有用。 * 只显示Obj-C代码 - 隐藏除了Objective-C之外的所有代码。大多数内部的Core Animation代码都是用C或者C++函数,所以这对我们集中精力到我们代码中显式调用的方法就很有用。 ## Core Animation     Core Animation工具用来监测Core Animation性能。它给我们提供了周期性的FPS,并且考虑到了发生在程序之外的动画(见图12.4)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc21b5f36b.png) 图12.4 使用可视化调试选项的Core Animation工具     Core Animation工具也提供了一系列复选框选项来帮助调试渲染瓶颈: * Color Blended Layers - 这个选项基于渲染程度对屏幕中的混合区域进行绿到红的高亮(也就是多个半透明图层的叠加)。由于重绘的原因,混合对GPU性能会有影响,同时也是滑动或者动画帧率下降的罪魁祸首之一。 * ColorHitsGreenandMissesRed - 当使用`shouldRasterizep`属性的时候,耗时的图层绘制会被缓存,然后当做一个简单的扁平图片呈现。当缓存再生的时候这个选项就用红色对栅格化图层进行了高亮。如果缓存频繁再生的话,就意味着栅格化可能会有负面的性能影响了(更多关于使用`shouldRasterize`的细节见第15章“图层性能”)。 * Color Copied Images - 有时候寄宿图片的生成意味着Core Animation被强制生成一些图片,然后发送到渲染服务器,而不是简单的指向原始指针。这个选项把这些图片渲染成蓝色。复制图片对内存和CPU使用来说都是一项非常昂贵的操作,所以应该尽可能的避免。 * Color Immediately - 通常Core Animation Instruments以每毫秒10次的频率更新图层调试颜色。对某些效果来说,这显然太慢了。这个选项就可以用来设置每帧都更新(可能会影响到渲染性能,而且会导致帧率测量不准,所以不要一直都设置它)。 * Color Misaligned Images - 这里会高亮那些被缩放或者拉伸以及没有正确对齐到像素边界的图片(也就是非整型坐标)。这些中的大多数通常都会导致图片的不正常缩放,如果把一张大图当缩略图显示,或者不正确地模糊图像,那么这个选项将会帮你识别出问题所在。 * Color Offscreen-Rendered Yellow - 这里会把那些需要离屏渲染的图层高亮成黄色。这些图层很可能需要用`shadowPath`或者`shouldRasterize`来优化。 * Color OpenGL Fast Path Blue - 这个选项会对任何直接使用OpenGL绘制的图层进行高亮。如果仅仅使用UIKit或者Core Animation的API,那么不会有任何效果。如果使用`GLKView`或者`CAEAGLLayer`,那如果不显示蓝色块的话就意味着你正在强制CPU渲染额外的纹理,而不是绘制到屏幕。 * Flash Updated Regions - 这个选项会对重绘的内容高亮成黄色(也就是任何在软件层面使用Core Graphics绘制的图层)。这种绘图的速度很慢。如果频繁发生这种情况的话,这意味着有一个隐藏的bug或者说通过增加缓存或者使用替代方案会有提升性能的空间。     这些高亮图层的选项同样在iOS模拟器的调试菜单也可用(图12.5)。我们之前说过用模拟器测试性能并不好,但如果你能通过这些高亮选项识别出性能问题出在什么地方的话,那么使用iOS模拟器来验证问题是否解决也是比真机测试更有效的。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc21bb8655.png) 图12.5 iOS模拟器中Core Animation可视化调试选项 ## OpenGL ES驱动     OpenGL ES驱动工具可以帮你测量GPU的利用率,同样也是一个很好的来判断和GPU相关动画性能的指示器。它同样也提供了类似Core Animation那样显示FPS的工具(图12.6)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc21c123ea.png) 图12.6 OpenGL ES驱动工具 侧栏的邮编是一系列有用的工具。其中和Core Animation性能最相关的是如下几点: * Renderer Utilization - 如果这个值超过了~50%,就意味着你的动画可能对帧率有所限制,很可能因为离屏渲染或者是重绘导致的过度混合。 * Tiler Utilization - 如果这个值超过了~50%,就意味着你的动画可能限制于几何结构方面,也就是在屏幕上有太多的图层占用了。 ## 一个可用的案例     现在我们已经对Instruments中动画性能工具非常熟悉了,那么可以用它在现实中解决一些实际问题。     我们创建一个简单的显示模拟联系人姓名和头像列表的应用。注意即使把头像图片存在应用本地,为了使应用看起来更真实,我们分别实时加载图片,而不是用`–imageNamed:`预加载。同样添加一些图层阴影来使得列表显示得更真实。清单12.1展示了最初版本的实现。 清单12.1 使用假数据的一个简单联系人列表 ~~~ #import "ViewController.h" #import @interface ViewController () @property (nonatomic, strong) NSArray *items; @property (nonatomic, weak) IBOutlet UITableView *tableView; @end @implementation ViewController - (NSString *)randomName { NSArray *first = @[@"Alice", @"Bob", @"Bill", @"Charles", @"Dan", @"Dave", @"Ethan", @"Frank"]; NSArray *last = @[@"Appleseed", @"Bandicoot", @"Caravan", @"Dabble", @"Ernest", @"Fortune"]; NSUInteger index1 = (rand()/(double)INT_MAX) * [first count]; NSUInteger index2 = (rand()/(double)INT_MAX) * [last count]; return [NSString stringWithFormat:@"%@ %@", first[index1], last[index2]]; } - (NSString *)randomAvatar { NSArray *images = @[@"Snowman", @"Igloo", @"Cone", @"Spaceship", @"Anchor", @"Key"]; NSUInteger index = (rand()/(double)INT_MAX) * [images count]; return images[index]; } - (void)viewDidLoad { [super viewDidLoad]; //set up data NSMutableArray *array = [NSMutableArray array]; for (int i = 0; i < 1000; i++) { //add name [array addObject:@{@"name": [self randomName], @"image": [self randomAvatar]}]; } self.items = array; //register cell class [self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"Cell"]; } - (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" forIndexPath:indexPath]; //load image NSDictionary *item = self.items[indexPath.row]; NSString *filePath = [[NSBundle mainBundle] pathForResource:item[@"image"] ofType:@"png"]; //set image and text cell.imageView.image = [UIImage imageWithContentsOfFile:filePath]; cell.textLabel.text = item[@"name"]; //set image shadow cell.imageView.layer.shadowOffset = CGSizeMake(0, 5); cell.imageView.layer.shadowOpacity = 0.75; cell.clipsToBounds = YES; //set text shadow cell.textLabel.backgroundColor = [UIColor clearColor]; cell.textLabel.layer.shadowOffset = CGSizeMake(0, 2); cell.textLabel.layer.shadowOpacity = 0.5; return cell; } @end ~~~     当快速滑动的时候就会非常卡(见图12.7的FPS计数器)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc21c89438.png) 图12.7 滑动帧率降到15FPS     仅凭直觉,我们猜测性能瓶颈应该在图片加载。我们实时从闪存加载图片,而且没有缓存,所以很可能是这个原因。我们可以用一些很赞的代码修复,然后使用GCD异步加载图片,然后缓存。。。等一下,在开始编码之前,测试一下假设是否成立。首先用我们的三个Instruments工具分析一下程序来定位问题。我们推测问题可能和图片加载相关,所以用Time Profiler工具来试试(图12.8)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc21cd8510.png) 图12.8 用The timing profile分析联系人列表     `-tableView:cellForRowAtIndexPath:`中的CPU时间总利用率只有~28%(也就是加载头像图片的地方),非常低。于是建议是CPU/IO并不是真正的限制因素。然后看看是不是GPU的问题:在OpenGL ES Driver工具中检测GPU利用率(图12.9)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc21d53629.png) 图12.9 OpenGL ES Driver工具显示的GPU利用率     渲染服务利用率的值达到51%和63%。看起来GPU需要做很多工作来渲染联系人列表。     为什么GPU利用率这么高呢?我们来用Core Animation调试工具选项来检查屏幕。首先打开Color Blended Layers(图12.10)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc21dc3b56.png) 图12.10 使用Color Blended Layers选项调试程序     屏幕中所有红色的部分都意味着字符标签视图的高级别混合,这很正常,因为我们把背景设置成了透明色来显示阴影效果。这就解释了为什么渲染利用率这么高了。     那么离屏绘制呢?打开Core Animation工具的Color Offscreen - Rendered Yellow选项(图12.11)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc21e38a3a.png) 图12.11 Color Offscreen–Rendered Yellow选项     所有的表格单元内容都在离屏绘制。这一定是因为我们给图片和标签视图添加的阴影效果。在代码中禁用阴影,然后看下性能是否有提高(图12.12)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc21e9970f.png) 图12.12 禁用阴影之后运行程序接近60FPS     问题解决了。干掉阴影之后,滑动很流畅。但是我们的联系人列表看起来没有之前好了。那如何保持阴影效果而且不会影响性能呢?     好吧,每一行的字符和头像在每一帧刷新的时候并不需要变,所以看起来`UITableViewCell`的图层非常适合做缓存。我们可以使用`shouldRasterize`来缓存图层内容。这将会让图层离屏之后渲染一次然后把结果保存起来,直到下次利用的时候去更新(见清单12.2)。 清单12.2 使用`shouldRasterize`提高性能 ~~~ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { //dequeue cell UITableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath]; ... //set text shadow cell.textLabel.backgroundColor = [UIColor clearColor]; cell.textLabel.layer.shadowOffset = CGSizeMake(0, 2); cell.textLabel.layer.shadowOpacity = 0.5; //rasterize cell.layer.shouldRasterize = YES; cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; } ~~~     我们仍然离屏绘制图层内容,但是由于显式地禁用了栅格化,Core Animation就对绘图缓存了结果,于是对提高了性能。我们可以验证缓存是否有效,在Core Animation工具中点击Color Hits Green and Misses Red选项(图12.13)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-12-24_567bc21ee30ee.png) 图12.13 Color Hits Green and Misses Red验证了缓存有效     结果和预期一致 - 大部分都是绿色,只有当滑动到屏幕上的时候会闪烁成红色。因此,现在帧率更加平滑了。     所以我们最初的设想是错的。图片的加载并不是真正的瓶颈所在,而且试图把它置于一个复杂的多线程加载和缓存的实现都将是徒劳。所以在动手修复之前验证问题所在是个很好的习惯!
';

12.2 测量,而不是猜测

最后更新于:2022-04-01 10:10:34

# 测量,而不是猜测     于是现在你知道有哪些点可能会影响动画性能,那该如何修复呢?好吧,其实不需要。有很多种诡计来优化动画,但如果盲目使用的话,可能会造成更多性能上的问题,而不是修复。     如何正确的测量而不是猜测这点很重要。根据性能相关的知识写出代码不同于仓促的优化。前者很好,后者实际上就是在浪费时间。     那该如何测量呢?第一步就是确保在真实环境下测试你的程序。 ## 真机测试,而不是模拟器     当你开始做一些性能方面的工作时,一定要在真机上测试,而不是模拟器。模拟器虽然是加快开发效率的一把利器,但它不能提供准确的真机性能参数。     模拟器运行在你的Mac上,然而Mac上的CPU往往比iOS设备要快。相反,Mac上的GPU和iOS设备的完全不一样,模拟器不得已要在软件层面(CPU)模拟设备的GPU,这意味着GPU相关的操作在模拟器上运行的更慢,尤其是使用`CAEAGLLayer`来写一些OpenGL的代码时候。     这就是说在模拟器上的测试出的性能会高度失真。如果动画在模拟器上运行流畅,可能在真机上十分糟糕。如果在模拟器上运行的很卡,也可能在真机上很平滑。你无法确定。     另一件重要的事情就是性能测试一定要用*发布*配置,而不是调试模式。因为当用发布环境打包的时候,编译器会引入一系列提高性能的优化,例如去掉调试符号或者移除并重新组织代码。你也可以自己做到这些,例如在发布环境禁用NSLog语句。你只关心发布性能,那才是你需要测试的点。     最后,最好在你支持的设备中性能最差的设备上测试:如果基于iOS6开发,这意味着最好在iPhone 3GS或者iPad2上测试。如果可能的话,测试不同的设备和iOS版本,因为苹果在不同的iOS版本和设备中做了一些改变,这也可能影响到一些性能。例如iPad3明显要在动画渲染上比iPad2慢很多,因为渲染4倍多的像素点(为了支持视网膜显示)。 ## 保持一致的帧率     为了做到动画的平滑,你需要以60FPS(帧每秒)的速度运行,以同步屏幕刷新速率。通过基于`NSTimer`或者`CADisplayLink`的动画你可以降低到30FPS,而且效果还不错,但是没办法通过Core Animation做到这点。如果不保持60FPS的速率,就可能随机丢帧,影响到体验。     你可以在使用的过程中明显感到有没有丢帧,但没办法通过肉眼来得到具体的数据,也没法知道你的做法有没有真的提高性能。你需要的是一系列精确的数据。     你可以在程序中用`CADisplayLink`来测量帧率(就像11章“基于定时器的动画”中那样),然后在屏幕上显示出来,但应用内的FPS显示并不能够完全真实测量出Core Animation性能,因为它仅仅测出应用内的帧率。我们知道很多动画都在应用之外发生(在渲染服务器进程中处理),但同时应用内FPS计数的确可以对某些性能问题提供参考,一旦找出一个问题的地方,你就需要得到更多精确详细的数据来定位到问题所在。苹果提供了一个强大的*Instruments*工具集来帮我们做到这些。
';