iOS核心动画高级技巧十二(图像IO)

目录
  • 加载和潜伏
  • 缓存
  • 文件格式
  • 总结
一 加载和潜伏

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

图片文件加载的速度被CPU和IO(输入/输出)同时影响。iOS设备中的闪存已经比传统硬盘快很多了,但仍然比RAM慢将近200倍左右,这就需要很小心地管理加载,来避免延迟。

只要有可能,试着在程序生命周期不易察觉的时候来加载图片,例如启动,或者在屏幕切换的过程中。按下按钮和按钮响应事件之间最大的延迟大概是200ms,这比动画每一帧切换的16ms小得多。你可以在程序首次启动的时候加载图片,但是如果20秒内无法启动程序的话,iOS检测计时器就会终止你的应用(而且如果启动大于2,3秒的话用户就会抱怨了)。

有些时候,提前加载所有的东西并不明智。比如说包含上千张图片的图片传送带:用户希望能够能够平滑快速翻动图片,所以就不可能提前预加载所有图片;那样会消耗太多的时间和内存。
有时候图片也需要从远程网络连接中下载,这将会比从磁盘加载要消耗更多的时间,甚至可能由于连接问题而加载失败(在几秒钟尝试之后)。你不能够在主线程中加载网络造成等待,所以需要后台线程。
1.1 线程加载

在前面性能调优我们的联系人列表例子中,图片都非常小,所以可以在主线程同步加载。但是对于大图来说,这样做就不太合适了,因为加载会消耗很长时间,造成滑动的不流畅。滑动动画会在主线程的run loop中更新,所以会有更多运行在渲染服务进程中CPU相关的性能问题。

下面显示了一个通过UICollectionView实现的基础的图片传送器。图片在主线程中-collectionView:cellForItemAtIndexPath:方法中同步加载。

static CGFloat kMagin = 10.f;

@interface ViewController ()<UICollectionViewDataSource, UICollectionViewDelegate>
@property (nonatomic, copy) NSArray *imagePaths;
@property (nonatomic, strong) UICollectionView *collectionView;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
    NSString *imgPath1 = [[NSBundle mainBundle] pathForResource:@"0" ofType:@"jpg"];
    NSString *imgPath3 = [[NSBundle mainBundle] pathForResource:@"2" ofType:@"jpg"];
    NSString *imgPath4 = [[NSBundle mainBundle] pathForResource:@"3" ofType:@"jpg"];
    NSString *imgPath5 = [[NSBundle mainBundle] pathForResource:@"4" ofType:@"jpeg"];
    NSString *imgPath7 = [[NSBundle mainBundle] pathForResource:@"6" ofType:@"jpeg"];
    self.imagePaths = @[imgPath1, imgPath3, imgPath4, imgPath5, imgPath7];
    
    [self.view addSubview:self.collectionView];
}

#pragma mark - UICollectionViewDataSource

- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
    return 1;
}

- (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 *imgView = (UIImageView *)[cell viewWithTag:imageTag];
    if (!imgView) {
        imgView = [[UIImageView alloc] initWithFrame: cell.contentView.bounds];
        imgView.tag = imageTag;
        [cell.contentView addSubview:imgView];
    }
    
    // set image
    NSString *imgPath = self.imagePaths[indexPath.row];
    imgView.image = [UIImage imageWithContentsOfFile:imgPath];
    return cell;
}

#pragma mark - lazy

