【iOS小结】Core Animation(下)─── 性能优化

一. 渲染原理和GPU、CPU的作用

我们在前面学到了Core Animation的功能(绘图和动画),接下来要学习关于性能相关的知识。在Core Animation性能优化方面,首先要了解CPU、GPU、IO相关的操作。
关于绘图和动画有两种处理方式:CPU(中央处理器)和GPU(图形处理器)。我们可以说CPU所做的工作都在软件层面,而GPU在硬件层面。我们可以用软件(使用GPU)做任何事情,但是对于图像处理,通常用硬件会更快,因为GPU使用图像对高度并行浮点预算做了优化。
动画和屏幕上组合的图层实际上被一个单独的进程管理,而不是你的应用程序,这个进程就是所谓的渲染服务
当运行一段动画时,分成以下阶段:

  • 布局
    这是准备你的视图/图层的层级关系,以及设置图层的属性(位置,背景色,边框)的阶段
  • 显示
    这是图层的寄宿图片被绘制的阶段。绘制可能会涉及到你的-drawRect:-drawLayer:inContext:方法的调用路径。
  • 准备
    这是Core Animation准备发送动画数据到渲染服务的阶段。这同时也是
    Core Animation将要执行一些别的事务例如解码动画过程中将要显示的图片的时间点。
  • 提交
    这是最后的阶段,Core Animation打包所有的图层和动画属性,通过IPC(内部处理通讯)发送到渲染服务进行显示。

不过这些阶段仅仅发生在你的应用程序中,一旦打包的图层和动画到达渲染服务进程,它们会被反序列化来形成一个叫渲染树的图层树。使用这个树状结构,渲染服务对动画的每一帧做出如下动作:

  • 对所有的图层属性计算中间值,设置OpenGL几何形状来执行渲染
  • 在屏幕上渲染可见的三角形

所以一共六个阶段,最后两个阶段在动画过程中不断重复。前五个阶段都在软件层面处理(通过CPU),只有最后一个被GPU执行。而我们真正能控制的就前两个阶段:布局和显示。

那在布局和显示阶段,哪些是影响CPU或者GPU的因素呢?

CPU相关的操作

CPU大多数工作都在动画开始之前,所以它不会影响到帧率。但是它会影响到动画开始的时间,造成界面的迟钝。以下CPU操作会延迟动画开始时间:

  • 布局计算
  • 视图懒加载
  • Core Graphics绘制
  • 解压图片
  • 太多的图层
GPU相关的操作

GPU用来采集图片和形状(三角形),运行变换,应用纹理和混合然后把它们输送到屏幕上。大多数CALayer的属性都是GPU来绘制的。

  • 太多的几何结构
  • 重绘
  • 离屏绘制
  • 过大的图片

还有一项影响性能的就是IO相关的操作。IO(输入/输出)指的是例如从闪存或者网络接口的硬件访问。IO比内存访问更慢,如果动画设计到IO,就需要使用一些优化(比如多线程、缓存、提前加载)

如果知道了哪些点会影响性能,也要进行正确的测试。通过测试才能合理地优化。测试时也应该要使用真机测试,并且使用发布配置,最好能在支持设备中最差的的设备上测试。我们可以通过Instruments工具集来测试。我们主要用到这几个:

  • 时间分析器(用来测量被方法/函数打断的CPU使用情况)
  • Core Animation(用来调试各种Core Animation性能问题)
  • OpenGL ES驱动(用来调试GPU性能问题)

二. 性能陷阱和优化

前面我们学习了Core Animation如何渲染,以及GPU、CPU的作用。接下来要具体分析具体的性能陷阱和优化技巧。
我们这边主要分成三大块:
① 绘图相关的优化
② IO操作相关的优化
③ 图层相关的优化

① 绘图相关的优化

在iOS中绘图通常由Core Graphics框架完成,在一些情况下相比Core
Animation 和OpenGL要慢不少。绘图不仅效率低,还有消耗可观的内存。CALayer只需要一些与自己相关的内存,只有它的寄宿图会消耗一定的内存空间,即使直接赋值给contens一张图片,也不会消耗额外的图片存储大小。但是,一旦实现了CALayerDelegate协议中的-drawLayer:inContext:(或者UIView的- drawRect:,该方法是前者的包装方法),图层就得创建一个绘制上下文,内存大小为 图层高 x 图层宽 x 4字节,宽高单位均为像素。对于一个Retina的全屏图层,所需的内存都要几M以上,图层每次重绘时都需要抹掉内存重新分配。

