核心动画

[iOS] 核心高级动画技巧 — Part4

2019-11-01  本文已影响0人  木小易Ying

基于定时器的动画

NSTimer

其实动画原理就是每秒钟更新60次,timer也是同样的道理,我们用一个周期是1/60的timer就可以实现动画了。(不断更新center)

NSTimer 并不是最佳方案,为了理解这点,我们需要确切地知道 NSTimer 是如何工作的。iOS上的每个线程都管理了一个NSRunloop ,字面上看就是通过一个 循环来完成一些任务列表。但是对主线程,这些任务包含如下几项:

当你设置一个 NSTimer ,他会被插入到当前任务列表中,然后直到指定时间过去之后才会被执行。但是何时启动定时器并没有一个时间上限,而且它只会在列表中上一个任务完成之后开始执行。这通常会导致有几毫秒的延迟,但是如果上一个任务过了很久才完成就会导致延迟很长一段时间。

屏幕重绘的频率是一秒钟六十次,但是和定时器行为一样,如果列表中上一个执行了很长时间,它也会延迟。这些延迟都是一个随机值,于是就不能保证定时器精准地一秒钟执行六十次。有时候发生在屏幕重绘之后,这就会使得更新屏幕会有个延迟,看起来就是动画卡壳了。有时候定时器会在屏幕更新的时候执行两次,于是动画看起来就跳动了。

我们可以通过一些途径来优化:

在关键帧的实现中,我们提前计算了所有帧,但是在新的解决方案中, 我们实际上实在按需要在计算。意义在于我们可以根据用户输入实时修改动画的逻 辑,或者和别的实时动画系统例如物理引擎进行整合。


CADisplayLink

CADisplayLink 是CoreAnimation提供的另一个类似于NSTimer 的类,它总是在屏幕完成一次更新之前启动,它的接口设计的和NSTimer 很类似,所以它实际上就是一个内置实现的替代,但是和timeInterval以秒为单位不同, CADisplayLink有一个整型的frameInterval属性,制定了间隔多少帧之后才执行。默认值是1,意味着每次屏幕更新之前都会执行一次。但是如果动画的代码执行起来超过了六十分之一秒,你可以指定frameInterval 为2,就是说动画每隔一帧执行一次(一秒钟30帧)或者3,也就是一秒钟20次,等等。

用CADisplayLink而不是 NSTimer ,会保证帧率足够连续,使得动画看起来更加平滑,但即使 CADisplayLink也不能保证每一帧都按计划执行,一些失去控制的离散的任务或者事件(例如资源紧张的后台程序)可能会导致动画偶尔地丢帧。 当使用 NSTimer 的时候,一旦有机会计时器就会开启,但是 CADisplayLink 却不一样:如果它丢失了帧,就会直接忽略它们,然后在下一次更新的时候接着运行。

无论是使用NSTimer还是CADisplayLink,我们仍然需要处理一帧的时间超出了预期的六十分之一秒。由于我们不能够计算出一帧真实的持续时间,所以需要手动测量。我们可以在每帧开始刷新的时候用CACurrentMediaTime()记录当前时 间,然后和上一帧记录的时间去比较。


RunLoop

注意到当创建CADisplayLink的时候,我们需要指定一个 run loop 和run loop mode,和 CADisplayLink 类似,NSTimer同样也可以使用不同的run loop模式配置,通过别的函数,而不是+scheduledTimerWithTimeInterval:构造器

self.timer = [NSTimer timerWithTimeInterval:1/60.0
                                 target:self
                               selector:@selector(step:)
                               userInfo:nil
                                repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:self.timer
                          forMode:NSRunLoopCommonModes];

性能调优

CPU/GPU

关于绘图和动画有两种处理的方式:CPU(中央处理器)和GPU(图形处理器)。在现代iOS设备中,都有可以运行不同软件的可编程芯片,但是由于历史原因,我们可以说CPU所做的工作都在软件层面,而GPU在硬件层面。

总的来说,我们可以用软件(使用CPU)做任何事情,但是对于图像处理,通常用硬件会更快,因为GPU使用图像对高度并行浮点运算做了优化。由于某些原因,我们想尽可能把屏幕渲染的工作交给硬件去处理。问题在于GPU并没有无限制处理性能,而且一旦资源用完的话,性能就会开始下降了(即使CPU并没有完全占用)