- (UICollectionView *)collectionView {
    if (!_collectionView) {
        UICollectionViewFlowLayout *flowLayout = [[UICollectionViewFlowLayout alloc] init];
        CGFloat itemWidth = (self.view.frame.size.width - 2 * kMagin);
        //设置单元格大小
        flowLayout.itemSize = CGSizeMake(itemWidth, itemWidth);
        //最小行间距(默认为10)
        flowLayout.minimumLineSpacing = 10;
        //最小item间距(默认为10)
        flowLayout.minimumInteritemSpacing = 10;
        //设置senction的内边距
        flowLayout.sectionInset = UIEdgeInsetsMake(kMagin, kMagin, kMagin, kMagin);
        //设置UICollectionView的滑动方向
        flowLayout.scrollDirection = UICollectionViewScrollDirectionHorizontal;
        //sectionHeader的大小,如果是竖向滚动,只需设置Y值。如果是横向,只需设置X值。
        flowLayout.headerReferenceSize = CGSizeMake(kMagin,0);
        _collectionView = [[UICollectionView alloc] initWithFrame:self.view.bounds collectionViewLayout:flowLayout];
        [_collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"cell"];
        _collectionView.dataSource = self;
        _collectionView.delegate = self;
    }
    return _collectionView;
}
  • 运行结果如下
运行中的图片传送器.gif

传送器中的图片尺寸为800x600像素的PNG,对iPhone5来说,1/60秒要加载大概700KB左右的图片。当传送器滚动的时候,图片也在实时加载,于是(预期中的)卡动就发生了。时间分析工具显示了很多时间都消耗在了UIImage的+imageWithContentsOfFile:方法中了。很明显,图片加载造成了瓶颈。

时间分析工具展示了CPU瓶颈.png

这里提升性能唯一的方式就是在另一个线程中加载图片。这并不能够降低实际的加载时间(可能情况会更糟,因为系统可能要消耗CPU时间来处理加载的图片数据),但是主线程能够有时间做一些别的事情,比如响应用户输入,以及滑动动画。

为了在后台线程加载图片,我们可以使用GCD或者NSOperationQueue创建自定义线程,或者使用CATiledLayer。为了从远程网络加载图片,我们可以使用异步的NSURLConnection,但是对本地存储的图片,并不十分有效。

1.2 GCD和NSOperationQueue

GCD(Grand Central Dispatch)和NSOperationQueue很类似,都给我们提供了队列闭包块来在线程中按一定顺序来执行。NSOperationQueue有一个Objecive-C接口(而不是使用GCD的全局C函数),同样在操作优先级和依赖关系上提供了很好的粒度控制,但是需要更多地设置代码。

下面显示了在低优先级的后台队列而不是主线程使用GCD加载图片的-collectionView:cellForItemAtIndexPath:方法,然后当需要加载图片到视图的时候切换到主线程,因为在后台线程访问视图会有安全隐患。

由于视图在UICollectionView会被循环利用,我们加载图片的时候不能确定是否被不同的索引重新复用。为了避免图片加载到错误的视图中,我们在加载前把单元格打上索引的标签,然后在设置图片的时候检测标签是否发生了改变。

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    // dequeue cell
    UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"cell" forIndexPath:indexPath];
    
    // add image view
    const NSInteger imageTag = 99;
    UIImageView *imgView = (UIImageView *)[cell viewWithTag:imageTag];
    if (!imgView) {
        imgView = [[UIImageView alloc] initWithFrame: cell.contentView.bounds];
        imgView.tag = imageTag;
        [cell.contentView addSubview:imgView];
    }
    
    // 使用GCD加载传送图片
    // tag cell with index and clear current image
    cell.tag = indexPath.row;
    imgView.image = nil;
    
    // switch to background thread
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
        // load image
        NSInteger index = indexPath.row;
        NSString *imgPath = self.imagePaths[index];
        UIImage *img = [UIImage imageWithContentsOfFile:imgPath];
        // set image on main thread, but only if index still matches up
        dispatch_async(dispatch_get_main_queue(), ^{
            if (index == cell.tag) {
                imgView.image = img;
            }
        });
    });
    return cell;
}

当运行更新后的版本,性能比之前不用线程的版本好多了,但仍然并不完美。

我们可以看到+imageWithContentsOfFile:方法并不在CPU时间轨迹的最顶部,所以我们的确修复了延迟加载的问题。问题在于我们假设传送器的性能瓶颈在于图片文件的加载,但实际上并不是这样。加载图片数据到内存中只是问题的第一部分。

