SDWebImage主线之缓存(不附源码)

写在前面

  1. 缓存模块的功能最主要的就是"存"和"取","取"(查找)缓存已经在SDWebImage主线梳理(一)SDWebImage主线梳理(二)里跟随主线流程介绍过,本篇不再赘述。

  2. 本篇主要介绍缓存模块的"存"。"存"主要分成两条线,一条线是没有缓存需要网络请求,接收到图片后要保存到缓存;另一条线是有缓存不需要网络请求,但是磁盘缓存取出后要存到内存缓存一份。

  3. 还有内存缓存(SDMemoryCache)和磁盘缓存(SDDiskCache)单个类的解析。

  4. 另外再介绍一下SD的缓存过期问题,以及缓存过期的灵魂六问。


保存缓存

没有缓存需要网络请求

没有缓存或第一次使用这张图片的时候,需要走网络请求,然后解码,接着保存到内存缓存,最后保存到磁盘缓存

  • -[SDWebImageManager callCacheProcessForOperation] 分成两个方向
    1. 查询缓存(必定走):-[SDImageCache queryImageForKey]
    2. 下载图片(等回调):等待查询结果,如果没缓存则正常走网络请求

保存缓存的大致调用流程:

  1. -[SDWebImageManager callDownloadProcessForOperation]

  2. -[SDWebImageDownloader requestImageWithURL] 方法的 block(LoaderCompletedBlock) 实现

  3. 走啊走,走到开始网络请求。请参考SDWebImage主线梳理(二)

  4. 网络请求已回调

  5. -[SDWebImageDownloaderOperation URLSession:task:didCompleteWithError:]

  6. -[SDWebImageDownloaderOperation callCompletionBlocksWithImage:imageData:error:finished:]

  7. 调用LoaderCompletedBlock回调

  8. LoaderCompletedBlock 实现中的最后一个 else 分支调用 -[SDWebImageManager callStoreCacheProcessForOperation]

  9. -[SDImageCache storeImage:imageData:forKey:cacheType:completion:]

  10. -[SDImageCache storeImage:imageData:forKey:toMemory:toDisk:completion:]

    1. 内存缓存
      1. if分支,默认走内存缓存

      2. 计算图片占用的内存空间

        1. UIImage 的关联属性 sd_memoryCost,key=@selector(sd_memoryCost)

        2. SDMemoryCacheCostForImage()函数

          1. 从 UIImage 获取 CGImageRef
          2. CGImageGetBytesPerRow() * CGImageGetHeight() 获取一帧的全部字节量
          3. 如果是动图还需要乘以帧数,否则直接返回全部字节量
      3. SETTER:-[SDMemoryCache setObject:forKey:cost:]

        1. 上来就走 -[super setObject:forKey:cost:],SDMemoryCache 继承于 NSCache
        2. shouldUseWeakMemoryCache == YES 继续,否则直接返回
        3. 保存到 weakCache(强键-弱值)的NSMapTable中
    2. 磁盘缓存
      1. if分支,判断入参 toDisk;

      2. 整个分支内都是在 ioQueue(异步、串行)队列中

      3. 处理特殊情况01:没有data只有image;需要先确定图片格式,即如果包含alpha通道就定义为PNG,否则定义为JPEG;

        1. 确定是否包含alpha通道:-[SDImageCoderHelper CGImageContainsAlpha:]
          1. 调用CGImageGetAlphaInfo()函数获取alpha信息
          2. kCGImageAlphaNone、kCGImageAlphaNoneSkipFirst、kCGImageAlphaNoneSkipLast,只要是alpha信息等于其中一个就代表不包含alpha通道
      4. 处理特殊情况02:将image转码为data;-[SDImageCodersManager encodedDataWithImage:format:options:]

      5. -[SDImageCache _storeImageDataToDisk:forKey:]

        1. -[SDDiskCache setData:forKey:]
          1. fileManager 判断该路径下是否存在文件 self.diskCachePath,不存在就创建一个文件夹

          2. -[SDDiskCache cachePathForKey:],一顿调整key和path,得到新的path

            1. -[SDDiskCache cachePathForKey:inPath:],在这里把self.diskCachePath传进去一起折腾
              1. SDDiskCacheFileNameForKey(key),专门处理key,看起来像是搞成MD5的样子,然后返回处理完的key
              2. 把处理成“MD5”的key拼接在self.diskCachePath后面就OK了
          3. path 转为 NSURL

          4. data 保存到 URL 路径下

有缓存不需要网络请求