大多数动画性能优化都是关于智能利用GPU和CPU,使得它们都不会超出负荷。于是我们首先需要知道Core Animation是如何在这两个处理器之间分配工作的。


Core Animation

一个简单的动画可能同步显示多个app的内容,例如当在iPad上多个程序之间使用手势切换,会使得多个程序同时显示在屏幕上。在一个特定的应用中用代码实现它是没有意义的,因为在iOS中不可能实现这种效果(App都是被沙箱管理,不能访问别的视图)。

动画和屏幕上组合的图层实际上被一个单独的进程管理,而不是你的应用程序。这个进程就是所谓的渲染服务。在iOS5和之前的版本是SpringBoard进程(同时管理着iOS的主屏)。在iOS6之后的版本中叫做BackBoard。

当运行一段动画时候,这个过程会被四个分离的阶段被打破:

但是这些仅仅阶段仅仅发生在你的应用程序之内,在动画在屏幕上显示之前仍然有更多的工作。一旦打包的图层和动画到达渲染服务进程,他们会被反序列化来形成另一个叫做渲染树的图层树(在第一章“图层树”中提到过)。使用这个树状结构,渲染服务对动画的每一帧做出如下工作:

所以一共有六个阶段;最后两个阶段在动画过程中不停地重复。前五个阶段都在软件层面处理(通过CPU),只有最后一个被GPU执行。而且,你真正只能控制前两个阶段:布局和显示。Core Animation框架在内部处理剩下的事务,你也控制不了它。

这并不是个问题,因为在布局和显示阶段,你可以决定哪些由CPU执行,哪些交给GPU去做。那么改如何判断呢?


GPU性能瓶颈:

大多数CALayer的属性都是用GPU来绘制。但是有一些事情会降低(基于GPU)图层绘制,比如:


CPU操作

大多数工作在Core Animation的CPU都发生在动画开始之前。这意味着它不会影响到帧率,但是他会延迟动画开始的时间,让你的界面看起来会比较迟钝。

以下CPU的操作都会延迟动画的开始时间:

当图层被成功打包,发送到渲染服务器之后,CPU仍然要做如下工作:为了显示屏幕上的图层,Core Animation必须对渲染树种的每个可见图层通过OpenGL循环转换成纹理三角板。由于GPU并不知晓Core Animation图层的任何结构,所以必须要由CPU做这些事情。这里CPU涉及的工作和图层个数成正比,所以如果在你的层级关系中有太多的图层,就会导致CPU没一帧的渲染,即使这些事情不是你的应用程序可控的。


IO

还有一项没涉及的就是IO相关工作。一些动画可能需要从闪存(甚至是远程URL)来加载。一个典型的例子就是两个视图控制器之间的过渡效果,这就需要从一个nib文件或者是它的内容中懒加载。IO比内存访问更慢,所以如果动画涉及到IO,就是一个大问题。总的来说,这就需要使用聪敏但尴尬的技术,也就是多线程,缓存和投机加载(提前加载当前不需要的资源,但是之后可能需要用到)


模拟器运行在你的Mac上,然而Mac上的CPU往往比iOS设备要快。相反,Mac上的GPU和iOS设备的完全不一样,模拟器不得已要在软件层面(CPU)模拟设备的GPU,这意味着GPU相关的操作在模拟器上运行的更慢。这就是说在模拟器上的测试出的性能会高度失真。

另一件重要的事情就是性能测试一定要用发布配置,而不是调试模式。因为当用发布环境打包的时候,编译器会引入一系列提高性能的优化,例如去掉调试符号或者移除并重新组织代码。


Instruments

时间分析器工具用来检测CPU的使用情况(百分之几的利用率)。它可以告诉我们程序中的哪个方法正在消耗大量的CPU时间。使用大量的CPU并不一定是个问题 - 你可能期望动画路径对CPU非常依赖,因为动画往往是iOS设备中最苛刻的任务。

Core Animation工具用来监测Core Animation性能。它给我们提供了周期性的FPS,并且考虑到了发生在程序之外的动画。

