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

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

图片的加载

在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》

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

推荐阅读更多精彩内容