iOS 杂谈技术

关于iOS中图像显示的一些优化处理

2016-11-21  本文已影响542人  37b32340bcbc

这篇文章是博主学习了几位大牛的关于图片处理和显示的文章后,结合自己的总结和实践后的记录。几位大牛的文章链接会在后面参上。因为水平有限,可能会有错误。

图片的加载

在iOS中从磁盘读取一张图片并显示在屏幕上,大概需要下面几个步骤

  1. 从磁盘拷贝数据到内核缓冲区
  1. 从内核缓冲区复制数据到用户空间
  2. 生成UIImageView,把图像数据赋值给UIImageView
  3. 如果图像数据为未解码的PNG/JPG,解码为位图数据
  4. CATransaction捕获到UIImageView layer树的变化
  5. 主线程Runloop提交CATransaction,开始进行图像渲染
    6.1. 果数据没有字节对齐,Core Animation会再拷贝一份数据,进行字节对齐。
    6.2. GPU处理位图数据,进行渲染。

简单理解上面几部的话大概就是把图片从磁盘读入内存,然后解码,然后渲染。其中读入内存的操作可以由子线程执行,解码和渲染的过程系统默认是一定要在主线程执行的。这也是很多时候显示图片发生卡顿的原因。不过在开发中多数情况下我们都是使用图片处理库来处理图片,这些库已经解决了主线程解码的问题(将解码操作放在子线程)。渲染是一定要在主线程的,这个不需要解释了。

关于卡顿的产生

说到卡顿的产生就不得不提到图像的显示原理,关于这部分内容在ibireme大神写的文章中有很详细的讲解,这里我只简单描述下(凑个字)。

在计算机系统中显示器要想显示画面首先需要CPU计算好显示内容,然后将计算好的结果交给GPU进行渲染,在双缓存+垂直同步机制下(iOS始终是双缓存+垂直同步)GPU会等到显示器发送垂直同步信号(VSync)后将渲染后的结果更新到帧缓冲区等待视频控制器读取数据。

解释了图像显示原理后再描述产生卡顿的原因就很好理解了,iOS设备的屏幕大概每秒刷新60次(这个值取决设备硬件,比如 iPhone 真机上通常是 59.97),也就是在这1/60秒内要完成CPU执行计算,GPU执行渲染变换等等然后提交帧缓存这些操作,等待下一次VSync信号到来后把结果显示在屏幕上。

如果在1/60秒内这些操作没有执行完成呢?这踏马就尴尬了。那么这一帧将会被丢弃,等待下一次VSync信号。体现在屏幕上就是界面什么都没做,还是显示上一帧的内容。这在肉眼看来就是卡了。

我尝试着在工程里存入了几张平均大小在1.5m的png图片,然后把这些图片放在tableView里面以每个cell一张图片的方式显示。在这里还需要提一下系统加载jpeg图片时速度会比png图片要快,但是因为XCode会在引入png图片时对png图片进行解码优化,所以解码操作上png要比jpeg更快,因为jpeg图片的解压算法更复杂。主要代码如下:

#define  Width  [UIScreen mainScreen].bounds.size.width
#define  Height  [UIScreen mainScreen].bounds.size.height
@interface TableViewController ()<UITableViewDelegate, UITableViewDataSource>
@property (nonatomic, strong) UITableView *table;
@property (nonatomic, strong) NSMutableArray *dataArr;
@property (nonatomic, strong) UIScrollView *scroll;
@end

@implementation TableViewController

- (void)viewDidLoad {
  [super viewDidLoad];
  self.view.backgroundColor = [UIColor whiteColor];

  self.dataArr = [NSMutableArray arrayWithCapacity:0];
  for (NSInteger i = 1; i < 6; i++) {
      NSString *path = [[NSBundle mainBundle] pathForResource:[NSString stringWithFormat:@"b%ld", i] ofType:@"png"];
      [self.dataArr addObject:path];
  }
  [self.view addSubview:self.table];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
  static NSString *identifier = @"cell";
  const NSInteger imageTag = 99;
  UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
  if (!cell) {
      cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:identifier];
  }

  UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
  if (!imageView) {
      imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, Width, Height)];
      imageView.tag = imageTag;
      [cell.contentView addSubview:imageView];
  }
  cell.tag = indexPath.row;


  NSString *imagePath = self.dataArr[indexPath.row];
  imageView.image = [UIImage imageWithContentsOfFile:imagePath];
  return cell;
}

运行这段代码发现已经发生了很严重的卡顿,原因在于这里将图片的加载,解码和渲染操作全部放在了主线程。在1/60秒内根本无法完成这些操作。所以需要将任务分散来缓解主线程压力。至于为什么使用imageWithContentsOfFile:方法而不是imageNamed:,为了演示效果,我不希望系统将解码后的图片进行缓存所以没有使用imageNamed方法。