有缓存的情况时,先查询,然后返回缓存,使用图片

  1. -[SDWebImageManager callCacheProcessForOperation]

  2. -[SDImageCache queryImageForKey]

  3. -[SDImageCache queryCacheOperationForKey]

    1. 调用自己实现的 queryDiskBlock
      1. -[SDImageCache diskImageDataBySearchingAllPathsForKey:], 拿出磁盘缓存;

      2. 来到没内存缓存 && 有磁盘缓存的分支; -[SDImageCache diskImageForKey:data:options:context:],返回解码后的UIImage

        1. SDImageCacheDecodeImageData(); SDImageCacheDefine.m 唯一的函数, 真真是在解码
      3. 计算 image 的 cost, 把 image 保存到 memoryCache 中

      4. 异步调用 doneBlock(diskImage, diskData, cacheType)

        1. -[SDImageCache queryCacheOperationForKey...] 的 doneBlock
        2. 实现在 -[SDWebImageManager callCacheProcessForOperation...] 的实现中
  4. 回到 SDWebImageManager, -[SDWebImageManager callCacheProcessForOperation...], else if 分支

  5. -[SDWebImageManager callCompletionBlockForOperation], 调用 completionBlock

    1. completionBlock 是 -[SDWebImageManager loadImageWithURL...] 的 block(InternalBlock2)
    2. 实现在 -[UIView(WebCache) sd_internalSetImageWithURL...]
  6. 回到 UIView(WebCache) InternalBlock2 的实现中, -[UIView(WebCache) sd_setImage:imageData:basedOnClassOrViaCustomSetImageBlock:transition:cacheType:imageURL:]

    1. 自己实现的 finalSetImageBlock ,自己调用

SDMemoryCache解析

  1. SDMemoryCache 继承自 NSCache

  2. 唯一公开属性 SDImageCacheConfig

  3. 私有属性 NSMapTable *weakCach:strong-weak cache, 用来弱引用 UIImage

  4. 配置中的 shouldUseWeakMemoryCache 选项

    1. SDMemoryCache 之所以支持弱内存缓存,是为了避免重复从磁盘加载,因为内存警告时会清除内存缓存,以致于SD失去了对图片的持有,无法进一步操作,需要重新从磁盘加载图片。
    2. 可以解决因App进入后台或者内存警告时清空内存而引起进入前台时的cell闪烁问题
  5. 当系统发出内存警告通知时,SDMemoryCache 只会移除内存缓存,而留下弱缓存(weakCache)

  6. SETTER 方法:先保存一份 UIImage 到内存缓存(NSCache), 如果 shouldUseWeakMemoryCache 再保存一份到 weakCache

  7. GETTER 方法:

    1. 与SETTER类似,上来走 -[super objectForKey:],然后判断 shouldUseWeakMemoryCache,YES继续,否则直接返回对象。
    2. 唯一值得说的地方是在从 weakCache 取完值后,有可能 weakCache 存的值和内存缓存的值不一致。因此做一次同步操作,将 weakCache 的值赋值给内存缓存。
  8. 内存缓存(SDMemoryCache)只保存 UIImage, 不保存NSData。image 已解码。

  9. 单说储存, SDMemoryCache 的内存缓存完全由其父类 NSCache 完成; 弱缓存由 strong-weak 属性 weakCache 完成;

  10. 再说存取, 存只是普通的向 NSCache 或 NSMapTable 赋值; 取也是从 NSCache 和 NSMapTable 普通的取值,只不过如果是从弱缓存取值还要同步给内存缓存。


