天天都在用的 SDWebImage, 你了解它的缓存策略吗?

BerHakala 8年前
   <p>SDWebImage 相信对大多数开发者来说,都是一个不陌生的名字。它除了帮助我们读取网络图片,还会处理这些图片的缓存。它的缓存机制到底是什么样的呢,让我给跟大家唠叨唠叨,希望你能有收获。</p>    <h2>基本结构</h2>    <p>闲言少叙,咱们这就开始。 首先咱们来看看 SDWebImage 的整体结构:</p>    <p><img src="https://simg.open-open.com/show/eed21c7346b3b0eadc2e9548112cb434.png"></p>    <p>有一个专门的 Cache 分类用来处理图片的缓存。 这里面也有两个类 SDImageCache 和 SDImageCacheConfig。 大部分的缓存处理都在 SDImageCache 这个类中实现。其他几个文件夹咱们分别有个字的功能,因为咱们这次专门讨论缓存策略,所以其他内容暂时略过。</p>    <h2>Memory 和 Disk 双缓存</h2>    <p>首先,SDWebImage 的图片缓存采用的是 Memory 和 Disk 双重 Cache 机制, 听起来挺高大上吧。其实也不复杂。</p>    <p>我们先来看看 Memory Cache,贴一段 SDImageCache 的代码:</p>    <pre>  <code class="language-objectivec">@interfaceSDImageCache()    #pragma mark - Properties  @property (strong, nonatomic, nonnull) NSCache *memCache;    ...  </code></pre>    <p>这里我们发现, 有一个叫做 memCache 的属性,它是一个 NSCache 对象,用于实现我们对图片的 Memory Cache。 SDWebImage 还专门实现了一个叫做 AutoPurgeCache 的类, 相比于普通的 NSCache, 它提供了一个在内存紧张时候释放缓存的能力:</p>    <pre>  <code class="language-objectivec">@interfaceAutoPurgeCache:NSCache  @end    @implementationAutoPurgeCache    - (nonnull instancetype)init {      self = [super init];      if (self) {  #if SD_UIKIT          [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(removeAllObjects) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];  #endif      }      return self;  }  </code></pre>    <p>其实就是接受系统的内存警告通知,然后清除掉自身的图片缓存。 这里大家比较少见的一个类应该是 NSCache 了。 简单来说,它是一个类似于 NSDictionary 的集合类,用于在内存中存储我们要缓存的数据。详细信息大家可以参考官方文档: <a href="/misc/goto?guid=4959738133569837705" rel="nofollow,noindex">https://developer.apple.com/reference/foundation/nscache</a> 。</p>    <p>说完 Memory Cache, 我们再来说说 Disk Cache,也就是文件缓存。</p>    <p>SDWebImage 会将图片存放到 NSCachesDirectory 目录中:</p>    <pre>  <code class="language-objectivec">- (nullable NSString *)makeDiskCachePath:(nonnull NSString*)fullNamespace {      NSArray<NSString *> *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);      return [paths[0] stringByAppendingPathComponent:fullNamespace];  }  </code></pre>    <p>然后为每一个缓存文件生成一个 md5 文件名, 存放到文件中。</p>    <h2>整体机制</h2>    <p>为了节约篇幅,提升大家的阅读体验,这里尽量少贴大段代码。 我们前面介绍了 SDWebImage 同时使用内存和硬盘两种缓存。 那么我们来看看当使用 SDWebImage 读取图片时候的完整流程。 我们一般会使用 SDWebImage 对 UIKit 的扩展,直接加载图片:</p>    <pre>  <code class="language-objectivec">[imageView sd_setImageWithURL:[NSURL URLWithString:@"http://swiftcafe.io/images/qrcode.jpg"]];  </code></pre>    <p>首先这个 Category 方法 sd_setImageWithURL 内部会调用 SDWebImageManager 的 downloadImageWithURL 方法来处理这个图片 URL:</p>    <pre>  <code class="language-objectivec">id <SDWebImageOperation> operation = [SDWebImageManager.sharedManager downloadImageWithURL:url options:options progress:progressBlock completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) {              ...  }];  </code></pre>    <p>SDWebImageManager 内部的 downloadImageWithURL 方法会先使用我们前面提到的 SDImageCache 类的 queryDiskCacheForKey 方法,查询图片缓存:</p>    <pre>  <code class="language-objectivec">operation.cacheOperation = [self.imageCache queryDiskCacheForKey:key done:^(UIImage *image, SDImageCacheType cacheType) {     ...  }];  </code></pre>    <p>再来看 queryDiskCacheForKey 方法内部, 先会查询 Memory Cache :</p>    <pre>  <code class="language-objectivec">UIImage *image = [self imageFromMemoryCacheForKey:key];  if (image) {      doneBlock(image, SDImageCacheTypeMemory);      return nil;  }  </code></pre>    <p>如果 Memory Cache 查找不到, 就会查询 Disk Cache:</p>    <pre>  <code class="language-objectivec">dispatch_async(self.ioQueue, ^{      if (operation.isCancelled) {          return;      }        @autoreleasepool {          UIImage *diskImage = [self diskImageForKey:key];          if (diskImage && self.shouldCacheImagesInMemory) {              NSUInteger cost = SDCacheCostForImage(diskImage);              [self.memCache setObject:diskImage forKey:key cost:cost];          }            dispatch_async(dispatch_get_main_queue(), ^{              doneBlock(diskImage, SDImageCacheTypeDisk);          });      }  });  </code></pre>    <p>查询 Disk Cache 的时候有一个小插曲,就是如果 Disk Cache 查询成功,还会把得到的图片再次设置到 Memory Cache 中。 这样做可以最大化那些高频率展现图片的效率。</p>    <p>如果缓存查询成功, 那么就会直接返回缓存数据。 如果不成功,接下来就开始请求网络:</p>    <pre>  <code class="language-objectivec">id <SDWebImageOperation> subOperation = [self.imageDownloader downloadImageWithURL:url options:downloaderOptions progress:progressBlock completed:^(UIImage *downloadedImage, NSData *data, NSError *error, BOOL finished) {  }  </code></pre>    <p>请求网络使用的是 imageDownloader 属性,这个示例专门负责下载图片数据。 如果下载失败, 会把失败的图片地址写入 failedURLs 集合:</p>    <pre>  <code class="language-objectivec">if (   error.code != NSURLErrorNotConnectedToInternet      && error.code != NSURLErrorCancelled      && error.code != NSURLErrorTimedOut      && error.code != NSURLErrorInternationalRoamingOff      && error.code != NSURLErrorDataNotAllowed      && error.code != NSURLErrorCannotFindHost      && error.code != NSURLErrorCannotConnectToHost) {      @synchronized (self.failedURLs) {          [self.failedURLs addObject:url];      }  }  </code></pre>    <p>为什么要有这个 failedURLs 呢, 因为 SDWebImage 默认会有一个对上次加载失败的图片拒绝再次加载的机制。 也就是说,一张图片在本次会话加载失败了,如果再次加载就会直接拒绝。SDWebImage 这样做可能是为了提高性能吧。这个机制可能会容易被大家忽略,所以这里特意提一下,说不定哪天遇到一些奇怪问题时候,这个知识点会帮你快速定位问题~</p>    <p>如果下载图片成功了,接下来就会使用 [self.imageCache storeImage] 方法将它写入缓存,并且调用 completedBlock 告诉前端显示图片:</p>    <pre>  <code class="language-objectivec">if (downloadedImage && finished) {      [self.imageCache storeImage:downloadedImage recalculateFromImage:NO imageData:data forKey:key toDisk:cacheOnDisk];  }    dispatch_main_sync_safe(^{      if (strongOperation && !strongOperation.isCancelled) {          completedBlock(downloadedImage, nil, SDImageCacheTypeNone, finished, url);      }  });  </code></pre>    <p>好了,到此为止 SDWebImage 的整体图片加载流程就都走完了。 由于要控制篇幅,我这里只挑了最重点的几个节点写出来,SDWebImage 的完整机制肯定会更全面,先帮大家疏通思路。</p>    <h2>是否要重试失败的 URL</h2>    <p>SDWebImage 的整体图片处理流程咱们体验了一遍。 那么有哪些重要的点对咱们使用它会有帮助呢? 我帮大家整理了一些。</p>    <p>你可以在加载图片的时候设置 SDWebImageRetryFailed 标记,这样 SDWebImage 就会加载之前失败过的图片了。 记得我们前面提到的 failedURLs 属性了吧,这个属性是在内存中存储的,如果图片加载失败, SDWebImage 会在本次 APP 会话中都不再重试这张图片了。当然这个加载失败是有条件的,如果是超时失败,不记在内。</p>    <p>总之,如果你更需要图片的可用性,而不是这一点点的性能优化,那么你就可以带上 SDWebImageRetryFailed 标记:</p>    <pre>  <code class="language-objectivec">[_image sd_setImageWithURL:[NSURL URLWithString:@"http://swiftcafe.io/images/qrcodexx.jpg"] placeholderImage:nil options:SDWebImageRetryFailed];  </code></pre>    <h2>Disk 缓存清理策略</h2>    <p>SDWebImage 会在每次 APP 结束的时候执行清理任务。 清理缓存的规则分两步进行。 第一步先清除掉过期的缓存文件。 如果清除掉过期的缓存之后,空间还不够。 那么就继续按文件时间从早到晚排序,先清除最早的缓存文件,直到剩余空间达到要求。</p>    <p>具体点,SDWebImage 是怎么控制哪些缓存过期,以及剩余空间多少才够呢? 通过两个属性:</p>    <pre>  <code class="language-objectivec">@interfaceSDImageCache:NSObject    @property (assign, nonatomic) NSInteger maxCacheAge;  @property (assign, nonatomic) NSUInteger maxCacheSize;  </code></pre>    <p>maxCacheAge 是文件缓存的时长, SDWebImage 会注册两个通知:</p>    <pre>  <code class="language-objectivec">[[NSNotificationCenter defaultCenter] addObserver:self                                                   selector:@selector(cleanDisk)                                                       name:UIApplicationWillTerminateNotification                                                     object:nil];    [[NSNotificationCenter defaultCenter] addObserver:self                                           selector:@selector(backgroundCleanDisk)                                               name:UIApplicationDidEnterBackgroundNotification                                             object:nil];  </code></pre>    <p>分别在应用进入后台和结束的时候,遍历所有的缓存文件,如果缓存文件超过 maxCacheAge 中指定的时长,就会被删除掉。</p>    <p>同样的, maxCacheSize 控制 SDImageCache 所允许的最大缓存空间。 如果清理完过期文件后缓存空间依然没达到 maxCacheSize 的要求, 那么就会继续清理旧文件,直到缓存空间达到要求为止。</p>    <p>了解了这个机制对我们有什么帮助呢? 我们来继续讲解,我们平时在使用 SDWebImage 的时候是没接触过它们的。那么以此推理,它们一定有默认值,也确实有:</p>    <pre>  <code class="language-objectivec">static const NSInteger kDefaultCacheMaxCacheAge = 60 * 60 * 24 * 7; // 1 week  </code></pre>    <p>上面是 maxCacheAge 的默认值,注释上写的很清楚,缓存一周。 再来看看 maxCacheSize。 翻了一遍 SDWebImage 的代码,并没有对 maxCacheSize 设置默认值。 这就意味着 SDWebImage 在默认情况下不会对缓存空间设限制。</p>    <p>这一点可以在 SDWebImage 清理缓存的代码中求证:</p>    <pre>  <code class="language-objectivec">if (self.maxCacheSize > 0 && currentCacheSize > self.maxCacheSize) {   //清理缓存代码  }  </code></pre>    <p>说明一下, 上面代码中的 currentCacheSize 变量代表当前图片缓存占用的空间。 从这里可以看出, 只有在 maxCacheSize 大于 0 并且当前缓存空间大于 maxCacheSize 的时候才进行第二步的缓存清理。</p>    <p>这意味着什么呢? 其实就是 SDWebImage 在默认情况下是不对我们的缓存大小设限制的,理论上,APP 中的图片缓存可以占满整个设备。</p>    <p>SDWebImage 的这个特性还是比较容易被大家忽略的,如果你开发的类似信息流的 APP,应该会加载大量的图片,如果这时候按照默认机制,缓存尺寸是没有限制的,并且默认的缓存周期是一周。 就很容易造成应用存储空间占用偏大的问题。</p>    <p>那么可能有人会说了,现在 iPhone 的存储空间都很大,多缓存一点也不是问题吧? 但你是否知道 iOS 上还有一个用量查询的功能呢。在设置项中用户可以查看每个 APP 的空间使用情况, 如果你的 APP 占用空间比较大的话,就很容易成为用户的卸载目标,这应该是需要关注的一个细节。</p>    <p>另外,过多的占用缓存空间其实并不一定有用。大部分情况是一些图片被缓存下来后,很少再被重复展现。所以合理的规划缓存空间尺寸还是很有必要的。可以这样设置:</p>    <pre>  <code class="language-objectivec">[SDImageCache sharedImageCache].maxCacheSize = 1024 * 1024 * 50; // 50M  </code></pre>    <p>maxCacheSize 是以字节来表示的,我们上面的计算代表 50M 的最大缓存空间。 把这行代码写在你的 APP 启动的时候,这样 SDWebImage 在清理缓存的时候,就会清理多余的缓存文件了。</p>    <h2>结语</h2>    <p>这次跟大家聊了聊 SDWebImage 整体流程,以及它的缓存策略。SDWebImage 是一个历时很久的开源库,并且它不断的保持着更新。 虽然是一个并不算很复杂的开源库,但仔细研读一下它的代码, 会发现它的内部很多机制设计的还是很巧妙的。 为了保证大家的阅读体验,尽量控制文章的篇幅,这里尽量选出对大家最有帮助的内容和大家分享。这篇文章结构整理花了几天时间,仔细筛选最重要的内容。想达到的效果就是,让他读起来不累,但却能很快抓住重点,让大家得到有用的信息。也希望大家对阅读体验能够提出反馈,帮助我给大家创造更好的内容。</p>    <p>如果想了解 SDWebImage 更详细的内容,那么它的 Github 主页就是最好的地方了,在这里也贴出来,供大家参考: <a href="/misc/goto?guid=4958838688702703567" rel="nofollow,noindex">https://github.com/rs/SDWebImage</a> 。</p>    <p>如果你觉得这篇文章有帮助,还可以关注微信公众号 swift-cafe,会有更多我的原创内容分享给你~</p>    <p> </p>    <p>来自:http://www.swiftcafe.io/2017/02/19/sdimage-cache/</p>    <p> </p>