《重读SDWebImage》-Cache部分

最近终于有些时间重读SD的源码了,本篇文章侧重分析SDWebImage缓存部分逻辑,以及其中的一些细节。

一.SDImageCache提供的功能

SDImageCache功能

SDWebImage为整个图片加载逻辑提供缓存支持,包括内存缓存(NSCache实现)和磁盘缓存,且支持同步和异步操作。提供单例对象可进行全局操作。

SDImageCache提供了两个枚举:

三种缓存选项SDImageCacheType

  • SDImageCacheTypeNone 不缓存
  • SDImageCacheTypeDisk 磁盘缓存
  • SDImageCacheTypeMemory 内存缓存

三种查询操作的选项SDImageCacheOptions,这是一个按位枚举可以多选:

  • SDImageCacheQueryDataWhenInMemory 在查询缓存数据时会强制查询磁盘缓存数据
  • SDImageCacheQueryDiskSync 查询磁盘缓存的时候会强制同步查询
  • SDImageCacheScaleDownLargeImages 会根据屏幕比例对图片进行尺寸调整

至于这两个枚举怎么用看后面的细节里会讲到

二、细节

2.1 .命名空间

每个ImageCache对象在创建的时候都必须提供命名空间,目的是区分磁盘缓存的路径,使每一个SDImageCache对象都有独立的存储空间不至于搞混,默认的命名空间为default。磁盘缓存都会在.../Library/Caches/namespace/com.hackemist.SDWebImageCache.namespace文件夹下。

磁盘缓存路径

下面看一下几个重要的方法:

- (nonnull instancetype)initWithNamespace:(nonnull NSString *)ns
                       diskCacheDirectory:(nonnull NSString *)directory {
    if ((self = [super init])) {
        //命名空间磁盘缓存都会存储在/Users/xingfan/Library/Developer/CoreSimulator/Devices/68BF3925-CD38-4A3C-AFAB-C2660D4D40AF/data/Containers/Data/Application/0889706E-09A2-4DEF-930A-18DE125E859D/Library/Caches/命名空间/com.hackemist.SDWebImageCache.命名空间/这个文件夹下
        NSString *fullNamespace = [@"com.hackemist.SDWebImageCache." stringByAppendingString:ns];
        
        // 创建穿行串行队列执行任务
        _ioQueue = dispatch_queue_create("com.hackemist.SDWebImageCache", DISPATCH_QUEUE_SERIAL);
        
        _config = [[SDImageCacheConfig alloc] init];
        
        // 创建SDMemoryCache继承自NSCache,提供内存缓存功能
        _memCache = [[SDMemoryCache alloc] initWithConfig:_config];
        _memCache.name = fullNamespace;

        // 添加disk地址如下:/Users/xingfan/Library/Developer/CoreSimulator/Devices/68BF3925-CD38-4A3C-AFAB-C2660D4D40AF/data/Containers/Data/Application/0889706E-09A2-4DEF-930A-18DE125E859D/Library/Caches/default/com.hackemist.SDWebImageCache.default
        if (directory != nil) {
            _diskCachePath = [directory stringByAppendingPathComponent:fullNamespace];
        } else {
            NSString *path = [self makeDiskCachePath:ns];
            _diskCachePath = path;
        }
        //最终磁盘缓存的文件夹路径被存储在_diskCachePath这个成员变量里面
        dispatch_sync(_ioQueue, ^{
            self.fileManager = [NSFileManager new];
        });
    }
    return self;
}
2.2. Cache path

Cache Path有如下几个功能

  • 添加自定义存储路径
  • 查询命名空间对应的磁盘缓存路径
  • 提供文件名生成方法,并不会用明文key存储,(key.utf8.md5+扩展名)的方式存储在本地

看下几个重要的方法。