SDDiskCache解析

  1. SDDiskCache 继承自 NSObject

  2. 两个私有属性:
    1.diskCachePath, 初始化就要有
    2.fileManager, 初始化时没有则 new 一个

  3. -[SDDiskCache cachePathForKey:]

    1. 入参只有一个key(图片的URL地址), 还有一个隐藏参数就是 diskCachePath;
    2. 处理 key, 就是 MD5 散列;
    3. 将key(MD5散列值) 拼接在 diskCachePath 后面,返回新路径;
  4. SETTER 方法:
    1.如果 diskCachePath 文件夹不存在则创建一个;
    2.获取新路径 diskCachePath/key(MD5); 新路径转成 NSURL
    3.图片data writeToURL:
    4.禁止iCloud备份

  5. GETTER 方法:
    1.获取新路径 diskCachePath/key(MD5);
    2.从新路径恢复(初始化) NSData
    3.如果data不存在,则去掉新路径的扩展名再试一次

  6. -[NSFileManager createDirectoryAtPath:withIntermediateDirectories:attributes:error:]

    1. 这个 withIntermediateDirectories 是说,目前真实路径是 AAA/BBB/CCC, 给定路径(想创建的文件夹路径)AAA/BBB/CCC/DDD/EEE, 如果是YES则D和E的文件夹都会被创建,否则啥也不创建
  7. 当调用-[SDDiskCache containsDataForKey:]-[SDDiskCache dataForKey:] 两个方法的时候,都会有一个操作就是当把key重新组装完之后还是找不到data,那么会尝试将key的扩展名去掉再试一遍。

  8. 存取, SDDiskCache 的磁盘缓存全靠 NSFileManager 和 NSData 二者配合; NSFileManager 来确定文件夹路径是否存在以及创建文件夹

    1. -[NSFileManager fileExistsAtPath]
      1. -[NSFileManager createDirectoryAtPath]
      2. -[NSFileManager removeItemAtPath]
    2. NSData:按照路径读写即可
      1. -[NSData writeToURL]
      2. -[NSData dataWithContentsOfFile]
  9. 磁盘缓存(SDDiskCache)只保存 NSData, 不保存 UIImage。data就是二进制流,不存在解码还是不解码的问题,如果非要问,就是未解码。

  10. removeExpiredData 详情见下下节



FAQ

  • Q: 网络请求完成时,我们缓存的是已解码的图片吗?
  • A: 是解码的图片

  • Q: 既然缓存的是已解码的图片,那在查询缓存时是否有解码操作,为什么?
  • A: decode image data only if in-memory cache missed,只有在没有内存缓存且有data的情况下才再次解码data;也就是App下一次启动,磁盘有缓存,内存没有缓存
    • 详情请在源码中搜:-[SDImageCache queryCacheOperationForKey]

  • Q: 图片存取的key是什么?
  • A: 图片的URL地址

  • Q: 储存到 SDDiskCache 的缓存是什么?
  • A: 是 imageData; 在 URLSession 的回调方法 didReceiveData 中一点一点拼接完成的data;在 didCompleteWithError 通过 block 回调回来

  • Q: 储存到 SDDiskCache 的 imageData 是解码过的吗?
  • A: imageData 就是二进制流(不存在解不解码的问题),而 didCompleteWithError 中说的解码是 imageData 解码变成位图保存到 UIImage 的产物;


缓存过期

源码解析:
-[SDDiskCache removeExpiredData]

