读源码之 SDWebImage

cubegao 2017-11-02 PM 498℃ 0条

背景

SDWebImageiOS开发过程中最常使用的一个网络图片库,包括图片的下载和缓存。

主要提供的功能如下:

1.提供UIImageView, UIButton, MKAnnotationView 的分类,用来加载网络图片,并进行缓存管理;
2.异步方式来下载网络图片
3.异步方式: memory (内存)+ disk (磁盘) 来缓存网络图片,LRU自动管理缓存;
4.后台图片解码,转换及压缩;
5.同一个 URL 不会重复下载;
6.失效的 URL 不会被无限重试;
7.支持 GIF动画 及 WebP 格式;
8.使用 GCD ARC;
9.开启子线程进行耗时操作,不阻塞主线程;

入口部分

在使用SDWebImage的时候,入口如下:

[self.imageView sd_setImageWithURL:[NSURL URLWithString:@"url"] 
                placeholderImage:[UIImage imageNamed:@"placeholder.png"]];

点进去,可以看到下面这些方法:

- (void)sd_setImageWithURL:(NSURL *)url;
- (void)sd_setImageWithURL:(NSURL *)url placeholderImage:(UIImage *)placeholder;
- (void)sd_setImageWithURL:(NSURL *)url placeholderImage:(UIImage *)placeholder options:(SDWebImageOptions)options;
...
- (void)sd_setImageWithURL:(NSURL *)url placeholderImage:(UIImage *)placeholder options:(SDWebImageOptions)options progress:(SDWebImageDownloaderProgressBlock)progressBlock completed:(SDWebImageCompletionBlock)completedBlock;

作为入口函数,在 sd_setImageWithURL 方法中采用了多种参数灵活搭配的同名方法。而内部实质,都在向最后一个 sd_setImageWithURL 传入参数最多的方法进行调用处理。可以清晰的梳理函数构造逻辑,减轻代码编写量。

下载部分

整个下载管理器对于下载请求的管理都是放在downloadImageWithURL:options:progress:completed:方法里面来处理的,该方法调用了上面所提到的addProgressCallback:andCompletedBlock:forURL:createCallback:方法来将请求的信息存入管理器中,同时在创建回调的block中创建新的操作,配置之后将其放入downloadQueue操作队列中,最后方法返回新创建的操作。每个下载操作的超时时间可以通过downloadTimeout属性来设置,默认值为15秒。

其具体实现如下:

- (id <SDWebImageOperation>)downloadImageWithURL:(NSURL *)url options:(SDWebImageDownloaderOptions)options progress:(SDWebImageDownloaderProgressBlock)progressBlock completed:(SDWebImageDownloaderCompletedBlock)completedBlock {
    ...
    
    [self addProgressCallback:progressBlock andCompletedBlock:completedBlock forURL:url createCallback:^{
        ...
        
        // 1. 创建请求对象,并根据options参数设置其属性
        // 为了避免潜在的重复缓存(NSURLCache + SDImageCache),如果没有明确告知需要缓存,则禁用图片请求的缓存操作
        NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:url cachePolicy:(options & SDWebImageDownloaderUseNSURLCache ? NSURLRequestUseProtocolCachePolicy : NSURLRequestReloadIgnoringLocalCacheData) timeoutInterval:timeoutInterval];
        ...
        
        // 2. 创建SDWebImageDownloaderOperation操作对象,并进行配置
        // 配置信息包括是否需要认证、优先级
        operation = [[wself.operationClass alloc] initWithRequest:request
                                                          options:options
                                   progress:^(NSInteger receivedSize, NSInteger expectedSize) {
                      // 3. 从管理器的callbacksForURL中找出该URL所有的进度处理回调并调用
                       ...
                    for (NSDictionary *callbacks in callbacksForURL) {
                  SDWebImageDownloaderProgressBlock callback = callbacks[kProgressCallbackKey];
                   if (callback) callback(receivedSize, expectedSize);
                                                             }
                                                         }
               completed:^(UIImage *image, NSData *data, NSError *error, BOOL finished) {
               // 4. 从管理器的callbacksForURL中找出该URL所有的完成处理回调并调用,
               // 如果finished为YES,则将该url对应的回调信息从URLCallbacks中删除
                 ...
                 if (finished) {
                 [sself removeCallbacksForURL:url];
                 }
                  for (NSDictionary *callbacks in callbacksForURL) {
                  SDWebImageDownloaderCompletedBlock callback = callbacks[kCompletedCallbackKey];
                  if (callback) callback(image, data, error, finished);
                                                            }
                                                        }
                                                        cancelled:^{
                                                            // 5. 取消操作将该url对应的回调信息从URLCallbacks中删除
                                                            SDWebImageDownloader *sself = wself;
                                                            if (!sself) return;
                                                            [sself removeCallbacksForURL:url];
                                                        }];
        
        ...
        
        // 6. 将操作加入到操作队列downloadQueue中
        // 如果是LIFO顺序,则将新的操作作为原队列中最后一个操作的依赖,然后将新操作设置为最后一个操作
        [wself.downloadQueue addOperation:operation];
        if (wself.executionOrder == SDWebImageDownloaderLIFOExecutionOrder) {
            [wself.lastAddedOperation addDependency:operation];
            wself.lastAddedOperation = operation;
        }
    }];
    
    return operation;
}