1.3 延迟解压

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

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

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

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

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

第三种方式就是绕过UIKit,像下面这样使用ImageIO框架:
// 第三种方式就是绕过UIKit,像下面这样使用ImageIO框架
NSInteger index  = indexPath.row;
NSURL *imgUrl = [NSURL fileURLWithPath:self.imagePaths[index]];
NSDictionary *options = @{(__bridge id)kCGImageSourceShouldCache : @(YES)};
CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)imgUrl, NULL);
CGImageRef imgRef = CGImageSourceCreateImageAtIndex(source, 0, (__bridge CFDictionaryRef)options);
UIImage *img = [UIImage imageWithCGImage:imgRef];
CGImageRelease(imgRef);
CFRelease(source);

这样就可以使用kCGImageSourceShouldCache来创建图片,强制图片立刻解压,然后在图片的生命周期保留解压后的版本。

最后一种方式就是使用UIKit加载图片,但是立刻会知道CGContext中去。图片必须要在绘制之前解压,所以就强制了解压的及时性。这样的好处在于绘制图片可以再后台线程(例如加载本身)执行,而不会阻塞UI。

有两种方式可以为强制解压提前渲染图片:
  • 将图片的一个像素绘制成一个像素大小的CGContext。这样仍然会解压整张图片,但是绘制本身并没有消耗任何时间。这样的好处在于加载的图片并不会在特定的设备上为绘制做优化,所以可以在任何时间点绘制出来。同样iOS也就可以丢弃解压后的图片来节省内存了。

  • 将整张图片绘制到CGContext中,丢弃原始的图片,并且用一个从上下文内容中新的图片来代替。这样比绘制单一像素那样需要更加复杂的计算,但是因此产生的图片将会为绘制做优化,而且由于原始压缩图片被抛弃了,iOS就不能够随时丢弃任何解压后的图片来节省内存了。

需要注意的是苹果特别推荐了不要使用这些诡计来绕过标准图片解压逻辑(所以也是他们选择用默认处理方式的原因),但是如果你使用很多大图来构建应用,那如果想提升性能,就只能和系统博弈了。

如果不使用+imageNamed:,那么把整张图片绘制到CGContext可能是最佳的方式了。尽管你可能认为多余的绘制相较别的解压技术而言性能不是很高,但是新创建的图片(在特定的设备上做过优化)可能比原始图片绘制的更快。

同样,如果想显示图片到比原始尺寸小的容器中,那么一次性在后台线程重新绘制到正确的尺寸会比每次显示的时候都做缩放会更有效(尽管在这个例子中我们加载的图片呈现正确的尺寸,所以不需要多余的优化)。

如果修改了-collectionView:cellForItemAtIndexPath:方法来重绘图片,你会发现滑动更加平滑。

  • 强制图片解压显示
// 第三种 强制图片解压显示
// switch to background thread
CGRect bounds = imgView.bounds;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
    // load image
    NSInteger index = indexPath.row;
    NSString *imgPath = self.imagePaths[index];
    UIImage *img = [UIImage imageWithContentsOfFile:imgPath];
    
    // redraw img using device context
    UIGraphicsBeginImageContextWithOptions(bounds.size, YES, 0);
    [img drawInRect:bounds];
    img = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    
    // set image on main thread, buy only if index still matches up
    dispatch_async(dispatch_get_main_queue(), ^{
        if (index == cell.tag) {
            imgView.image = img;
        }
    });
});
return cell;
1.4 CATiledLayer

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

这样使用CATiledLayer有几个潜在的弊端:
  • CATiledLayer的队列和缓存算法没有暴露出来,所以我们只能祈祷它能匹配我们的需求

  • CATiledLayer需要我们每次重绘图片到CGContext中,即使它已经解压缩,而且和我们单元格尺寸一样(因此可以直接用作图层内容,而不需要重绘)。