OpenGL ES驱动工具可以帮你测量GPU的利用率,同样也是一个很好的来判断和GPU相关动画性能的指示器。它同样也提供了类似Core Animation那样显示FPS的工具。

其中和Core Animation性能最相关的是如下几点:

instrument

高效绘图

绘图通常在Core Animation的上下文中指代软件绘图(意即:不由GPU协助 的绘图)。在iOS中,软件绘图通常是由Core Graphics框架完成来完成。但是,在一些必要的情况下,相比Core Animation和OpenGL,Core Graphics要慢了不少。
软件绘图不仅效率低,还会消耗可观的内存。

CALayer只需要一些与自己相关的内存:只有它的寄宿图会消耗一定的内存空间。即使直接赋给 contents属性一 张图片,也不需要增加额外的照片存储大小。如果相同的一张图片被多个图层作为 contents 属性,那么他们将会共用同一块内存,而不是复制内存块。

但是一旦你实现了CALayerDelegate 协议中的 -drawLayer:inContext:方 法或者 UIView 中的- drawRect:方法(其实就是前者的包装方法),图层就创建了一个绘制上下文,这个上下文需要的大小的内存可从这个算式得出:图层宽图层高4字节,宽高的单位均为像素。对于一个在Retina iPad上的全屏图层来说,这个内存量就是 204815264字节,相当于12MB内存,图层每次重绘的时候都需要重新抹掉内存然后重新分配。

软件绘图的代价昂贵,除非绝对必要,你应该避免重绘你的视图。提高绘制性能的秘诀就在于尽量避免去绘制。


我们用Core Graphics来绘图的一个通常原因就是只是用图片或是图层效果不能 轻易地绘制出矢量图形。矢量绘图包含一下这些:

例如用贝塞尔曲线实现随着手指移动绘制线条,因为每次移动手指的时候都会重绘整个贝塞尔路径(UIBezierPath),随着路径越来越复杂,每次重绘 的工作就会增加,直接导致了帧数的下降。

Core Animation为这些图形类型的绘制提供了专门的类,并给他们提供硬件支持 (第六章『专有图层』有详细提到)。CAShapeLayer可以绘制多边形,直线和 曲线。 CATextLayer 可以绘制文本。CAGradientLayer 用来绘制渐变。这些总体上都比Core Graphics更快,同时他们也避免了创造一个寄宿图。

#import "DrawingView.h"

@interface DrawingView()
@property (nonatomic, strong) UIBezierPath *path;
@end


@implementation DrawingView

+ (Class)layerClass{
    
    return [CAShapeLayer class];
}

- (void)awakeFromNib{
    [super awakeFromNib];
    self.path = [[UIBezierPath alloc]init];
  
    CAShapeLayer *shapeLayer = (CAShapeLayer *)self.layer;
    shapeLayer.strokeColor = [UIColor redColor].CGColor;
    shapeLayer.fillColor = [UIColor clearColor].CGColor;
    shapeLayer.lineJoin = kCALineJoinRound;
    shapeLayer.lineCap = kCALineCapRound;
    shapeLayer.lineWidth = 5.0f;
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    CGPoint point = [[touches anyObject] locationInView:self];
    [self.path moveToPoint:point];
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    CGPoint point  = [[touches anyObject] locationInView:self];
    [self.path addLineToPoint:point];
    ((CAShapeLayer *)self.layer).path = self.path.CGPath;
    
}
@end
设置重绘区域

当一个视图被改动过了,TA可能需要重绘。但是很多情况下,只是这个视图的一 部分被改变了,所以重绘整个寄宿图就太浪费了。

当你检测到指定视图或图层的指定部分需要被重绘,你直接调用- setNeedsDisplayInRect:来标记它,然后将影响到的矩形作为参数传入。这样就会在一次视图刷新时调用视图的-drawRect: (或图层代理的- drawLayer:inContext:方法)。

传入-drawLayer:inContext:的CGContext参数会自动被裁切以适应对应的矩形。为了确定矩形的尺寸大小,你可以用CGContextGetClipBoundlingBox()方法来从上下文获得大小。调用- drawRect()会更简单,因为CGRect会作为参数直接传入。


异步绘制

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

针对这个问题,有一些方法可以用到:


图像IO