缓存部分

为了减少网络流量的消耗,我们都希望下载下来的图片缓存到本地,下次再去获取同一张图片时,可以直接从本地获取,而不再从远程服务器获取。这样做的另一个好处是提升了用户体验,用户第二次查看同一幅图片时,能快速从本地获取图片直接呈现给用户。

SDWebImage提供了对图片缓存的支持,而该功能是由SDImageCache类来完成的。该类负责处理内存缓存及一个可选的磁盘缓存。其中磁盘缓存的写操作是异步的,这样就不会对UI操作造成影响。

- (void)storeImage:(UIImage *)image recalculateFromImage:(BOOL)recalculate imageData:(NSData *)imageData forKey:(NSString *)key toDisk:(BOOL)toDisk {
    ...
    
    // 1. 内存缓存,将其存入NSCache中,同时传入图片的消耗值
    [self.memCache setObject:image forKey:key cost:image.size.height * image.size.width * image.scale * image.scale];
    
    if (toDisk) {
        // 2. 如果确定需要磁盘缓存,则将缓存操作作为一个任务放入ioQueue中
        dispatch_async(self.ioQueue, ^{
            NSData *data = imageData;
            
            if (image && (recalculate || !data)) {
#if TARGET_OS_IPHONE
                
                // 3. 需要确定图片是PNG还是JPEG。PNG图片容易检测,因为有一个唯一签名。PNG图像的前8个字节总是包含以下值:137 80 78 71 13 10 26 10
                // 在imageData为nil的情况下假定图像为PNG。我们将其当作PNG以避免丢失透明度。而当有图片数据时,我们检测其前缀,确定图片的类型
                BOOL imageIsPng = YES;
                
                if ([imageData length] >= [kPNGSignatureData length]) {
                    imageIsPng = ImageDataHasPNGPreffix(imageData);
                }
                
                if (imageIsPng) {
                    data = UIImagePNGRepresentation(image);
                }
                else {
                    data = UIImageJPEGRepresentation(image, (CGFloat)1.0);
                }
#else
                data = [NSBitmapImageRep representationOfImageRepsInArray:image.representations usingType: NSJPEGFileType properties:nil];
#endif
            }
            
            // 4. 创建缓存文件并存储图片
            if (data) {
                if (![_fileManager fileExistsAtPath:_diskCachePath]) {
                    [_fileManager createDirectoryAtPath:_diskCachePath withIntermediateDirectories:YES attributes:nil error:NULL];
                }
                
                [_fileManager createFileAtPath:[self defaultCachePathForKey:key] contents:data attributes:nil];
            }
        });
    }
}

缓存到了本地,怎么查找缓存呢?代码如下:

- (NSOperation *)queryDiskCacheForKey:(NSString *)key done:(SDWebImageQueryCompletedBlock)doneBlock {
    ...
    
    // 1. 首先查看内存缓存,如果查找到,则直接回调doneBlock并返回
    UIImage *image = [self imageFromDiskCacheForKey:key];
    if (image) {
        doneBlock(image, SDImageCacheTypeMemory);
        return nil;
    }
    
    // 2. 如果内存中没有,则在磁盘中查找。如果找到,则将其放到内存缓存,并调用doneBlock回调
    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 * diskImage.scale;
                [self.memCache setObject:diskImage forKey:key cost:cost];
            }
            
            dispatch_async(dispatch_get_main_queue(), ^{
                doneBlock(diskImage, SDImageCacheTypeDisk);
            });
        }
    });
    
    return operation;
}

