一款轻量级的 iOS 图像缓存

jopen 10年前

我们的 iOS 应用都包含了大量的图像。创建富有吸引力的视图,主要依赖于大量的装饰图片,所有这些首先必须从远程服务器获取。如果每次打开应用都要从服务器一次又一次的获取每个图像,那么用户体验肯定达不到好的效果,所以本地缓存远程图像是非常有必要的。

版本1-寻找一张图片,并从磁盘上读取它

我们的第一个图片缓存是简单但是有效的。从缓存中寻找每一个我们曾经访问过的图片,用远程的URL作为缓存的键值。如果本地的磁盘缓存时有效的,从磁盘中读取文件并创建UIImage,并立即返回。如果再磁盘上没有找到文件,异步的从远程URL获取文件,缓存到磁盘,然后返回一个新建的UIImage。

目前这对于我们的使用是完全满足了。但是它有一个不必要的弱点:每次缓存请求都需要从磁盘读取图片,性能都消耗在对磁盘的访问和图片文件的解码上了。

版本 2 - 內存缓存

谢天谢地,苹果的UIImage有内置的内存缓存。所以,你只需修改一行代码,图片就能从磁盘缓存改为内存缓存。

当你使用imageNamed:获取UIImage的时候,它第一步就是检查自己的内存看看是否已经加载过这个图片。如果这样,你不用任何系统花销就能得到一个UIImage实例。所以把原本这样的写法替换掉:

return [UIImage imageWithContentsOfFile:[self absolutePathForURL:url]];

我们可以零花销去访问内存缓存,用下面的代码就可以了:

return [UIImage imageNamed:[self relativePathForURL:url]];

UIImage  会查找它的内存缓存,如果找到,就会零消耗地返回这个照片. 如果没有找到,这张照片就会从磁盘加载,消耗一定的系统性能。

版本3 —— 获取队列、数据预取和变量催促

当改进应用程序设计时我们渴望更多的图片元素,渴望更炫的画面,更大的图片,更多的图片。

让这些大图尽可能快的显示在屏幕上对用户体验来说是至关重要的,而只是简单地在每次需要显示的时候去缓存当中抓取图片数据是不能够解决问题的。大图需要更多的时间才能从网络上加载回来,并且一次请求太多的图片会导致所有图片都来不及加载。需要仔细考虑什么时候去检查缓冲当中有无图片数据以及什么时候从网络上抓取图片。我们需要预缓存和抓取列阵。

快速队列和慢速队列

我们设置了两个队列,一个串行,一个并行。在屏幕上被迫切要求的图片进入并行队列(fastQueue),可能晚点才需要的图片进入串行队列(slowQueue)。

就UITableView的实现而言,这意味着在屏幕上的表格单元从fastQueue获取图片, 每个关闭的屏幕行的图片从slowQueue预加载。

现在不需要处理图片

假设我们要从服务器上请求包含30条事件的一页资讯回来,一旦这些内容请求回来时我们就可以排队等待预取其中的每一张图。

- (void)pageLoaded:(NSArray *)newEvents {          for (SGEvent *event in newEvents) {                 [SGImageCache slowGetImageForURL:event.imageURL thenDo:nil];          }  }

slowGetImageForURL:这个方法将图片添加到slowQueue这个队列当中,允许它们在不阻塞网络通信的前提下被一张一张的取出来。

thenDo:这个代码块在这里是没有被实现,是因为我们目前还不需要对图片做任何事情。所有我们需要做的就是确保它们在本地磁盘缓存当中,并且随时准备在屏幕上滑动表格时来使用。

现在就要处理图片

显示在屏幕上的表格希望立即显示它们的图片,所以在table cell子类当中实现:

- (void)setEvent:(SGEvent *)event {          __weak SGEventCell *me = self;          [SGImageCache getImageForURL:event.imageURL thenDo:^(UIImage *image) {                 me.imageView.image = image;    }      ];  }

getImageForURL:这个方法将抓取图片的过程添加到fastQueue这个队列当中,意味着只要iOS系统允许,它们会并行被地执行。如果抓取图片的过程已经存在于slowQueue队列当中,它会被移动到fastQueue队列中,从而避免重复请求。