关于UIImage的这两个方法,他们的相同点是都只是将数据读入内存而不进行解码,只有当图片将要显示之前才会被解码(事实上UIImage的几个创建方法都是这样的)。不同点是imageNamed:方法会在第一次解码显示之后将解码后的位图进行全局缓存,只有在程序退入后台或者接收到内存警告时才会将位图释放。这也是为什么在第一次滑动tableVIew的时候会卡,之后再反复滑动就不会卡顿的原因。imageWithContentsOfFile:方法虽然在64位设备是默认也会缓存(缓存到CGImage内部),但是一旦图片被释放,缓存的数据也会被释放。

imageWithContentsOfFile:这个方法的底层实现是调用了ImageIO框架的CGImageSourceCreateWithData()方法,该方法在有一个ShouldCache参数,64位设备上参数值默认是YES。(这句话99.9%抄袭自ibireme的文章)。

当我在看《iOS Core Animation: Advanced Techniques》这本书的时候,书中提到可以使用CGImageSourceCreateWithURL()方法生成image来避免延时解码,不知道是我看的这版年代太久还是理解有误。使用这个方法实质上和CGImageSourceCreateWithData()没有什么区别,都达不到解码的效果。并且在实际的代码中测试发现确实没有解决延时解码的问题。测试代码如下:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
static NSString *identifier = @"cell";
const NSInteger imageTag = 99;
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
if (!cell) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:identifier];
}

  UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
    if (!imageView) {
      imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, Width, Height)];
      imageView.tag = imageTag;
      [cell.contentView addSubview:imageView];
  }
  cell.tag = indexPath.row;
  cell.tag = indexPath.row;
  imageView.image = nil;
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
      NSInteger index = indexPath.row;
      NSURL *imageURL = [NSURL fileURLWithPath:self.dataArr[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);
      dispatch_async(dispatch_get_main_queue(), ^{
          if (index == cell.tag) {
              imageView.image = image;
          }
      });
  });
  return cell;
}

将代码修改为这样后卡顿依然存在。

这里可以简单实现一下异步解码的操作,将cellForRow方法里面的部分代码改成这样:

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
    NSInteger index = indexPath.row;
    NSString *imagePath = self.dataArr[index];
    UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
    //redraw image using device context
    UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 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;
        }
    });
});

将图片绘制到画布上,然后从画布取出图片。这样做的好处是图像的绘制完全可以放在后台执行,我们只需在绘制完成后取出图片,然后在主线程上赋值给UIImageView就可以,这个时候的图片因为是程序绘制的就已经不再是jpg或者png或者其他格式了,所以自然也不需要解码操作。这只是简单地实现,具体的可是学习YYWebImage或者SDWebImageDecoder。

缓存和异步解码只是缓解CPU压力的方法之一,除此之外还有很多地方可以优化CPU和GPU资源,比如之前提到的对于圆角和阴影的处理。

另外还有一种比较有意思的方式用来显示很大的图片,就是使用CATiledLayer,在iOS6以前系统的地图就是使用它来实现的。这个类的出现也是为了解决加载大图造成的性能问题,它会将一张大图分解成多张小图碎片,然后分开显示。关于CATiledLayer的使用我也是从《iOS Core Animation: Advanced Techniques》这本书中看到的,书中给了一个例子,将一张20482048的图片分割成64张小图,然后将CATiledLayer添加在一个大小是256 * 256的UIScrollView上,contentSize为20482048,开始的时候显示第一片图片,然后根据手势的滑动方向以及当前的位置,CATiledLayer的代理方法- (void)drawLayer: inContext: 会加载相应的图片碎片,就像是地图应用中地图会一块一块的加载出来一样。

这种方法也可以使用到前面的例子当中,我们把代码改成这样:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
  static NSString *identifier = @"cell";
  const NSInteger imageTag = 99;
  UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
  if (!cell) {
      cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:identifier];
  }

  UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
  if (!imageView) {
      imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, Width, Height)];
      imageView.tag = imageTag;
      [cell.contentView addSubview:imageView];
  }
  cell.tag = indexPath.row;

  CATiledLayer *tileLayer = (CATiledLayer *)[cell.contentView.layer.sublayers lastObject];
  if (!tileLayer) {
      tileLayer = [CATiledLayer layer];
      tileLayer.frame = CGRectMake(0, 0, Width, Height);
      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];
  }
  tileLayer.contents = nil;
  [tileLayer setValue:@(indexPath.row) forKey:@"index"];
  [tileLayer setNeedsDisplay];

return cell;
}

可以看到当滑动屏幕的时候图片的显示会呈现碎片式的淡入淡出效果。

参考的文章:
iOS图片加载速度极限优化—FastImageCache解析
iOS 处理图片的一些小 Tip
iOS 保持界面流畅的技巧
《iOS Core Animation: Advanced Techniques》

上一篇下一篇

猜你喜欢

热点阅读