SDWebImage源码阅读2——缓存机制

前言

继上篇SDWebImage源码阅读1——整体脉络结构捋了下SDWebImage整体的脉络结构后,本篇主要研究其缓存机制,这是其重点。


分析

上篇我们说了SDWebImageManager这个类是其完成图片加载的核心类,它是整个代码逻辑的中心,可以把它叫做图片加载管理器。因为它拥有两个非常核心的属性:SDImageCacheSDWebImageDownloader两者的实例对象作为其属性,在该图片加载管理器里完成了有关缓存和网络下载图片的处理。
比如imageCache这个属性在随着管理器初始化后,当管理器获取图片时它先在缓存中查找了缓存图片;然后从网络下载新图片后又** 将图片存入了缓存;除此外,还做了某图片是否有缓存**等功能。
本篇我们单就研究SDImageCache这个有关缓存的类。

代码

代码很长,我们分为几部分来研究。

——初始化——

+ (SDImageCache *)sharedImageCache {
    static dispatch_once_t once;
    static id instance;
    dispatch_once(&once, ^{
        instance = [self new];
        kPNGSignatureData = [NSData dataWithBytes:kPNGSignatureBytes length:8];
    });
    return instance;
}

- (id)init {
    return [self initWithNamespace:@"default"]; // 默认是使用"default"命名空间的
}

- (id)initWithNamespace:(NSString *)ns {
    if ((self = [super init])) {
        
        NSString *fullNamespace = [@"com.hackemist.SDWebImageCache." stringByAppendingString:ns];
        
        // 初始化默认的缓存时间
        _maxCacheAge = kDefaultCacheMaxCacheAge; // 1 week
        
        _memCache = [[NSCache alloc] init]; // 内存缓存是直接使用的NSCache
        _memCache.name = fullNamespace;

        // 磁盘缓存的路径
        NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
        _diskCachePath = [paths[0] stringByAppendingPathComponent:fullNamespace];
        

        // 创建一个专门的串行队列,用于磁盘读写
        _ioQueue = dispatch_queue_create("com.hackemist.SDWebImageCache", DISPATCH_QUEUE_SERIAL);
        // 初始化文件管理器(在自己创建的队列中同步执行)
        dispatch_sync(_ioQueue, ^{
            _fileManager = [NSFileManager new];
        });

#if TARGET_OS_IPHONE
        // Subscribe to app events
        [[NSNotificationCenter defaultCenter] addObserver:self
                                                 selector:@selector(clearMemory)
                                                     name:UIApplicationDidReceiveMemoryWarningNotification
                                                   object:nil];

        [[NSNotificationCenter defaultCenter] addObserver:self
                                                 selector:@selector(cleanDisk)
                                                     name:UIApplicationWillTerminateNotification
                                                   object:nil];

        [[NSNotificationCenter defaultCenter] addObserver:self
                                                 selector:@selector(backgroundCleanDisk)
                                                     name:UIApplicationDidEnterBackgroundNotification
                                                   object:nil];
#endif
    }

    return self;
}

// 在dealloc中移除观察和销毁队列
- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self];
    SDDispatchQueueRelease(_ioQueue);
}

First:可以看到第一个方法是非常熟悉的单例方法,用于生成一个唯一的实例。需要注意的是其中也同时初始化了kPNGSignatureData这个变量,它是NSData型的,代表PNG图片的签名数据(或者叫前缀数据也可),它是用来判断图片是否是PNG格式图片的。其原理是:PNG图片很容易检测,因为它拥有一个独特的签名,PNG文件的前八字节经常包含如下(十进制)的数值137 80 78 71 13 10 26 10。我们正可据此鉴别PNG文件。其实该类的一开头就有以下一段代码,声明并定义了C函数ImageDataHasPNGPreffix来完成此功能。

// PNG signature bytes and data (below)
static unsigned char kPNGSignatureBytes[8] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
static NSData *kPNGSignatureData = nil;

// data数据是否以PNG开头
BOOL ImageDataHasPNGPreffix(NSData *data); // C函数声明

BOOL ImageDataHasPNGPreffix(NSData *data) { // C函数定义
    NSUInteger pngSignatureLength = [kPNGSignatureData length];
    if ([data length] >= pngSignatureLength) {
        if ([[data subdataWithRange:NSMakeRange(0, pngSignatureLength)] isEqualToData:kPNGSignatureData]) {
            return YES;
        }
    }

    return NO;
}