绘图实际消耗的时间通常并不是影响性能的因素。图片消耗很大一部分内存,而且不太可能把需要显示的图片都保留在内存中,所以需要在应用运行的时候周期性地加载和卸载图片。

- (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;
 }

我们假设传送器的性能瓶颈在 于图片文件的加载,但实际上并不是这样。加载图片数据到内存中只是问题的第一部分。

延迟解压

一旦图片文件被加载就必须要进行解码,解码过程是一个相当复杂的任务,需要消耗非常长的时间。解码后的图片将同样使用相当大的内存

用于加载的CPU时间相对于解码来说根据图片格式而不同。对于PNG图片来说, 加载会比JPEG更长,因为文件可能更大,但是解码会相对较快,而且Xcode会把 PNG图片进行解码优化之后引入工程。JPEG图片更小,加载更快,但是解压的步 骤要消耗更长的时间,因为JPEG解压算法比基于zip的PNG算法更加复杂。

当加载图片的时候,iOS通常会延迟解压图片的时间,直到加载到内存之后。这就会在准备绘制图片的时候影响性能,因为需要在绘制之前进行解压(通常是消耗时间的问题所在)。

(1)最简单的方法就是使用UIImage的+imageNamed: 方法避免延时加载。不像+imageWithContentsOfFile:(和其他别的UIImage加载方法),这个方法会在加载图片之后立刻进行解压(就和本章之前我们谈到的好处一样)。问题在于 +imageNamed: 只对从应用资源束中的图片有效,所以对用户生成的图片内容 或者是下载的图片就没法使用了。

(2)另一种立刻加载图片的方法就是把它设置成图层内容,或者是 UIImageView 的 image 属性。不幸的是,这又需要在主线程执行,所以不会对性能有所提升。

(3)第三种方式就是绕过 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 来创建图片,强制图片立刻解压,然后在图片的生命周期保留解压后的版本。

有两种方式可以为强制解压提前渲染图片:

例如:


- (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 == imageView.tag) {
        imageView.image = image; 
      }
    }); 
  });
  return cell;
}

CATiledLayer

CATiledLayer 可以用来异步加载和显示大型图片,而不阻塞用户输入。但是我们同样可以使用CATiledLayer 在 UICollectionView 中为每个表格创建分离的CATiledLayer实例加载传动器图片,每个表格仅使用一个图层。

