美文网首页第三方框架学习iOS学习iOS进阶
SDWebImage源码阅读2——缓存机制

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

作者: Wang66 | 来源:发表于2016-08-03 23:55 被阅读628次

    前言

    继上篇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网络图片下载的代码,下篇吧。
    边写边循环了好多遍朴树的《平凡之路》

    平凡之路_朴树.jpg

    更新 10.27

    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——缓存机制

      本文链接:https://www.haomeiwen.com/subject/brxcsttx.html