所以,有以下几种优化方案:
(1) 除了一些特殊情况,可以使用Core Animation的为图形类型的绘制提供的类来代替。比如,CAShapeLayer可以绘制多边形,直线,曲线。CATextLayer可以绘制文本。CAGradientLayer用来绘制渐变。这些总体都比Core Graphics快,同时它们也避免创建一个寄宿图。
(2) 只重绘制脏矩形
为了减少不必要的绘制,iOS将屏幕分为需要重绘的区域和不需要重绘的区域。那些需要重绘的部分被称为“脏矩形”,当你检测到指定视图或图层的指定部分需要被重绘,你直接调用-setNeedsDisplayInRect:来标记它,然后将影响的矩形作为参数传入。这样视图刷新时就会去刷新该区域。
(3) 异步绘制
可以提前在另外一个线程绘制内容,然后将由此绘制出的图片直接设置为图层的内容。Core Animation提供了两种选择:CATiledLayer和drawsAsynchronously属性。

② IO操作相关的优化

图片的加载和解压也是一个很影响性能的因素。
图片文件加载的速度被CPU和IO(输入/输出)同时影响,iOS设备中的闪存虽然比硬盘快很多,但是仍然比RAM慢很多,所以就需要管理加载,来避免延迟。
可以使用多线程加载,开辟一个新线程加载图片,在主线程赋值给imageView:

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
          //加载图片
          NSInteger index = indexPath.row;
          NSString *imagePath = self.imagePaths[index];
          UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
          //在主线程赋值给imageView
          dispatch_async(dispatch_get_main_queue(), ^{
              if (index == cell.tag) {
                  imageView.image = image;
          }
});

实际上,这样的优化并不完美,因为还有一个影响性能的问题----解压。一旦图片文件被加载就必须要进行解码,解码过程是一个相当复杂的任务,需要消耗非常长的时间。解码后的图片将同样使用相当大的内存。图片格式不同,用于加载和解码的时间也不同。PNG图片文件更大,所以加载会比JPEG更长,但是解码会相对较快,因为解码算法比JPEG简单。
当加载图片的时候,iOS通常会延迟解压图片的时间,直到加载到内存中(意思是直到把图片设置成图层内容,或是赋值给UIImageView才去解压)。所以就需要用以下方法来避免延迟解压:
(1)最简单的就是使用UIImage的+imageNamed:方法避免延迟解压。不同于UIImage的其他加载方法,这个方法会在加载图片后立刻进行解压。
(2)使用ImageIO框架

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);

(3)使用UIKit加载图片后,立刻绘制到CGContext中。因为图片必须要在绘制之前解压,另外也可以将绘制和加载放在多线程中。如果要想显示图片到比原始尺寸小的容器中,那么一次性在后台线程重新绘制到正确的尺寸会比每次显示的时候做缩放更快。

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
    //加载图片
    NSInteger index = indexPath.row;
    NSString *imagePath = self.imagePaths[index];
    UIImage *image = [UIImage imageWithContentsOfFile:imagePath];

    //将图片绘制到上下文
    UIGraphicsBeginImageContextWithOptions(imageView.bounds, YES, 0);
    image drawInRect:imageView.bounds];
    image = UIGraphicsGetImageFromCurrentImageContext(); 
    UIGraphicsEndImageContext();

    //在主线程设置图片
    dispatch_async(dispatch_get_main_queue(), ^{
        if (index == cell.tag) {
            imageView.image = image;
        } });
});

即使使用了上述加载图片和解压的技术,有时候仍然会发现实时加载大图还是有问题。除了在加载中同时显示一个占位图片,我们还可以使用如下方案:

(1)大小图切换
如果需要快速加载和显示移动大图,可以在移动时显示一个小图(或低分辨率的图),然后当停止时再换成大图。只要高分辨率图片和低分辨率图片尺寸颜色保持一致,肉眼很难察觉替换的过程。
如果没有低分辨率版本的图片,可以自己生成,动态将大图绘制到较小的CGContext,然后存储到某处以备复用。以下两个方法是用来判断是否滚动的:

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

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView;

(2)缓存
缓存就是存储昂贵计算后的结果(或者是从闪存或者网络加载的问价)在内存中,以便后续使用,这样访问起来就很快。之前提到的使用[UIImage imageNamed:] 加载图片除了可以立刻解压图片而不用等到绘制的时候,也有另一个优点:它在内存中自动缓存了解压后的图片,即使你还没用到它。但是[UIImage imageNamed:]并不适用任何情况,也有如下几个局限性:
(1)仅仅适用于在应用程序资源目录下的图片。
(2)只适用于按钮、背景这种图片,对于照片这种大图,系统可能会移除这些图片来节省内存。
(3)缓存机制不是公开的,不能很好控制它,不如设置缓存大小,从缓存移除没用的图片。
所以,就需要我们去自定义缓存,而iOS中就可以使用NSCache。