** Second:**然后我们看到重写了init初始化方法,在其中调用的是方法initWithNamespace:(全能初始化方法)。在初始化方法内首先设置了最大缓存时间,默认为一周;然后初始化了内存缓存,内存缓存用的原生的NSCache;然后初始化了磁盘缓存的路径;接着又自己创建了一个专门用于磁盘读写的串行队列,紧接着初始化了文件管理器_fileManager

最后添加了三个观察者,用于监听3种APP的状态,每种状态都会触发执行一个方法。分别是,当收到内存警告时便执行clearMemory方法,清空内存缓存;当程序被终止时执行cleanDisk方法,清理磁盘缓存;当程序进入后台状态时执行backgroundCleanDisk方法,向系统“借时间”清理磁盘缓存。(请注意"清空-clear"和"清扫-clean"的差别)

// 收到内存警告时,清空内存缓存
- (void)clearMemory {
    [self.memCache removeAllObjects];
}

// 当程序被终止时,清扫磁盘
- (void)cleanDisk {
    [self cleanDiskWithCompletionBlock:nil];
}

// 当程序进入后台状态时,向系统“借时间”完成清扫磁盘的动作
- (void)backgroundCleanDisk {
    UIApplication *application = [UIApplication sharedApplication];
    // 开启后台长时间任务
    __block UIBackgroundTaskIdentifier bgTask = [application beginBackgroundTaskWithExpirationHandler:^{
        [application endBackgroundTask:bgTask]; // 若到了系统规定的时间(一般是10分钟),则会在此调用这个方法,结束后台运行任务。
        bgTask = UIBackgroundTaskInvalid;
    }];

    [self cleanDiskWithCompletionBlock:^{
        [application endBackgroundTask:bgTask]; // 当任务完成时,也调用此方法,主动结束后台运行任务。
        bgTask = UIBackgroundTaskInvalid;
    }];
}

第一个方法很简单,调用NSCache的实例方法removeAllObjects就行了;第二个和第三个方法都是调用了cleanDiskWithCompletionBlock:这个方法来实现清扫磁盘,所不同的是程序进入后台状态时,iOS系统默认只留给程序秒级别的时间处理一些未完成的动作,而我们清扫磁盘是个耗时的任务,所以得向系统“多借点时间”以保证我们能完成磁盘清扫的任务。开启后台长时间任务的代码上面已经注释的比较清楚了,若要详细了解可以看看这篇文章:ios在后台 完成一个长期任务。这儿我们的重点是搞明白清扫缓存的方法cleanDiskWithCompletionBlock

——清扫磁盘缓存——

其清扫磁盘缓存的逻辑简单来说是:一上来就清除过期的文件;然后判断此时的缓存文件大小是否小于设置的最大大小。若大于最大大小,则进行第二轮的清扫,清扫到缓存文件大小为设置的最大大小的一半。

