SDWebImage (5.0.6) 图片加载奇淫巧技

最近在研究图片缓存框架,所以SDWebImage一定是我研究的不二选择,下面就简单讲述一下SDWebImage是如何加载图片的,以及加载过程中涉及到的一些骚操作。

这里先强调一点SDWebImage加载图片过程中的两个骚操作:
a. 如何避免同一时间多个请求,请求同一张图片下载多次问题。
b. 如何解决TableViewCell 复用时导致的图片展示错乱问题。
下文中会对这两个问题给出答案,好了不扯淡了,进入正题。

当我们使用SDWebImage加载图片时需要调用如下方法:

1
2
3
- (void)sd_setImageWithURL:(nullable NSURL *)url {
[self sd_setImageWithURL:url placeholderImage:nil options:0 progress:nil completed:nil];
}

之后进行一系列的传递会传递到最深层的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)sd_setImageWithURL:(nullable NSURL *)url placeholderImage:(nullable UIImage *)placeholder
options:(SDWebImageOptions)options
context:(nullable SDWebImageContext *)context
progress:(nullable SDImageLoaderProgressBlock)progressBlock
completed:(nullable SDExternalCompletionBlock)completedBlock {
[self sd_internalSetImageWithURL:url placeholderImage:placeholder options:options context:context setImageBlock:nil
progress:progressBlock
completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error,
SDImageCacheType cacheType, BOOL finished, NSURL * _Nullable imageURL) {
if (completedBlock) {
completedBlock(image, error, cacheType, imageURL);
}
}];
}

可以看到,这个方法里面调用了UIView+Webcache分类里面的一个方法:

1
2
3
4
5
6
7
8
- (void)sd_internalSetImageWithURL:(nullable NSURL *)url placeholderImage:(nullable UIImage *)placeholder
options:(SDWebImageOptions)options
context:(nullable SDWebImageContext *)context
setImageBlock:(nullable SDSetImageBlock)setImageBlock
progress:(nullable SDImageLoaderProgressBlock)progressBlock
completed:(nullable SDInternalCompletionBlock)completedBlock {
......
}

这个方法就是我们加载图片的正式入口方法。下面我们看一下这个方法里面都主要做了什么。
第一步,根据validOperationKey 取消掉正在执行的操作operation如下调用:

1
2
3
4
5
6
NSString *validOperationKey = context[SDWebImageContextSetImageOperationKey];
if (!validOperationKey) {
validOperationKey = NSStringFromClass([self class]);
}
self.sd_latestOperationKey = validOperationKey;
[self sd_cancelImageLoadOperationWithKey:validOperationKey];

sd_cancelImageLoadOperationWithKey: 方法的内部实现会查询到已经存在的同名任务,并且会取消掉这个任务,并在当前view的operationDictionary 容器中移除掉。源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (void)sd_cancelImageLoadOperationWithKey:(nullable NSString *)key {
if (key) {
// Cancel in progress downloader from queue
SDOperationsDictionary *operationDictionary = [self sd_operationDictionary];
id<SDWebImageOperation> operation;

@synchronized (self) {
operation = [operationDictionary objectForKey:key];
}
if (operation) {
if ([operation conformsToProtocol:@protocol(SDWebImageOperation)]) {
[operation cancel];
}
@synchronized (self) {
[operationDictionary removeObjectForKey:key];
}
}
}
}

这里需要说明一下:[self sd_operationDictionary]这个调用,这个方法的实现是给当前View通过关联对象的技术关联了一个NSMapTable对象,用来存储请求链接接对应的请求操作类型如NSMapTable<NSString *, id>。源码如下:

1
2
3
4
5
6
7
8
9
10
11
- (SDOperationsDictionary *)sd_operationDictionary {
@synchronized(self) {
SDOperationsDictionary *operations = objc_getAssociatedObject(self, &loadOperationKey);
if (operations) {
return operations;
}
operations = [[NSMapTable alloc] initWithKeyOptions:NSPointerFunctionsStrongMemory valueOptions:NSPointerFunctionsWeakMemory capacity:0];
objc_setAssociatedObject(self, &loadOperationKey, operations, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
return operations;
}
}

绕了这么大一圈,你可能会问,为什么一上来要调用sd_cancelImageLoadOperationWithKey:这个方法?通过上面的源码分析SDWebImage这样设计是为了解决TableViewCell复用时,如果被复用的Cell的ImageView请求的图片没有回调时展示图片错乱的问题。原理就是如果被复用的Cell的ImageView之前请求的图片还没有回调,而此时需要请求新的图片,那么就取消掉之前的请求operation,并从operationDictionary中移除掉。然后去加载需要加载的新图片。如果说,之前的图片请求在这之后回调回来的话,会判断之前请求的operation是否存在,以及operation的isCancel属性,如果不存在或者isCancel=Yes的话,就不会回调到UI界面。也就是如下代码逻辑:

1
2
3
4
5
6
7
8
@weakify(operation);
operation.loaderOperation = [self.imageLoader requestImageWithURL:url options:options context:context progress:progressBlock completed:^(UIImage *downloadedImage, NSData *downloadedData, NSError *error, BOOL finished) {
@strongify(operation);
if (!operation || operation.isCancelled) {
// Do nothing if the operation was cancelled
// See #699 for more details
// if we would call the completedBlock, there could be a race condition between this block and another completedBlock for the same object, so if this one is called second, we will overwrite the new data
}

说了这么多,相信应该清楚为什么要调用sd_cancelImageLoadOperationWithKey:方法了,我们接着回到sd_internalSetImageWithURL:方法中,cancel之后就会清掉当前imageView上次下载的图片:

1
2
3
4
5
if (!(options & SDWebImageDelayPlaceholder)) {
dispatch_main_async_safe(^{
[self sd_setImage:placeholder imageData:nil basedOnClassOrViaCustomSetImageBlock:setImageBlock cacheType:SDImageCacheTypeNone imageURL:url];
});
}

这里可以解释,复用的时候,已经展示过图片的imageView为什么在被复用的时候没有展示之前存在的图片而是展示placeholer或者不展示的原因。
接下来,就是判断我们传入的url是否合法,以及设置UIImageView的加载指示器,还有加载进度block,此处不做详细说明了。我们着重看加载图片的方法:

1
2
3
id <SDWebImageOperation> operation = [manager loadImageWithURL:url options:options context:context progress:combinedProgressBlock completed:^(UIImage *image, NSData *data, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) {
......
}

这里当前view利用前面生成的manager 去加载我们需要的图片,并把获取的结果回调给了上一级调用方。从上面的代码可以看到,获取图片的同时返回了一个operation,这个operation就是标识获取当前url图片的一个操作。之后会把这个operation放在当前view的operationDictionary中:

1
[self sd_setImageLoadOperation:operation forKey:validOperationKey];

sd_setImageLoadOperation:内部实现如下:

1
2
3
4
5
6
7
8
9
10
11
- (void)sd_setImageLoadOperation:(nullable id<SDWebImageOperation>)operation forKey:(nullable NSString *)key {
if (key) {
[self sd_cancelImageLoadOperationWithKey:key];
if (operation) {
SDOperationsDictionary *operationDictionary = [self sd_operationDictionary];
@synchronized (self) {
[operationDictionary setObject:operation forKey:key];
}
}
}
}

这也是程序一开始时,能够取消掉同名operation的原因。就是同一个view发送一个图片请求就会记录在operationDictionary中来标识有请求正在执行。
我们接着看loadImageWithURL:方法内部实现:
首先,判断url是否合法,然后生成一个请求图片的operation,这个和我们刚才讲到的operation在内存中是同一个,因为是从该方法中返回出去的。
其次,将这个operation添加到正在运行的操作容器中:

1
2
3
SD_LOCK(self.runningOperationsLock);
[self.runningOperations addObject:operation];
SD_UNLOCK(self.runningOperationsLock);

之后进入重点,那就是开始从缓存中读取图片:

1
2
// Start the entry to load image from cache
[self callCacheProcessForOperation:operation url:url options:options context:context progress:progressBlock completed:completedBlock];

同样的,将我们刚才讲到的operation传入到这个方法中。我们看一下这个方法中做了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Query cache process
- (void)callCacheProcessForOperation:(nonnull SDWebImageCombinedOperation *)operation
url:(nonnull NSURL *)url
options:(SDWebImageOptions)options
context:(nullable SDWebImageContext *)context
progress:(nullable SDImageLoaderProgressBlock)progressBlock
completed:(nullable SDInternalCompletionBlock)completedBlock {
// Check whether we should query cache
BOOL shouldQueryCache = (options & SDWebImageFromLoaderOnly) == 0;
if (shouldQueryCache) {
id<SDWebImageCacheKeyFilter> cacheKeyFilter = context[SDWebImageContextCacheKeyFilter];
NSString *key = [self cacheKeyForURL:url cacheKeyFilter:cacheKeyFilter];
@weakify(operation);
operation.cacheOperation = [self.imageCache queryImageForKey:key options:options context:context completion:^(UIImage * _Nullable cachedImage, NSData * _Nullable cachedData, SDImageCacheType cacheType) {
@strongify(operation);
if (!operation || operation.isCancelled) {
[self safelyRemoveOperationFromRunning:operation];
return;
}
// Continue download process
[self callDownloadProcessForOperation:operation url:url options:options context:context cachedImage:cachedImage cachedData:cachedData cacheType:cacheType progress:progressBlock completed:completedBlock];
}];
} else {
// Continue download process
[self callDownloadProcessForOperation:operation url:url options:options context:context cachedImage:nil cachedData:nil cacheType:SDImageCacheTypeNone progress:progressBlock completed:completedBlock];
}
}

从上面的源码可以看出,
首先判断是否需要从缓存中读取图片,如果需要,就处理url,处理后得到我们读取缓存的key。
然后,开始从缓存中读取图片,回调之后判断当前operation是否还存在,以及operation是否被取消,如果取消的话就从runningOperations中移除当前operation并返回,什么也不做。否则,调用下载处理程序:callDownloadProcessForOperation:并把我们读取出来的缓存数据传入该方法。接下来我们看看这个方法的内部实现:
首先判断是否需要下载图片,如果不需要就判断缓存数据如果缓存有值就直接返回给调用方,如果需要就先看一下之前读取的缓存数据是否有值,如果有值,就直接返回给调用方。如果没有的话,就使用imageLoader下载图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// `SDWebImageCombinedOperation` -> `SDWebImageDownloadToken` -> `downloadOperationCancelToken`, which is a `SDCallbacksDictionary` and retain the completed block below, so we need weak-strong again to avoid retain cycle
@weakify(operation);
operation.loaderOperation = [self.imageLoader requestImageWithURL:url options:options context:context progress:progressBlock completed:^(UIImage *downloadedImage, NSData *downloadedData, NSError *error, BOOL finished) {
@strongify(operation);
if (!operation || operation.isCancelled) {
// Do nothing if the operation was cancelled
// See #699 for more details
// if we would call the completedBlock, there could be a race condition between this block and another completedBlock for the same object, so if this one is called second, we will overwrite the new data
} else if (cachedImage && options & SDWebImageRefreshCached && [error.domain isEqualToString:SDWebImageErrorDomain] && error.code == SDWebImageErrorCacheNotModified) {
// Image refresh hit the NSURLCache cache, do not call the completion block
} else if (error) {
[self callCompletionBlockForOperation:operation completion:completedBlock error:error url:url];
BOOL shouldBlockFailedURL = [self shouldBlockFailedURLWithURL:url error:error];

if (shouldBlockFailedURL) {
SD_LOCK(self.failedURLsLock);
[self.failedURLs addObject:url];
SD_UNLOCK(self.failedURLsLock);
}
} else {
if ((options & SDWebImageRetryFailed)) {
SD_LOCK(self.failedURLsLock);
[self.failedURLs removeObject:url];
SD_UNLOCK(self.failedURLsLock);
}
[self callStoreCacheProcessForOperation:operation url:url
options:options context:context
downloadedImage:downloadedImage
downloadedData:downloadedData
finished:finished
progress:progressBlock
completed:completedBlock];
}

if (finished) {
[self safelyRemoveOperationFromRunning:operation];
}
}];

从上面的源码中可以看出请求图片的回调回来后:
1.如果operation不存在或者被取消,什么也不处理
2.如果有error则直接回调错误信息,并把当前url加入到filedURLs中。
3.如果一切正常,则把错误请求从filedURLs中移除,并把下载好的图片数据传递到缓存处理程序。
4.最后,如果finished==YES,则把当前operation从runningOperations中移除。

接下来我们看一下这个方法的内部实现:
首先处理一些下载器选项,然后调用下载图片方法:

1
return [self downloadImageWithURL:url options:downloaderOptions context:context progress:progressBlock completed:completedBlock];

接着看上面这个方法的内部实现:
首先判断url是否合法,如果合法,从下载器的URLOperations属性中读取该url对应的operation,如果operation不存在,或者已经取消或者已经完成,则根据url重新生成一个operation,同时记录该operation到URLOperations中,并把该operation添加到下载队列中去:

1
2
3
4
self.URLOperations[url] = operation;
// Add operation to operation queue only after all configuration done according to Apple's doc.
// `addOperation:` does not synchronously execute the `operation.completionBlock` so this will not cause deadlock.
[self.downloadQueue addOperation:operation];

如果存在operation,但是operation没有正在执行,则根据条件调整operation的请求优先级。
如果有正在执行的operation,不创建新的请求operation,而是给当前operation添加回调对象progressBlock 和 completedBlock。

1
id downloadOperationCancelToken = [operation addHandlersForProgress:progressBlock completed:completedBlock];

看下这个方法的内部实现:

1
2
3
4
5
6
7
8
9
10
- (nullable id)addHandlersForProgress:(nullable SDWebImageDownloaderProgressBlock)progressBlock
completed:(nullable SDWebImageDownloaderCompletedBlock)completedBlock {
SDCallbacksDictionary *callbacks = [NSMutableDictionary new];
if (progressBlock) callbacks[kProgressCallbackKey] = [progressBlock copy];
if (completedBlock) callbacks[kCompletedCallbackKey] = [completedBlock copy];
SD_LOCK(self.callbacksLock);
[self.callbackBlocks addObject:callbacks];
SD_UNLOCK(self.callbacksLock);
return callbacks;
}

从中可以看出一个ImageDownloaderOperation可以有多个回调block。
那么问题来了,SDWebImage为什么会这么设计呢?
答案是为了解决在同一时间,多个请求同时下载一张图片的时候,对该图片请求只下载一次。也就是请求只发送一次,而请求有结果的时候根据存储的多个返回block 依次返回给调用方。这方法是不是很机智。这一点也可从请求结果的代码中得到验证:

1
2
3
4
5
6
7
8
9
10
11
- (void)callCompletionBlocksWithImage:(nullable UIImage *)image
imageData:(nullable NSData *)imageData
error:(nullable NSError *)error
finished:(BOOL)finished {
NSArray<id> *completionBlocks = [self callbacksForKey:kCompletedCallbackKey];
dispatch_main_async_safe(^{
for (SDWebImageDownloaderCompletedBlock completedBlock in completionBlocks) {
completedBlock(image, imageData, error, finished);
}
});
}

从上面的代码中可以看到,方法内部是遍历了所有需要完成回调的completedBlock,然后回调出去。

到这里,SDWebImage加载图片的主要流程就已经基本讲完,本文中着重讲述了下载流程,并没有对缓存部分做详细说明,后面会抽时间完善。欢迎勘误。
PS:建议在阅读本文时和源码一起阅读。

谢谢您的支持!