//添加自定义文件路径,在查找缓存的时候会同时在self.customPaths里面查找
- (void)addReadOnlyCachePath:(nonnull NSString *)path {
    if (!self.customPaths) {
        self.customPaths = [NSMutableArray new];
    }

    if (![self.customPaths containsObject:path]) {
        [self.customPaths addObject:path];
    }
}
//文件名生成规则,对图片url的utf8String进行md5,链接最后如果有扩展名会拼接上扩展名
- (nullable NSString *)cachedFileNameForKey:(nullable NSString *)key {
    const char *str = key.UTF8String;
    if (str == NULL) {
        str = "";
    }
    unsigned char r[CC_MD5_DIGEST_LENGTH];
    CC_MD5(str, (CC_LONG)strlen(str), r);
    NSURL *keyURL = [NSURL URLWithString:key];
    NSString *ext = keyURL ? keyURL.pathExtension : key.pathExtension;
    // File system has file name length limit, we need to check if ext is too long, we don't add it to the filename
    if (ext.length > SD_MAX_FILE_EXTENSION_LENGTH) {
        ext = nil;
    }
    //%02x 格式控制: x意思是以十六进制输出,2为指定的输出字段的宽度.如果位数小于2,则左端补0
    NSString *filename = [NSString stringWithFormat:@"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%@",
                          r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9], r[10],
                          r[11], r[12], r[13], r[14], r[15], ext.length == 0 ? @"" : [NSString stringWithFormat:@".%@", ext]];
    return filename;
}
2.3. 存储操作

根据给出的key存储对应的image数据,主要有两个方法。如下。

//异步缓存图片
- (void)storeImage:(nullable UIImage *)image
         imageData:(nullable NSData *)imageData
            forKey:(nullable NSString *)key
            toDisk:(BOOL)toDisk
        completion:(nullable SDWebImageNoParamsBlock)completionBlock {
    if (!image || !key) {
        if (completionBlock) {
            completionBlock();
        }
        return;
    }
    // 判断配置中是否需要进行内存缓存,如果需要存入memCache
    if (self.config.shouldCacheImagesInMemory) {
        NSUInteger cost = image.sd_memoryCost;
        [self.memCache setObject:image forKey:key cost:cost];
    }
    
    if (toDisk) {
    //异步执行磁盘缓存,因为磁盘缓存耗时
        dispatch_async(self.ioQueue, ^{
    //用自动释放池可以提前释放局部变量,减少内存峰值
            @autoreleasepool {
                NSData *data = imageData;
                //如果data为空这里生成imageData
                if (!data && image) {
                    // 根据是否存在alpha通道判断图片类型
                    SDImageFormat format;
                    //判断image格式
                    if (SDCGImageRefContainsAlpha(image.CGImage)) {
                        format = SDImageFormatPNG;
                    } else {
                        format = SDImageFormatJPEG;
                    }
                    //根据图片类型将图片转成nsdata,过程之后的文章里会讲,这里侧重于缓存逻辑
                    data = [[SDWebImageCodersManager sharedInstance] encodedDataWithImage:image format:format];
                }
                //执行磁盘缓存
                [self _storeImageDataToDisk:data forKey:key];
            }
            
            if (completionBlock) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    completionBlock();
                });
            }
        });
    } else {
        if (completionBlock) {
            completionBlock();
        }
    }
}
// 这里确保是从io队列中调用,将imageData存储到磁盘缓存中
- (void) _storeImageDataToDisk:(nullable NSData *)imageData forKey:(nullable NSString *)key {
    if (!imageData || !key) {
        return;
    }
    //判断目标文件夹是否存在,如果不存在创建
    if (![self.fileManager fileExistsAtPath:_diskCachePath]) {
        [self.fileManager createDirectoryAtPath:_diskCachePath withIntermediateDirectories:YES attributes:nil error:NULL];
    }
    
    // 获取缓存文件的文件名并拼接到diskCachePath后面,md5+后缀,.../Library/Caches/命名空间/com.hackemist.SDWebImageCache.命名空间/urlmd5+后缀名
    NSString *cachePathForKey = [self defaultCachePathForKey:key];
    // transform to NSUrl
    NSURL *fileURL = [NSURL fileURLWithPath:cachePathForKey];
    //将二进制文件写入目标文件夹
    [imageData writeToURL:fileURL options:self.config.diskCacheWritingOptions error:nil];
    
    // iclould操作
    if (self.config.shouldDisableiCloud) {
        [fileURL setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil];
    }
}
2.4.查询和检索

对于查询和检索SDImageCache提供了如下功能:

  • 查询当前key在磁盘中是否有对应的缓存数据(提供了同步和异步方法)
  • 查询当前key在内存中是否有对应的缓存数据(同步)
  • 检索出当前key在磁盘中存储的数据,同时提供block返回UIImage和NSData。方法默认情况下会先在内存缓存中查找,再到磁盘缓存查找,如果在磁盘缓存中查找到,会加入到内存缓存中(异步操作)
    看下关键方法:
- (nullable NSOperation *)queryCacheOperationForKey:(nullable NSString *)key options:(SDImageCacheOptions)options done:(nullable SDCacheQueryCompletedBlock)doneBlock {
    if (!key) {
        if (doneBlock) {
            doneBlock(nil, nil, SDImageCacheTypeNone);
        }
        return nil;
    }
    
    // 先检测NSCache里面是否有缓存数据
    UIImage *image = [self imageFromMemoryCacheForKey:key];
    //这里就是开头讲到的查询策略,如果SDImageCacheQueryDataWhenInMemory则强制查询磁盘缓存Data数据。跳过此步骤,如果没有这个选项直接返回,但是不会返回Data数据。
    BOOL shouldQueryMemoryOnly = (image && !(options & SDImageCacheQueryDataWhenInMemory));
    if (shouldQueryMemoryOnly) {
        if (doneBlock) {
            doneBlock(image, nil, SDImageCacheTypeMemory);
        }
        return nil;
    }
    //这个operation内并不包含存储操作,只是在异步执行磁盘缓存的时候,在外部可以对operation 进行cancel操作,可以中断磁盘缓存的逻辑。
    NSOperation *operation = [NSOperation new];
    void(^queryDiskBlock)(void) =  ^{
        if (operation.isCancelled) {
            // do not call the completion if cancelled
            return;
        }
        //这里跟前面一样,用自动释放池来减少内存峰值。
        @autoreleasepool {
        //此方法会搜索所有路径下的磁盘缓存数据,包括customPath和namespace下。
            NSData *diskData = [self diskImageDataBySearchingAllPathsForKey:key];
            UIImage *diskImage;
            SDImageCacheType cacheType = SDImageCacheTypeNone;
            //判断刚刚内存缓存中是否有image数据。
            if (image) {
                // 如果有的话,返回内存中获取的image和磁盘中获取的data并且cache类型是SDImageCacheTypeMemory
                diskImage = image;
                cacheType = SDImageCacheTypeMemory;
            } else if (diskData) {
                cacheType = SDImageCacheTypeDisk;
              //如果没有的话,那么由data转成UIImage,返回这时候返回的数据都是来自于磁盘缓存。
                diskImage = [self diskImageForKey:key data:diskData options:options];
                if (diskImage && self.config.shouldCacheImagesInMemory) {
                    NSUInteger cost = diskImage.sd_memoryCost;
                    [self.memCache setObject:diskImage forKey:key cost:cost];
                }
            }
            if (doneBlock) {
                if (options & SDImageCacheQueryDiskSync) {
                    doneBlock(diskImage, diskData, cacheType);
                } else {
                    dispatch_async(dispatch_get_main_queue(), ^{
                        doneBlock(diskImage, diskData, cacheType);
                    });
                }
            }
        }
    };
    //这里如果选项包括强制同步执行磁盘缓存操作,那么同步执行,否则异步执行
    if (options & SDImageCacheQueryDiskSync) {
        queryDiskBlock();
    } else {
        dispatch_async(self.ioQueue, queryDiskBlock);
    }
    
    return operation;
}
//该方法作用就是将磁盘取出来的数据转成UIImage
- (nullable UIImage *)diskImageForKey:(nullable NSString *)key data:(nullable NSData *)data options:(SDImageCacheOptions)options {
    if (data) {
        //由data获取到image,
        UIImage *image = [[SDWebImageCodersManager sharedInstance] decodedImageWithData:data];
        //根据key的名字设置图片的scale。因为这里不会像imageName方法自动添加scale
        image = [self scaledImageForKey:key image:image];
        //看存取策略如果需要返回解压后的image,那么解压image
        if (self.config.shouldDecompressImages) {
            BOOL shouldScaleDown = options & SDImageCacheScaleDownLargeImages;
            image = [[SDWebImageCodersManager sharedInstance] decompressedImageWithImage:image data:&data options:@{SDWebImageCoderScaleDownLargeImagesKey: @(shouldScaleDown)}];
        }
        //返回解压后的image。
        return image;
    } else {
        return nil;
    }
}
2.4.删除操作

删除操作相对简单的多,源码就不写了,没啥好说的😂

  • 从内存中删除对应数据
  • 从磁盘中删除对应数据
2.5.内存清理操作

这部分是保证内存性能,和磁盘缓存大小的关键

  • NSCache本身就会在内存占用较高的时候自动清理内存,所以这部分不用过多关心,SD也只是在收到内存警告的时候将NSCache清空。
  • 而保证磁盘空间用的是LRU(Least recently used,最近最少使用)缓存策略,有两个选项以最近访问时间为基准,以最近修改时间为基准。
    下面我们看一下LRU缓存淘汰机制是如何实现的。