// 清扫磁盘
- (void)cleanDiskWithCompletionBlock:(void (^)())completionBlock {
    dispatch_async(self.ioQueue, ^{
        NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES]; // 缓存文件的路径
        // 将要获取文件的3个属性(URL是否为目录;内容最后更新日期;文件总的分配大小)
        NSArray *resourceKeys = @[NSURLIsDirectoryKey, NSURLContentModificationDateKey,NSURLTotalFileAllocatedSizeKey];

        // 使用目录枚举器获取缓存文件有用的属性
        NSDirectoryEnumerator *fileEnumerator = [_fileManager enumeratorAtURL:diskCacheURL
                                                   includingPropertiesForKeys:resourceKeys
                                                                      options:NSDirectoryEnumerationSkipsHiddenFiles
                                                                 errorHandler:NULL];

        NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:-self.maxCacheAge];
        NSMutableDictionary *cacheFiles = [NSMutableDictionary dictionary];
        NSUInteger currentCacheSize = 0;
        
        // 枚举缓存目录的所有文件,此循环有两个目的:
        // 1.清除超过过期日期的文件;
        // 2.
        NSMutableArray *urlsToDelete = [[NSMutableArray alloc] init];
        for (NSURL *fileURL in fileEnumerator) {
            NSDictionary *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:NULL]; // 传入想获得的该URL路径文件的属性数组,得到这些属性字典。

            // 若该URL是目录,则跳过。
            if ([resourceValues[NSURLIsDirectoryKey] boolValue]) {
                continue;
            }

            // 清除过期文件
            NSDate *modificationDate = resourceValues[NSURLContentModificationDateKey];
            if ([[modificationDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
                [urlsToDelete addObject:fileURL]; // 把过期的文件url暂时先置于urlsToDelete数组中
                continue;
            }

            // 计算文件总的大小并保存保留下来的文件的引用。
            NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
            currentCacheSize += [totalAllocatedSize unsignedIntegerValue];
            [cacheFiles setObject:resourceValues forKey:fileURL];
        }
        
        for (NSURL *fileURL in urlsToDelete) {
            [_fileManager removeItemAtURL:fileURL error:nil];
        }

        
        // 如果剩下的磁盘缓存文件仍然大于我们设置的最大大小,则要执行以大小为基础的第二轮清除
        if (self.maxCacheSize > 0 && currentCacheSize > self.maxCacheSize) {
            // 此轮清理的目标是最大缓存的一半
            const NSUInteger desiredCacheSize = self.maxCacheSize / 2;

            // 以剩下的文件最后更新时间排序(最老的最先被清除)
            NSArray *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent
                                                            usingComparator:^NSComparisonResult(id obj1, id obj2) {
                                                                return [obj1[NSURLContentModificationDateKey] compare:obj2[NSURLContentModificationDateKey]];
                                                            }];

            // 删除已排好序的文件,直到达到最大缓存限制的一半
            for (NSURL *fileURL in sortedFiles) {
                if ([_fileManager removeItemAtURL:fileURL error:nil]) {
                    NSDictionary *resourceValues = cacheFiles[fileURL];
                    NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
                    currentCacheSize -= [totalAllocatedSize unsignedIntegerValue];

                    if (currentCacheSize < desiredCacheSize) {
                        break;
                    }
                }
            }
        }
        if (completionBlock) {
            dispatch_async(dispatch_get_main_queue(), ^{
                completionBlock();
            });
        }
    });
}

上面的代码中比较重要的是,先以文件管理器_fileManager的迭代器遍历出了所有缓存文件,并且,该迭代器我们传入一个“感兴趣属性数组”,则遍历后我们就可以拿到文件的这些属性值,有“URL是否为目录”、“内容最后更新日期”、“文件总的分配大小”三个属性,这是很重要的一个步骤,因为我们后续要依此判断文件是否过期,总缓存文件大小是否超过预设最大值。

当我们清除了过期文件,并已更新当前总缓存文件大小,且将“幸存”下来的所有文件存入cacheFiles字典中,以fileURLkeyresourceValuesvalue
这时需要判断幸存下来的文件们的大小是否大于缓存预设最大值。若大于,则需要继续清扫文件大小至预设值的一半。此时依据的是越早的文件优先被清扫,所以得根据“ 内容最后更新日期”这个属性来进行排序。然后遍历排序后的sortedFiles数组,边遍历边删除,同时更新幸存文件们的总大小,一旦达到预设值的一半,则退出。

——写入缓存——

其存入缓存的逻辑是:首先将图片存入内存缓存,若需要存入磁盘,则存入磁盘。其中的细节是存入本地的是NSData数据,因此需要判断数据源image是何种格式的,再相应的由image生成data。最后以图片urlMD5计算过后的字符串再拼接出完成文件路径,遂新建一个文件,将data存入。代码中的注释很全了,就不再多解释了。

- (void)storeImage:(UIImage *)image forKey:(NSString *)key {
    [self storeImage:image recalculateFromImage:YES imageData:nil forKey:key toDisk:YES];
}

- (void)storeImage:(UIImage *)image forKey:(NSString *)key toDisk:(BOOL)toDisk {
    [self storeImage:image recalculateFromImage:YES imageData:nil forKey:key toDisk:toDisk];
}