而部分清理则是根据我们设定的一些参数值来移除一些文件,这里主要有两个指标:文件的缓存有效期及最大缓存空间大小。文件的缓存有效期可以通过maxCacheAge属性来设置,默认是1周的时间。如果文件的缓存时间超过这个时间值,则将其移除。而最大缓存空间大小是通过maxCacheSize属性来设置的,如果所有缓存文件的总大小超过这一大小,则会按照文件最后修改时间的逆序,以每次一半的递归来移除那些过早的文件,直到缓存的实际大小小于我们设置的最大使用空间。清理的操作在-cleanDiskWithCompletionBlock:方法中,其实现如下:

- (void)cleanDiskWithCompletionBlock:(SDWebImageNoParamsBlock)completionBlock {
    dispatch_async(self.ioQueue, ^{
        NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES];
        NSArray *resourceKeys = @[NSURLIsDirectoryKey, NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey];
        
        // 1. 该枚举器预先获取缓存文件的有用的属性
        NSDirectoryEnumerator *fileEnumerator = [_fileManager enumeratorAtURL:diskCacheURL
                                                   includingPropertiesForKeys:resourceKeys
                                                                      options:NSDirectoryEnumerationSkipsHiddenFiles
                                                                 errorHandler:NULL];
        
        NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:-self.maxCacheAge];
        NSMutableDictionary *cacheFiles = [NSMutableDictionary dictionary];
        NSUInteger currentCacheSize = 0;
        
        // 2. 枚举缓存文件夹中所有文件,该迭代有两个目的:移除比过期日期更老的文件;存储文件属性以备后面执行基于缓存大小的清理操作
        NSMutableArray *urlsToDelete = [[NSMutableArray alloc] init];
        for (NSURL *fileURL in fileEnumerator) {
            NSDictionary *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:NULL];
            
            // 3. 跳过文件夹
            if ([resourceValues[NSURLIsDirectoryKey] boolValue]) {
                continue;
            }
            
            // 4. 移除早于有效期的老文件
            NSDate *modificationDate = resourceValues[NSURLContentModificationDateKey];
            if ([[modificationDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
                [urlsToDelete addObject:fileURL];
                continue;
            }
            
            // 5. 存储文件的引用并计算所有文件的总大小,以备后用
            NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
            currentCacheSize += [totalAllocatedSize unsignedIntegerValue];
            [cacheFiles setObject:resourceValues forKey:fileURL];
        }
        
        for (NSURL *fileURL in urlsToDelete) {
            [_fileManager removeItemAtURL:fileURL error:nil];
        }
        
        // 6.如果磁盘缓存的大小大于我们配置的最大大小,则执行基于文件大小的清理,我们首先删除最老的文件
        if (self.maxCacheSize > 0 && currentCacheSize > self.maxCacheSize) {
            // 7. 以设置的最大缓存大小的一半作为清理目标
            const NSUInteger desiredCacheSize = self.maxCacheSize / 2;
            
            // 8. 按照最后修改时间来排序剩下的缓存文件
            NSArray *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent
                                                            usingComparator:^NSComparisonResult(id obj1, id obj2) {
                                                                return [obj1[NSURLContentModificationDateKey] compare:obj2[NSURLContentModificationDateKey]];
                                                            }];
            
            // 9. 删除文件,直到缓存总大小降到我们期望的大小
            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();
            });
        }
    });
}

总结

1.把图片的解码工作放到子线程(SDWebImageDecoder)。
2.在主线程中完成重采样的工作。重采样算法一般有: Nearest Neighbour Resampling (最邻近重采样)、 Bilinear Resampling(双线性/两次线性重采样)、Bicubic Resampling (双立方/两次立方重采样)等。
3.利用空间换时间的做法,在子线程中解码图片并缓存位图结果,避免图片的重复解码,提升图片展示性能。如果列表中需要展示很多网络图片,SDWebImage这种做法,有利于提高列表的流畅度。
4.SDWebImage中下载的图片,即使解码缩放(decodedAndScaledDownImageWithImage:)后,图片大小未必和imageView的大小相同,这会引发重采样,我们可以在图片显示前,将图片裁剪成和imageView的大小相同,提升性能(一个小的优化点)。

标签: 源码

非特殊说明,本博所有文章均为博主原创。

评论啦~