一直异步

等等,getImageForURL:不是一个异步方法吗?如果你明知道图片已经在缓存中,但是却不想在主线程上立即使用它吗?直觉告诉你那是错误的。

从磁盘上加载图片太费资源,同样解压图片也会费很多资源。可以在滑动的过程当中进行配置和添加表格,这最后一件你想在滑动表格时做的事是很危险地,因为它会阻塞主线程,会有卡顿的现象出现。

使用getImageForURL:可以让磁盘加载的动作脱离主线程,于是当thenDo:这个用于收尾工作的代码块执行的时候它已经有了一个UIImage实例,从而不会有滑动卡顿的危险。如果图片已经存在于本地缓存当中,用于收尾工作的代码块会在下一次运行周期执行,并且用户不会注意到两者之间的差别。他们会注意到的是滑动不会卡顿了。

现在,不需要你快速执行

如果用户很快的滑动表格到底部,几十或几百个表格单元会出现在屏幕上,并向fastQueue请求图片数据,然后很快地从屏幕上消失。突然间这个并行地队列会将大量实际上不再需要的图片请求充斥进网络。当用户最终停止滑动时,那些当前屏幕上相应的表格单元视图会将它们的图片请求至于那些并不急需的请求后面,因此网络阻塞了。

这就是 wheremoveTaskToSlowQueueForURL:这个方法的产生的原因.

// a table cell is going off screen-   (void)tableView:(UITableView *)table          didEndDisplayingCell:(UITableViewCell *)cell          forRowAtIndexPath:(NSIndexPath*)indexPath {           // we don't need it right now, so move it to the slow queue                    [SGImageCache moveTaskToSlowQueueForURL:[[(id)cell event] imageURL]];  }

这确保在fastQueue中的只会有真正需要被快速执行的任务。任何以前认为需要快速执行但现在不需要的任务会被移至slowQueue中。

重点和选择

已经有相当多的iOS图片缓存库。它们中一些库只针对某些应用场景,一些库提供了不同场景一定的可扩展性。我们的库即没有专门针对某些应用场景,也没有太多大而全的特性。针对我们的用户我们有三类基本的重点:

重点 1: 最好的帧率

很多的库都非常专注在这一点上,使用一些高度定制和复杂的方法,尽管基准没有决定性地显示这样有效。我们发现最好的帧率由这些决定:

  1. 将对磁盘的访问(并且几乎其它的所有)脱离主线程。

  2. 使用UIImage的内存缓存来避免不必要的磁盘访问和图片解压。

重点 2: 让最最重要的图片优先显示

大多数的库都考虑让队列管理成为别人关心的事。对于我们的应用,这几乎是最重要的点。

让正确的图片在正确的时间显示在屏幕上可以归结为一个简单的问题:“我们现在就需要它显示还是过一会儿?”。那些需要立即显示的图片是并行加载地,而其它所有东西都被添加到串行队列中。所有之前急迫的事但现在不急迫的话就会从fastQueue分到slowQueue中。并且当fastQueue在工作时,slowQueue是处于挂起状态的。

这让那些急需显示的图片可以单独访问网络,同时也确保了一张非急需显示的图片可以在过一会成为一张急需显示的图片,因为它已经存到了缓存当中,随时准备用于显示。

重点 3: 尽可能简单的API

大多数库都做到了这一点。许多库为了隐藏细节内容而提供了UIImageView的分类,并且许多库让抓取一张图片的流程变得尽可能的便利。针对我们经常做的三件事,我们的库选定了三个主要的方法:

快速抓到一张图

__weak SGEventCell *me = self;[SGImageCache getImageForURL:event.imageURL thenDo:^(UIImage *image) {    me.imageView.image = image;}];

排队等待一张我们一会才需要的图片

[SGImageCache slowGetImageForURL:event.imageURL thenDo:nil];

通知缓存一张急需显示的图已经不需要立刻显示

[SGImageCache moveTaskToSlowQueueForURL:event.imageURL];

结论

通过专注于预取,队列管理,从主线程移除耗时的任务,并且依赖于UIImage内置的内存缓存,我们努力从一个简单的软件包中得到好的结果。

一些有用的链接