- (void)removeExpiredData {
    NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES];
    
    // 确定文件最后的时间,是最后一次修改的时间还是访问的时间; 默认是最后一次修改的时间
    NSURLResourceKey cacheContentDateKey = NSURLContentModificationDateKey; // 最后一次修改的时间
    switch (self.config.diskCacheExpireType) {
        case SDImageCacheConfigExpireTypeAccessDate:
            cacheContentDateKey = NSURLContentAccessDateKey; // 最后一次访问的时间
            break;
            
        case SDImageCacheConfigExpireTypeModificationDate:
            cacheContentDateKey = NSURLContentModificationDateKey;
            break;
            
        default:
            break;
    }
    
    // NSURLIsDirectoryKey: 判断文件是否是文件夹; cacheContentDateKey: 获取文件最后一次修改的时间; NSURLTotalFileAllocatedSizeKey: 整个文件被分配的磁盘空间大小
    NSArray<NSString *> *resourceKeys = @[NSURLIsDirectoryKey, cacheContentDateKey, NSURLTotalFileAllocatedSizeKey];
    
    // 枚举器(Enumerator) 预先把需要的信息都保存下来
    NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtURL:diskCacheURL
                                               includingPropertiesForKeys:resourceKeys
                                                                  options:NSDirectoryEnumerationSkipsHiddenFiles
                                                             errorHandler:NULL];
    // 默认从此刻倒推7天
    NSDate *expirationDate = (self.config.maxDiskAge < 0) ? nil: [NSDate dateWithTimeIntervalSinceNow:-self.config.maxDiskAge];
    // 一会用来保存文件信息(resourceValues), {fileURL : resourceValues}
    NSMutableDictionary<NSURL *, NSDictionary<NSString *, id> *> *cacheFiles = [NSMutableDictionary dictionary];
    // 保存所有文件占用的磁盘空间大小
    NSUInteger currentCacheSize = 0;
    
    // 清除操作分为两部分:1.删除过期的缓存文件; 2.磁盘缓存大小超过限制后,删除一部分(从最旧的文件开始),直到剩下的缓存大小低于“限制的一半”大小
    // Enumerate all of the files in the cache directory.  This loop has two purposes:
    //
    //  1. Removing files that are older than the expiration date.
    //  2. Storing file attributes for the size-based cleanup pass.
    NSMutableArray<NSURL *> *urlsToDelete = [[NSMutableArray alloc] init];
    for (NSURL *fileURL in fileEnumerator) {
        NSError *error;
        // 获取文件信息
        NSDictionary<NSString *, id> *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:&error];
        
        // Skip directories and errors.
        if (error || !resourceValues || [resourceValues[NSURLIsDirectoryKey] boolValue]) {
            continue;
        }
        
        // Remove files that are older than the expiration date;
        NSDate *modifiedDate = resourceValues[cacheContentDateKey];
        // laterDate: 会返回调用者和入参两者中最晚的日期
        if (expirationDate && [[modifiedDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
            [urlsToDelete addObject:fileURL];
            continue;
        }
        
        // Store a reference to this file and account for its total size.
        // 获取文件的大小, 字节数
        NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
        // 累加
        currentCacheSize += totalAllocatedSize.unsignedIntegerValue;
        // 保存文件信息
        cacheFiles[fileURL] = resourceValues;
    }

    // 逐个删除过期的缓存文件
    for (NSURL *fileURL in urlsToDelete) {
        [self.fileManager removeItemAtURL:fileURL error:nil];
    }
    
    // If our remaining disk cache exceeds a configured maximum size, perform a second
    // size-based cleanup pass.  We delete the oldest files first.
    NSUInteger maxDiskSize = self.config.maxDiskSize;
    if (maxDiskSize > 0 && currentCacheSize > maxDiskSize) {
        // Target half of our maximum cache size for this cleanup pass.
        // 定个小目标:从最旧的文件开始删除,一直删,一直删,直到剩下的缓存总字节数小于 maxDiskSize 的一半
        const NSUInteger desiredCacheSize = maxDiskSize / 2;
        
        // Sort the remaining cache files by their last modification time or last access time (oldest first).
        // 通过比较value(NSDate)之间的大小来排序key,时间最早的key排在前面
        NSArray<NSURL *> *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent
                                                                 usingComparator:^NSComparisonResult(id obj1, id obj2) {
                                                                     return [obj1[cacheContentDateKey] compare:obj2[cacheContentDateKey]];
                                                                 }];
        
        // Delete files until we fall below our desired cache size.
        for (NSURL *fileURL in sortedFiles) {
            if ([self.fileManager removeItemAtURL:fileURL error:nil]) {
                // 刚才在上一个for循环中保存的文件信息,按照fileURL取出来
                NSDictionary<NSString *, id> *resourceValues = cacheFiles[fileURL];
                // 从文件信息中取出总字节数(占用磁盘空间的大小)
                NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
                // 刚才一直在累加,现在删除一个往下减一个
                currentCacheSize -= totalAllocatedSize.unsignedIntegerValue;
                // 一直删,一直删,直到缓存总字节数小于目标字节数(前面定的小目标)
                if (currentCacheSize < desiredCacheSize) {
                    break;
                }
            }
        }
    }
}

缓存过期之灵魂六问
  • Q1: 缓存过期后,如何在代码中编写清除功能?
  • A1: 参照源码解析,分成两部分:一是缓存文件有过期的需要进行清除,一是缓存文件占用磁盘空间超过大小限制需要进行清除

  • Q2: 怎么确定这个缓存已过期?
  • A2: 过期时间检测不用我们费心,因为文件自己会记录自己的最后一次时间(最后一次访问时间和最后一次修改时间),通过 NSDirectoryEnumerator 就可以获取

  • Q3: 怎么知道缓存文件是否超过限制大小?
  • A3: 文件占用的磁盘空间大小也不用我们费心,同样可以通过 NSDirectoryEnumerator 获取

  • Q4: 什么时机清除?
  • A4: 在 SDImageCache 中注册了系统进入后台的通知,当App进入后台时,会开启一个后台任务,调用 -[SDImageCache deleteOldFilesWithCompletionBlock:] -> -[SDDiskCache removeExpiredData] 进行一次清除。目前看是能持续50s。

  • Q5: 多久清除一次?
  • A5: 每次进入后台都会调用清除方法,超过了限制(时间或者空间大小)即进行相应的清除工作。

  • Q6: 按什么顺序清除?
  • A6: 过期的缓存不看顺序,只要是过期的就毫不留情的删除; 但是当缓存占用磁盘空间大小超过限制的时候,我们开始不断的删除缓存文件中最旧的,最后删到还剩 maxDiskSize 的一半大小的时候为止。

推荐阅读更多精彩内容