我们来看看这些弊端有没有造成不同:下面显示了使用CATiledLayer对图片传送器的重新实现。

/// 第五种 使用CATiledLayer的图片传送器
- (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;
}

#pragma mark - CALayerDelegate

- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx {
    // get image index
    NSInteger index = [[layer valueForKey:@"index"] integerValue];
    
    // load tile image
    NSString *imgPath = self.imagePaths[index];
    UIImage *tileImg = [UIImage imageWithContentsOfFile:imgPath];
    
    // calculate img rect
    CGFloat aspectRatio = tileImg.size.height / tileImg.size.width;
    CGRect imgRect = CGRectZero;
    imgRect.size.width = layer.bounds.size.width;
    imgRect.size.height = layer.bounds.size.height * aspectRatio;
    imgRect.origin.y = (layer.bounds.size.height - imgRect.size.height) * 0.5;
    
    // draw tile
    UIGraphicsPushContext(ctx);
    [tileImg drawInRect:imgRect];
    UIGraphicsPopContext();
}

需要解释的几点:

  • CATiledLayer的tileSize属性单位是像素,而不是,所以为了保证瓦片和表格尺寸一致,需要乘以屏幕比例因子。

  • -drawLayer:inContext:方法中,我们需要知道图层属于哪一个indexPath以加载正确的图片。这里我们利用了CALayerKVC来存储和检索任意的值,将图层和索引打标签。

结果CATiledLayer工作的很好,性能问题解决了,而且和用GCD实现的代码量差不多。仅有一个问题在于图片加载到屏幕上后有一个明显的淡入。

加载图片之后的淡入.gif

我们可以调整CATiledLayerfadeDuration属性来调整淡入的速度,或者直接将整个渐变移除,但是这并没有根本性地去除问题:在图片加载到准备绘制的时候总会有一个延迟,这将会导致滑动时候新图片的跳入。这并不是CATiledLayer的问题,使用GCD的版本也有这个问题。

即使使用上述我们讨论的所有加载图片和缓存的技术,有时候仍然会发现实时加载大图还是有问题。就和前面提到的那样,iPad上一整个视网膜屏图片分辨率达到了2048x1536,而且会消耗12MB的RAM(未压缩)。第三代iPad的硬件并不能支持1/60秒的帧率加载,解压和显示这种图片。即使用后台线程加载来避免动画卡顿,仍然解决不了问题。

我们可以在加载的同时显示一个占位图片,但这并没有根本解决问题,我们可以做到更好。

1.4 分辨率交换

视网膜分辨率(根据苹果市场定义)代表了人的肉眼在正常视角距离能够分辨的最小像素尺寸。但是这只能应用于静态像素。当观察一个移动图片时,你的眼睛就会对细节不敏感,于是一个低分辨率的图片和视网膜质量的图片没什么区别了。

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

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

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

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

你可以使用这几个方法来检测传送器是否停止滚动,然后加载高分辨率的图片。只要高分辨率图片和低分辨率图片尺寸颜色保持一致,你会很难察觉到替换的过程(确保在同一台机器使用相同的图像程序或者脚本生成这些图片)。

二 缓存

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

缓存其实很简单:就是存储昂贵计算后的结果(或者是从闪存或者网络加载的文件)在内存中,以便后续使用,这样访问起来很快。问题在于缓存本质上是一个权衡过程 - 为了提升性能而消耗了内存,但是由于内存是一个非常宝贵的资源,所以不能把所有东西都做缓存。

何时将何物做缓存(做多久)并不总是很明显。幸运的是,大多情况下,iOS都为我们做好了图片的缓存。

+imageNamed:方法

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

对于iOS应用那些主要的图片(例如图标,按钮和背景图片),使用[UIImage imageNamed:]加载图片是最简单最有效的方式。在nib文件中引用的图片同样也是这个机制,所以你很多时候都在隐式的使用它。