//清理旧磁盘文件,这段代码稍微多一点
- (void)deleteOldFilesWithCompletionBlock:(nullable SDWebImageNoParamsBlock)completionBlock {
    dispatch_async(self.ioQueue, ^{
      //获取磁盘缓存的文件夹
        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;
        }
        
        NSArray<NSString *> *resourceKeys = @[NSURLIsDirectoryKey, cacheContentDateKey, NSURLTotalFileAllocatedSizeKey];

        // 获取缓存文件夹下所有的文件
        NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtURL:diskCacheURL
                                                   includingPropertiesForKeys:resourceKeys
                                                                      options:NSDirectoryEnumerationSkipsHiddenFiles
                                                                 errorHandler:NULL];
        //设置过期时间,默认是一周
        NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:-self.config.maxCacheAge];
        NSMutableDictionary<NSURL *, NSDictionary<NSString *, id> *> *cacheFiles = [NSMutableDictionary dictionary];
        //存储着未过期文件的总大小
        NSUInteger currentCacheSize = 0;

        // 枚举缓存目录中的所有文件,有两个目的
        //
        //  1. 筛选出过期文件,添加进urlsToDelete数组中
        //  2. 存储并计算出未过期文件的总大小
        NSMutableArray<NSURL *> *urlsToDelete = [[NSMutableArray alloc] init];
        for (NSURL *fileURL in fileEnumerator) {
            NSError *error;
            NSDictionary<NSString *, id> *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:&error];

            // 跳过目录、和错误
            if (error || !resourceValues || [resourceValues[NSURLIsDirectoryKey] boolValue]) {
                continue;
            }

            // 将过期文件添加进urlsToDelete中
            NSDate *modifiedDate = resourceValues[cacheContentDateKey];
            if ([[modifiedDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
                [urlsToDelete addObject:fileURL];
                continue;
            }
            
            // 存储未删除的文件信息,并计算总大小
            NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
            currentCacheSize += totalAllocatedSize.unsignedIntegerValue;
            cacheFiles[fileURL] = resourceValues;
        }
        //删除过期文件
        for (NSURL *fileURL in urlsToDelete) {
            [self.fileManager removeItemAtURL:fileURL error:nil];
        }

        // 如果剩余的磁盘文件大小超过了设置的最大值,那么执行LRU淘汰策略,删除最老的文件
        if (self.config.maxCacheSize > 0 && currentCacheSize > self.config.maxCacheSize) {
            // 清理的目标为最大缓存值得一般
            const NSUInteger desiredCacheSize = self.config.maxCacheSize / 2;

            // 讲目录下的文件按照时间书序排序,老的排在前面
            NSArray<NSURL *> *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent
                                                                     usingComparator:^NSComparisonResult(id obj1, id obj2) {
                                                                         return [obj1[cacheContentDateKey] compare:obj2[cacheContentDateKey]];
                                                                     }];

            // 遍历删除,直到当前大小小于目标大小。
            for (NSURL *fileURL in sortedFiles) {
                if ([self.fileManager removeItemAtURL:fileURL error:nil]) {
                    NSDictionary<NSString *, id> *resourceValues = cacheFiles[fileURL];
                    NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
                    currentCacheSize -= totalAllocatedSize.unsignedIntegerValue;

                    if (currentCacheSize < desiredCacheSize) {
                        break;
                    }
                }
            }
        }
        if (completionBlock) {
            dispatch_async(dispatch_get_main_queue(), ^{
                completionBlock();
            });
        }
    });
}
2.6. Cache信息

提供接口返回当前磁盘缓存的信息

  • 当前磁盘缓存的总大小(同步)
  • 当前磁盘缓存的总数量(同步)
  • 计算磁盘缓存的总大小和数量(异步)

这个也不上代码了,没什么特殊之处。

三、Q&A

Q:SDImageCache中有很多异步操作也没见里面用线程锁,那么是如何保证线程安全的呢?
A:对于内存缓存NSCache本身就是内存安全的,磁盘缓存使用一个全局的串行队列保证的,串行队列的性质决定了无论同步执行还是异步执行,都会等之前的任务执行完才会执行下一个任务。

Q:SDImageCache的好处有哪些?
A:采用二级缓存机制(先从内存中去找,如果没有再到磁盘里去找,如果在没有再去下载,下载过后再存储到内存和磁盘当中),避免了图片的多次下载。有LRU缓存淘汰机制。

本人能力有限,有问题忘大神们及时指出。

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

推荐阅读更多精彩内容