这样使用 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
{
  //register cell class
  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; 
    //tileSize是像素不是点哦
    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

分辨率交换

如果需要快速加载和显示移动大图,简单的办法就是欺骗人眼,在移动传送器的时候显示一个小图(或者低分辨率),然后当停止的时候再换成大图。这意味着我们需要对每张图片存储两份不同分辨率的副本,但是幸运的是,由于需要同时支持Retina和非Retina设备,本来这就是普遍要做到的。

如果从远程源或者用户的相册加载没有可用的低分辨率版本图片,那就可以动态将大图绘制到较小的 CGContext ,然后存储到某处以备复用。

为了做到图片交换,我们需要利用 UIScrollView 的一些实现UIScrollViewDelegate协议的委托方法(和其他类似于UITableView和UICollectionView基于滚动视图的控件一样):

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate;
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView;

缓存

如果有很多张图片要显示,最好不要提前把所有都加载进来,而是应该当移出屏幕之后立刻销毁。通过选择性的缓存,你就可以避免来回滚动时图片重复性的加载了。

+imageNamed:

之前我们提到使用[UIImage imageNamed:]加载图片有个好处在于可以立刻解压图片而不用等到绘制的时候。但是[UIImage imageNamed:]方法有另一个非常显著的好处:它在内存中自动缓存了解压后的图片,即使你自己没有保留对它的任何引用。

对于iOS应用那些主要的图片(例如图标,按钮和背景图片),使用[UIImage imageNamed:]加载图片是最简单最有效的方式。在nib文件中引用的图片同样也是这个机制,所以你很多时候都在隐式的使用它。
但是[UIImage imageNamed:]并不适用任何情况。它为用户界面做了优化,但是并不是对应用程序需要显示的所有类型的图片都适用。有些时候你还是要实现自己的缓存机制,原因如下:

自定义缓存

NSCache

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

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

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

#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

文件格式

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

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

- (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;
}

PNG和JPEG压缩算法作用于两种不同的图片类型:JPEG对于噪点大的图片效果很好;但是PNG更适合于扁平颜色,锋利的线条或者一些渐变色的图片。

格式对比

相对于不友好的PNG图片,相同像素的JPEG图片总是比PNG加载更快,除非一些非常小的图片、但对于友好的PNG图片,一些中大尺寸的图效果还是很好的。

如果用JPEG的话,一些多线程和缓存策略都没必要了。但JPEG图片并不是所有情况都适用。如果图片需要一些透明效果,或者压缩之后细节损耗很多,那就该考虑用别的格式了。苹果在iOS系统中对PNG和JPEG都做了一些优化,所以普通情况下都应该用这种格式。


混合图片

对于包含透明的图片来说,最好是使用压缩透明通道的PNG图片和压缩RGB部分的JPEG图片混合起来加载。这就对任何格式都适用了,而且无论从质量还是文件尺寸还是加载性能来说都和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:方法的支持。


JPG2000

除了JPEG和PNG之外iOS还支持别的一些格式,例如TIFF和GIF,但是由于他们质量压缩得更厉害,性能比JPEG和PNG糟糕的多,所以大多数情况并不用考虑。

但是iOS之后,苹果低调添加了对JPEG 2000图片格式的支持,但是JPEG 2000图片在(设备和模拟器)运行时会有效,而且比JPEG质量更好,同样也对透明通道有很好的支持。但是JPEG 2000图片在加载和显示图片方面明显要比PNG和JPEG慢得多,所以对图片大小比运行效率更敏感的时候,使用它是一个不错的选择。


PVRTC

当前市场的每个iOS设备都使用了Imagination Technologies PowerVR图像芯片作为GPU。PowerVR芯片支持一种叫做PVRTC(PowerVR Texture Compression)的标准图片压缩。

和iOS上可用的大多数图片格式不同,PVRTC不用提前解压就可以被直接绘制到屏幕上。这意味着在加载图片之后不需要有解压操作,所以内存中的图片比其他图片格式大大减少了(这取决于压缩设置,大概只有1/60那么大)。

但是PVRTC仍然有一些弊端:


图层性能

隐式绘制

寄宿图可以通过Core Graphics直接绘制,也可以直接载入一个图片文件并赋值给contents属性,或事先绘制一个屏幕之外的 CGContext上下文。在之前的两 章中我们讨论了这些场景下的优化。但是除了常见的显式创建寄宿图,你也可以通过以下三种方式创建隐式的:

  1. 使用特性的图层属性
  2. 特定的视图
  3. 特定的图层子类
文本

CATextLayer 和 UILabel 都是直接将文本绘制在图层的寄宿图中。事实上这两种方式用了完全不同的渲染方式:在iOS 6及之前,UILabel 用WebKit的HTML渲染引擎来绘制文本,而 CATextLayer 用的是Core Text.后者渲染更迅速,所以在所有需要绘制大量文本的情形下都优先使用它吧。但是这两种方法都用了软件的方式绘制,因此他们实际上要比硬件加速合成方式要慢。

不论如何,尽可能地避免改变那些包含文本的视图的frame,因为这样做的话文本就需要重绘。例如,如果你想在图层的角落里显示一段静态的文本,但是这个图层经常改动,你就应该把文本放在一个子图层中。

光栅化

启用 shouldRasterize 属性会将图层绘制到一个屏幕之外的图像。然后这个图像将会被缓存起来并绘制到实际图层的 contents 和子图层。如果有很多的子图层或者有复杂的效果应用,这样做就会比重绘所有事务的所有帧划得来得多。但是光栅化原始图像需要时间,而且还会消耗额外的内存。

为了检测你是否正确地使用了光栅化方式,用Instrument查看一下Color Hits Green和Misses Red项目,是否已光栅化图像被频繁地刷新。


离屏渲染

当图层属性的混合体被指定为在未预合成之前不能直接在屏幕中绘制时,屏幕外渲染就被唤起了。屏幕外渲染并不意味着软件绘制,但是它意味着图层必须在被显示之前在一个屏幕外上下文中被渲染(不论CPU还是GPU)。图层的以下属性将会触发屏幕外绘制:

有时候我们可以把那些需要屏幕外绘制的图层开启光栅化以作为一个优化方式,前提是这些图层并不会被频繁地重绘。

对于那些需要动画而且要在屏幕外渲染的图层来说,你可以用CAShapeLayer , contentsCenter或者shadowPath 来获得同样的表现而且较少地影响到性能。

圆角的话可以用CAShapeLayer或者用一张可伸缩的圆形内容图片做;阴影如果简单就用shadowpath,复杂可以做一张阴影图~


混合和过度绘制

GPU每一帧可以绘制的像素有一个最大限制(就是所谓的fill rate),这个情况下可以轻易地绘制整个屏幕的所有像素。但是如果由于重叠图层的关系需要不停地重绘同一区域的话,掉帧就可能发生了。

GPU会放弃绘制那些完全被其他图层遮挡的像素,但是要计算出一个图层是否被遮挡也是相当复杂并且会消耗处理器资源。同样,合并不同图层的透明重叠像素 (即混合)消耗的资源也是相当客观的。所以为了加速处理进程,不到必须时刻不要使用透明图层。任何情况下,你应该这样做:

这样做减少了混合行为(因为编译器知道在图层之后的东西都不会对最终的像素 颜色产生影响)并且计算得到了加速,避免了过度绘制行为因为Core Animation可以舍弃所有被完全遮盖住的图层,而不用每个像素都去计算一遍。

如果用到了图像,尽量避免透明;如果是文本的话,一个白色背景的 UILabel(或者其他颜色)会比透明背景要 更高效。


减少图层数量

初始化图层,处理图层,打包通过IPC发给渲染引擎,转化成OpenGL几何图形,这些是一个图层的大致资源开销,一次性能够在屏幕上显示的最大图层数量也是有限的。

确切的限制数量取决于iOS设备,图层类型,图层内容和属性等。但是总得说来可以容纳上百或上千个,下面我们将演示即使图层本身并没有做什么也会遇到的性能问题。

裁切

在对图层做任何优化之前,你需要确定你不是在创建一些不可见的图层,图层在以下几种情况下回事不可见的:

Core Animation非常擅长处理对视觉效果无意义的图层。但我们应该在图层对象在创建之前就判断好,如果在视区以外就不要绘制,以避免创建和配置不必要图层的额外工作。

对象回收

处理巨大数量的相似视图或图层时还有一个技巧就是回收他们。对象回收在iOS 颇为常见; UITableView和UICollectionView都有用到。

对象回收的基础原则就是你需要创建一个相似对象池。当一个对象的指定实例结束了使命,你把它添加到对象池中。每次当你需要一个实例时,你就从池中取出一个。当且仅当池中为空时再创建一个新的。

CALayer *layer = [self.recyclePool anyObject]; 
if (layer) {
    recycled ++;
    [self.recyclePool removeObject:layer];
} else {
    layer = [[CALayer alloc] init];
    layer.frame = CGRectMake(0, 0, SIZE, SIZE); 
}
Core Graphics绘制

例如,如果你正在使用多个UILabel 或者UIImageView 实例去显示固定内容,你可以把他们全部替换成一个单独的视图,然后用- drawRect: 方法绘制出那些复杂的视图层级。

这个提议看上去并不合理因为大家都知道软件绘制行为要比GPU合成要慢而且还需要更多的内存空间,但是在因为图层数量而使得性能受限的情况下,软件绘制很可能提高性能呢,因为它避免了图层分配和操作问题。

你可以自己实验一下这个情况,它包含了性能和栅格化的权衡,但是意味着你可以从图层树上去掉子图层(用shouldRasterize,与完全遮挡图层相反)。


-renderInContext: 方法

使用CALayer的-renderInContext:方法,你可以将图层及其子图层快照进一个Core Graphics上下文然后得到一个图片,它可以直接显示在UIImageView中,或者作为另一个图层的contents。不同于shouldRasterize 要求图层与图层树相关联,这个方法没有持续的性能消耗。

当图层内容改变时,刷新这张图片的机会取决于你(不同于shouldRasterize,它自动地处理缓存和缓存验证),但是一旦图片被生成,相比于让Core Animation处理一个复杂的图层树,你节省了相当客观的性能。

上一篇下一篇

猜你喜欢

热点阅读