但是[UIImage imageNamed:]并不适用任何情况。它为用户界面做了优化,但是并不是对应用程序需要显示的所有类型的图片都适用。有些时候你还是要实现自己的缓存机制,原因如下:

  • [UIImage imageNamed:]方法仅仅适用于在应用程序资源束目录下的图片,但是大多数应用的许多图片都要从网络或者是用户的相机中获取,所以[UIImage imageNamed:]就没法用了。

  • [UIImage imageNamed:]缓存用来存储应用界面的图片(按钮,背景等等)。如果对照片这种大图也用这种缓存,那么iOS系统就很可能会移除这些图片来节省内存。那么在切换页面时性能就会下降,因为这些图片都需要重新加载。对传送器的图片使用一个单独的缓存机制就可以把它和应用图片的生命周期解耦。

  • [UIImage imageNamed:]缓存机制并不是公开的,所以你不能很好地控制它。例如,你没法做到检测图片是否在加载之前就做了缓存,不能够设置缓存大小,当图片没用的时候也不能把它从缓存中移除。

2.1 自定义缓存

构建一个所谓的缓存系统非常困难。菲尔 卡尔顿曾经说过:“在计算机科学中只有两件难事:缓存和命名”。

如果要写自己的图片缓存的话,那该如何实现呢?让我们来看看要涉及哪些方面:

  • 选择一个合适的缓存键 - 缓存键用来做图片的唯一标识。如果实时创建图片,通常不太好生成一个字符串来区分别的图片。在我们的图片传送带例子中就很简单,我们可以用图片的文件名或者表格索引。

  • 提前缓存 - 如果生成和加载数据的代价很大,你可能想当第一次需要用到的时候再去加载和缓存。提前加载的逻辑是应用内在就有的,但是在我们的例子中,这也非常好实现,因为对于一个给定的位置和滚动方向,我们就可以精确地判断出哪一张图片将会出现。

  • 缓存失效 - 如果图片文件发生了变化,怎样才能通知到缓存更新呢?这是个非常困难的问题(就像菲尔 卡尔顿提到的),但是幸运的是当从程序资源加载静态图片的时候并不需要考虑这些。对用户提供的图片来说(可能会被修改或者覆盖),一个比较好的方式就是当图片缓存的时候打上一个时间戳以便当文件更新的时候作比较。

  • 缓存回收 - 当内存不够的时候,如何判断哪些缓存需要清空呢?这就需要到你写一个合适的算法了。幸运的是,对缓存回收的问题,苹果提供了一个叫做NSCache通用的解决方案

2.2 NSCache

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

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

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

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

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

  • 添加缓存
/// 第六种 添加cache缓存
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    // dequeue cell
    UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"cell" forIndexPath:indexPath];
    // add image view
    UIImageView *imgView = [cell.contentView.subviews lastObject];
    if (!imgView) {
        imgView = [[UIImageView alloc] initWithFrame:cell.contentView.bounds];
        imgView.contentMode = UIViewContentModeScaleAspectFit;
        [cell.contentView addSubview:imgView];
    }
    // set or load img for this index
    imgView.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;
}

- (UIImage *)loadImageAtIndex:(NSUInteger)index {
    // set up cache
    static NSCache *cache = nil;
    if (!cache) {
        cache = [[NSCache alloc] init];
    }
    
    // if already cached, return immediately
    UIImage *img = [cache objectForKey:@(index)];
    if (img) {
        return [img isKindOfClass:[NSNull class]] ? nil : img;
    }
    
    // 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 *imgPath = self.imagePaths[index];
        UIImage *img = [UIImage imageWithContentsOfFile:imgPath];
        
        // redraw image using device context
        UIGraphicsBeginImageContextWithOptions(img.size, YES, 0);
        [img drawAtPoint:CGPointZero];
        UIGraphicsEndImageContext();
        
        // set image for correct image view
        // cache the image
        dispatch_async(dispatch_get_main_queue(), ^{
            [cache setObject:img forKey:@(index)];
            // display the image
            NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:0];
            UICollectionViewCell *cell = [self.collectionView cellForItemAtIndexPath:indexPath];
            UIImageView *imgView = [cell.contentView.subviews lastObject];
            imgView.image = img;
        });
    });
    // not loaded yet
    return nil;
}

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