// 存入缓存
- (void)storeImage:(UIImage *)image recalculateFromImage:(BOOL)recalculate imageData:(NSData *)imageData forKey:(NSString *)key toDisk:(BOOL)toDisk {
    if (!image || !key) {
        return;
    }
    // 先存入内存缓存
    // cost意为"成本"(http://www.jianshu.com/p/9a9fb9c4110f)
    [self.memCache setObject:image forKey:key cost:image.size.height * image.size.width * image.scale];

    if (toDisk) {
        dispatch_async(self.ioQueue, ^{
            NSData *data = imageData;

            if (image && (recalculate || !data)) {
                // 如果imageData为nil(也就是说,如果试图直接保存一个UIImage或者图片是由下载转换得来)并且图片有alpha通道,
                // 我们将认为它是PNG文件以避免丢失透明度信息。
                BOOL imageIsPng = YES;

                // // 但是如果我们有image data,我们将查询数据前缀来判断是否是PNG图片
                if ([imageData length] >= [kPNGSignatureData length]) {
                    imageIsPng = ImageDataHasPNGPreffix(imageData);
                }

                if (imageIsPng) {
                    data = UIImagePNGRepresentation(image);
                }
                else {
                    data = UIImageJPEGRepresentation(image, (CGFloat)1.0);
                }
            }

            if (data) {
                if (![_fileManager fileExistsAtPath:_diskCachePath]) {
                    [_fileManager createDirectoryAtPath:_diskCachePath withIntermediateDirectories:YES attributes:nil error:NULL];
                }

                // 在磁盘新建专门的文件,并写入图片数据
                [_fileManager createFileAtPath:[self defaultCachePathForKey:key] contents:data attributes:nil];
            }
        });
    }
}

上面的代码有个需要说明的地方是:上面的代码中调用的是方法defaultCachePathForKey:,它们的细节是下面这样的。** 它把图片的url字符串先MD5计算成新的字符串,然后拼接出缓存文件的完整路径。**

- (NSString *)defaultCachePathForKey:(NSString *)key {
    return [self cachePathForKey:key inPath:self.diskCachePath];
}

- (NSString *)cachePathForKey:(NSString *)key inPath:(NSString *)path {
    NSString *filename = [self cachedFileNameForKey:key];
    return [path stringByAppendingPathComponent:filename];
}

// MD5计算:将图片的URL字符串进行MD5计算
- (NSString *)cachedFileNameForKey:(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);
    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]];

    return filename;
}

——读出缓存——

读出缓存的逻辑是:** 一上来先从内存缓存中读取,若有则回调,一切结束;若无则继续从磁盘缓存中查找。找到后,先将图片存入内存缓存,随即再回调。**

// 从缓存中查找图片
- (NSOperation *)queryDiskCacheForKey:(NSString *)key done:(void (^)(UIImage *image, SDImageCacheType cacheType))doneBlock {
    if (!doneBlock) {
        return nil;
    }

    if (!key) {
        doneBlock(nil, SDImageCacheTypeNone);
        return nil;
    }

    // 首先从内存缓存中查找(NSCache中以url字符串为key)
    UIImage *image = [self imageFromMemoryCacheForKey:key];
    if (image) {
        doneBlock(image, SDImageCacheTypeMemory);
        return nil;
    }

    // 执行到此,说明在内存缓存中没有找到,需要在磁盘中查找。
    NSOperation *operation = [NSOperation new];
    dispatch_async(self.ioQueue, ^{
        if (operation.isCancelled) {
            return;
        }

        @autoreleasepool {
            UIImage *diskImage = [self diskImageForKey:key]; // 从磁盘查找
            if (diskImage) {
                CGFloat cost = diskImage.size.height * diskImage.size.width * diskImage.scale;
                [self.memCache setObject:diskImage forKey:key cost:cost];  // 若从磁盘找到,则先将其添加到内存缓存中。
            }

            dispatch_async(dispatch_get_main_queue(), ^{
                doneBlock(diskImage, SDImageCacheTypeDisk); // 然后将其block回调
            });
        }
    });

    return operation;
}
// 某key对应的内存缓存
- (UIImage *)imageFromMemoryCacheForKey:(NSString *)key {
    return [self.memCache objectForKey:key];
}

可以看到上面这个方法返回值类型是NSOperation的,当查询磁盘时创建了一个operation对象作为return对象,这是为了管理查询动作,能取消操作等。另外,查询磁盘的动作是** 异步在串行队列 **执行的。同时,还自建了自动释放池,以能及时释放对象内存。最后查找到图片后要回到主线程回调,别忘记此时是异步的哦。

其实查询磁盘缓存的核心是方法:diskImageForKey:,它的实现是这样的:

// 以key为索引在磁盘中查找出image
- (UIImage *)diskImageForKey:(NSString *)key {
    NSData *data = [self diskImageDataBySearchingAllPathsForKey:key]; // 由key查找出图片,不过是NSData型的
    if (data) {
        UIImage *image = [UIImage sd_imageWithData:data]; // 由NSData转换为UIImage
        image = [self scaledImageForKey:key image:image];
        image = [UIImage decodedImageWithImage:image];
        return image;
    }
    else {
        return nil;
    }
}