- (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]; UICollectionV UIImageView *imageView = [cell.contentView.subviews lastObject];
            imageView.image = image;
        }); });
    //not loaded yet
    return nil;
    
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(nonnull NSIndexPath *)indexPath {
    //dequeue cell
    UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell"];
    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;
}

③ 图层相关的优化

只有注重图层树本身,才能挖掘更好的性能。

减少重绘

寄宿图可以通过Core Graphics直接绘制,也可以直接载入一个图片文件并赋值给contents属性,或事先绘制一个屏幕外的CGContext上下文。除了图形外,CATextLayer和UILabel都是直接将文本绘制在图层的寄宿图中,虽然渲染方式不一样(UILabel用WebKit的HTML渲染引擎来绘制文本,CATextLayer用的是CoreText,后者更迅速,所以绘制大量文本有限使用CATextLayer),但都是用软件的绘制方式,实际上比硬件加速合成的方式要慢。
不论如何,都要尽量避免重绘。比如尽量避免改变那些包含文本的视图的frame,因为这样文本就需要重绘。如果该图层经常改动,可以把这静态的文本放在一个子图层中。

合理利用光栅化

启用shouldRasterize属性会将图层绘制到一个屏幕之外的图像,然后这个图像就会被缓存起来并绘制到实际图层的contens和子图层(通俗讲就是将所有图层合成起来)。如果有很多子图层或者复杂的效果应用,这样会比重绘所有事务的所有帧效果好,但是光栅化原始图像需要时间,还会消耗额外的内存。当我们使用得当,光栅化可以提供很大的性能优势,但是要避免作用在不断变化的图层上。

减少离屏渲染

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

  • 圆角(和maskToBounds一起使用时)
  • 图层蒙版(mask)
  • 阴影

屏幕外渲染和启用光栅化相似,除了它没有像光栅化图层消耗大,子图层也并没有被影响,结果也没有被缓存,所以不会有长期的内存占用。但是如果太多图层在屏幕外渲染会影响到性能。
所以,有时候我们可以把那些需要屏幕外绘制的图层开启光栅化作为一个优化方式(前提是这些图层不会被频繁重绘)。对于那些需要动画而且要在屏幕外渲染的图层,我们可以使用CAShapeLayer(设置圆角),contentsCenter(创建一个可伸缩图片,可以绘制成任意边框效果而不需要额外的性能损耗)或者shadowPath来减少对性能的影响。

//contentsCenter的例子
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; 
[self.layerView.layer addSublayer:blueLayer];
避免混合和过度绘制

GPU每一帧可以绘制的像素有个最大限制,如果由于重复图层的关系需要不断重绘同一区域,可能会超出限制,造成掉帧。GPU会放弃绘制那些完全被其他图层遮盖的像素,但是计算一个图层是否被遮挡也是相当复杂并且消耗处理器资源的。同样,合并不同图层的透明重叠像素(混合)也是十分消耗资源的。所以为加快处理进程,不到必要时不要使用透明图层。所以,我们要给视图的backgroundColor属性设置一个不透明的颜色,并设置opaque属性为YES。这样就会避免过度绘制,因为Core Animation可以舍弃所有被完全遮挡住d额图层,不用每个像素都去计算一遍。
另外,合理使用shouldRasterize属性,将一个固定的图层体系合成单张照片,也会避免子图层的混合和过度绘制的性能问题。

减少图层数量

初始化图层,处理图层,打包通过IPC发送给渲染引擎,转成OpenGL几何图形,这是一个图层大致的资源开销。事实上,一次能够在屏幕上显示的最大图层数量也是有限的。(一般几百上千个,取决于设备,图层内容等)

合理利用Core Graphics绘制

在图层数量影响性能的情况下,软件绘制很可能会提高性能,因为它避免了图层分配和操作问题(比如一个多个UILabel和UIImageView的复合视图,可以替换成一个单独的视图,用-drawRect:绘制出来)。不过这样绘制虽然快,但是使用UIView实例更为简单,快捷。
使用CALayer的-renderInContext:方法,可以将图层及其子图层快照进一个Core Graphics上下文然后得到一张图片。不同于shouldRasterize,这个方法没有持续的性能消耗,相对于让Core Animation处理一个复杂的图层树,可以节省可观的性能。

你可能感兴趣的:(【iOS小结】Core Animation(下)─── 性能优化)