三 文件格式

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

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

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

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    // dequeue cell
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell"];
    if (!cell) {
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:@"cell"];
    }
    // set up cell
    cell.textLabel.text = [NSString stringWithFormat:@"%ld",(long)indexPath.row];
    cell.detailTextLabel.text = @"Loading...";
    // load image
    [self loadImageAtIndex:indexPath.row];
    
    return cell;
}

- (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:kImageFolder];
        NSString *jpgPath = [[NSBundle mainBundle] pathForResource:fileName ofType:@"jpg" inDirectory:kImageFolder];
        
        // 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];
        });
    });
}

- (CFTimeInterval)loadImageForOneSec:(NSString *)path {
    // create drawing context to use for decompression
    UIGraphicsBeginImageContext(CGSizeMake(1, 1));
    // start timing
    NSInteger imgLoaded = 0;
    CFTimeInterval endTime = 0;
    CFTimeInterval startTime = CFAbsoluteTimeGetCurrent();
    
    while (endTime - startTime < 1) {
        // load image
        UIImage *img = [UIImage imageWithContentsOfFile:path];
        // decompress image by drawing it
        [img drawAtPoint:CGPointZero];
        // update totals
        imgLoaded++;
        endTime = CFAbsoluteTimeGetCurrent();
    }
    // close context
    UIGraphicsEndImageContext();
    // calculate time per image
    return (endTime - startTime) / imgLoaded;
}

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

结论:

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

  • 所以对于之前的图片传送器程序来说,JPEG会是个不错的选择。如果用JPEG的话,一些多线程和缓存策略都没必要了。

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

3.1 混合图片

对于包含透明的图片来说,最好是使用压缩透明通道的PNG图片和压缩RGB部分的JPEG图片混合起来加载。这就对任何格式都适用了,而且无论从质量还是文件尺寸还是加载性能来说都和PNG和JPEG的图片相近。相关分别加载颜色和遮罩图片并在运行时合成的代码如下。

#pragma mark - 从PNG遮罩和JPEG创建的混合图片

- (void)mixColor {
    self.imgView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 200, 200)];
    self.imgView.center = self.view.center;
    [self.view addSubview:self.imgView];
    
    // load color image
    UIImage *img = [UIImage imageNamed:@"0.jpg"];
    // load mask image
    UIImage *mask = [UIImage imageNamed:@"1.png"];
    // convert mask to correct format
    CGColorSpaceRef graySpace = CGColorSpaceCreateDeviceGray();
    CGImageRef maskRef = CGImageCreateCopyWithColorSpace(mask.CGImage, graySpace);
    CGColorSpaceRelease(graySpace);
    // comine images
    CGImageRef resultRef = CGImageCreateWithMask(img.CGImage, maskRef);
    UIImage *result = [UIImage imageWithCGImage:resultRef];
    CGImageRelease(resultRef);
    CGImageRelease(maskRef);
    // display result
    self.imgView.image = result;
}