结尾

SDWebImage的缓存机制都封装在了SDImageCache里,这个类至此也说得差不多了。可能还要研究研究SDWebImage网络图片下载的代码,下篇吧。
边写边循环了好多遍朴树的《平凡之路》

SDWebImage源码阅读2——缓存机制_第1张图片
平凡之路_朴树.jpg

更新 10.27

SDWebImage源码阅读2——缓存机制_第2张图片
mine.png

最近项目中要代码出了个bug,记录一下。这儿的“智慧校园”是个UITableViewCell,图片是网络图片。这里需要解决的是不仅要将网络图片显示出来,还要保证图片不变形。得根据网络图片的尺寸,结合手机屏幕的宽度计算出应该显示的高度。这就和UITableViewCell的刷新cell的内容方法refreshContent:,和计算cell高度方法cellHeight:有关了。由url得到UIImage一开始我这么写的。

UIImage *image = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:imgUrl]]];

但是这么写是有问题的,每次进入这个界面时会有个卡顿感,这是因为这个加载时同步的,当然会卡顿,所以这种方式要慎用。我们应该异步加载这个图片。此时完全可以用SDWebImage这个库的方法:异步加载网络图片,然后在block回调里返回UIImage

        __block CustomImageView *weakContentView = _contentView;
        __weak CourseSelectionCell *weakSelf = self;
        [_contentView setImageWithURL:url placeholderImage:IMAGE(@"zhihuixiaoyuan") options:SDWebImageRetryFailed  completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType) {
            if (image) {
                weakContentView.image = image;
                weakSelf.imgHasLoadedBlock();
            }else{
                weakContentView.image = IMAGE(@"zhihuixiaoyuan");
            }

虽然它是异步请求的,但也不能每次都从网络加载啊,毕竟SDWebImage是有缓存功能的。SDWebImage缓存是以NSURL为键,以UIImage为值进行缓存的。我们先判断该url是否有对应的缓存图片,若有则取对应的缓存图片。若无则再从网络加载。

    SDWebImageManager *manager = [SDWebImageManager sharedManager];
    
    NSURL *url=[NSURL URLWithString:imgUrl];
    if ([manager diskImageExistsForURL:url]) {
        UIImage *img = [[manager imageCache] imageFromDiskCacheForKey:url.absoluteString];
        [_contentView setImage:img];
    }
    else
    {
        __block CustomImageView *weakContentView = _contentView;
        __weak CourseSelectionCell *weakSelf = self;
        [_contentView setImageWithURL:url placeholderImage:IMAGE(@"zhihuixiaoyuan") options:SDWebImageRetryFailed  completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType) {
            if (image) {
                weakContentView.image = image;
                weakSelf.imgHasLoadedBlock();
            }else{
                weakContentView.image = IMAGE(@"zhihuixiaoyuan");
            }
        }] ;
    }

接下来就是在计算UITableViewCell高度这个方法的问题了。这儿的高度是根据图片的尺寸动态算出来的,所以说也得从网络加载,但是该方法是个类方法,它无法像上面一样调用SDWebImage的方法setImageWithURL:
** 注意,下面这个计算高度的方法里,都是取缓存的图片。那要是某url没缓存呢?**上面刷新cell内容的方法里其实已经写了,在block回调里得到图片后不仅给视图赋了值,而且还调用了一个定义好的imgHasLoadedBlockblock。该block的实现刷新了该row,就会重新执行该cell里面的方法,此时就能在cellHeight:方法里拿到缓存图片了,因为已经在第一次执行时加载过了。我们来看VC中该block的实现:

        selectCell.imgHasLoadedBlock = ^{
            
            [_myTableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
        };
+ (CGFloat)cellHeight:(NSString *)imgUrl
{
//  UIImage *image = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:imgUrl]]];
    if(imgUrl.length>0){
        SDWebImageManager *manager = [SDWebImageManager sharedManager];
        NSURL *url=[NSURL URLWithString:imgUrl];
        if ([manager diskImageExistsForURL:url]) {
            UIImage *img = [[manager imageCache] imageFromDiskCacheForKey:url.absoluteString];
            return ((PDWidth_mainScreen-30.f)*img.size.height)/img.size.width+20.f;
        }
    }
    return 70.f;
}

你可能感兴趣的:(SDWebImage源码阅读2——缓存机制)