对每张图片都使用两个独立的文件确实有些累赘。JPNG的库(https://github.com/nicklockwood/JPNG)对这个技术提供了一个开源的可以复用的实现,并且添加了直接使用+imageNamed:+imageWithContentsOfFile:方法的支持。

3.2 JPEG 2000

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

但是iOS之后,苹果低调添加了对JPEG 2000图片格式的支持,所以大多数人并不知道。它甚至并不被Xcode很好的支持 - JPEG 2000图片都没在Interface Builder中显示。

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

但仍然要对JPEG 2000保持关注,因为在后续iOS版本说不定就对它的性能做提升,但是在现阶段,混合图片对更小尺寸和质量的文件性能会更好。

3.3 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设备上最好不要实时生成。

如果你愿意使用OpenGL,而且即使提前生成图片也能忍受得了,那么PVRTC将会提供相对于别的可用格式来说非常高效的加载性能。比如,可以在主线程1/60秒之内加载并显示一张2048×2048的PVRTC图片(这已经足够大来填充一个视网膜屏幕的iPad了),这就避免了很多使用线程或者缓存等等复杂的技术难度。

Xcode包含了一些命令行工具例如texturetool来生成PVRTC图片,但是用起来很不方便(它存在于Xcode应用程序束中),而且很受限制。一个更好的方案就是使用Imagination Technologies *PVRTexTool*,可以从 https://www.imgtec.com/developers/powervr-sdk-tools/installers/ 处免费获得

安装了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

下面的代码展示了加载和显示PVRTC图片的步骤(CAEAGLLayer例子代码改动而来)

//
//  GLViewController.m
//  AnimationIO_12
//
//  Created by chenshuang on 2019/11/9.
//  Copyright © 2019 chenshuang. All rights reserved.
//

#import "GLViewController.h"
#import <GLKit/GLKit.h>

/// 加载和显示PVRTC图片
@interface GLViewController ()

@property (nonatomic, strong) 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 GLViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    [self drawUI];
}

- (void)viewDidUnload {
    [self tearDownBuffers];
    [super viewDidUnload];
}

- (void)dealloc {
    [self tearDownBuffers];
    [EAGLContext setCurrentContext:nil];
}

- (void)setupBuffers {
    // setup frame buffer
    glGenFramebuffers(1, &_framebuffer);
    glBindFramebuffer(GL_FRAMEBUFFER, _framebuffer);
    
    // setup 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
        glDeleteBuffers(1, &_colorRenderbuffer);
        _colorRenderbuffer = 0;
    }
}

- (void)drawFrame {
    // bind framebuffer & set viewport
    glBindBuffer(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);
    
    // setup vertices
    GLfloat vertices[] = {
        -1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f, 1.0f, -1.0f
    };
    // setup 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);
    // persent render buffer
    glBindRenderbuffer(GL_RENDERBUFFER, _colorRenderbuffer);
    [self.glContext presentRenderbuffer:GL_RENDERBUFFER];
}

- (void)drawUI {
    self.glView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 300, 300)];
    self.glView.center = self.view.center;
    [self.view addSubview:self.glView];
    
    // setup context
    self.glContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
    [EAGLContext setCurrentContext:self.glContext];
    
    // setup 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 *imgFile = [[NSBundle mainBundle] pathForResource:@"Snowman" ofType:@"pvr"];
    self.textureInfo = [GLKTextureLoader textureWithContentsOfFile:imgFile options:nil error:NULL];
    
    //create texture
    GLKEffectPropertyTexture *texture = [[GLKEffectPropertyTexture alloc] init];
    texture.enabled = YES;
    texture.envMode = GLKTextureEnvModeDecal;
    texture.name = self.textureInfo.name;
    
    // setup base effect
    self.effect = [[GLKBaseEffect alloc] init];
    self.effect.texture2d0.name = texture.name;
    
    // setup buffers
    [self setupBuffers];
    
    // draw frame
    [self drawFrame];
}

@end

如你所见,非常不容易,如果你对在常规应用中使用PVRTC图片很感兴趣的话(例如基于OpenGL的游戏),可以参考一下GLView的库(https://github.com/nicklockwood/GLView),它提供了一个简单的GLImageView类,重新实现了UIImageView的各种功能,但同时提供了PVRTC图片,而不需要你写任何OpenGL代码。


本文摘自iOS核心动画高级技巧 - 图像IO


项目链接地址 - AnimationIO_12

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容