SDWebImage 图片归并下载的 Bug修复

图片下载中的归并下载

如图所示,其实就是把并发中的相同请求连接,回调进行绑定,真正的网络请求只维持一份,请求结束后再统一回调

SDWebImage 图片归并下载的 Bug修复_第1张图片
1.png

SDWebImage 比较早就实现了该方案

由于 SD 最新的 4.x 跟 3.x api 上不兼容,导致我们替换起来很麻烦,而且核心功能并没有变化,所以我们还是使用 3.x 当中最新的 3.8.2 版本。

下面我们 走入 SDWebImage 的源码,了解下 SD 中的归并下载是如何实现的。

- (id )downloadImageWithURL:(NSURL *)url
                                         options:(SDWebImageOptions)options
                                        progress:(SDWebImageDownloaderProgressBlock)progressBlock
                                       completed:(SDWebImageCompletionWithFinishedBlock)completedBlock {
    // Invoking this method without a completedBlock is pointless
    NSAssert(completedBlock != nil, @"If you mean to prefetch the image, use -[SDWebImagePrefetcher prefetchURLs] instead");

    // Very common mistake is to send the URL using NSString object instead of NSURL. For some strange reason, XCode won't
    // throw any warning for this type mismatch. Here we failsafe this error by allowing URLs to be passed as NSString.
    if ([url isKindOfClass:NSString.class]) {
        url = [NSURL URLWithString:(NSString *)url];
    }
     ...    忽略一堆的代码
     
 NSString *key = [self cacheKeyForURL:url];
 
 // 其实在读取磁盘缓存这步就可以做 归并 处理了,但是 disk io 损耗并不大,SD没做,而且也不是我们的重点
operation.cacheOperation = [self.imageCache queryDiskCacheForKey:key done:^(UIImage *image, SDImageCacheType cacheType) {
    if (operation.isCancelled) {
        @synchronized (self.runningOperations) {
            [self.runningOperations removeObject:operation];
        }
        return;
    }
    ...            

继续忽略一堆的代码

有缓存就直接返回图片缓存,无缓存就准备开始下载,我们来看重点的下载代码

id  subOperation = [self.imageDownloader downloadImageWithURL:url options:downloaderOptions progress:progressBlock completed:^(UIImage *downloadedImage, NSData *data, NSError *error, BOOL finished) {
                __strong __typeof(weakOperation) strongOperation = weakOperation;
                if (!strongOperation || strongOperation.isCancelled) {
    
其实是在 SDImageDownloadManager 去创建 operation
                
- (id )downloadImageWithURL:(NSURL *)url options:(SDWebImageDownloaderOptions)options progress:(SDWebImageDownloaderProgressBlock)progressBlock completed:(SDWebImageDownloaderCompletedBlock)completedBlock {
    __block SDWebImageDownloaderOperation *operation;
    __weak __typeof(self)wself = self;

    [self addProgressCallback:progressBlock completedBlock:completedBlock forURL:url createCallback:^{
        // 创建请求的代码
    }];


继续跟


- (void)addProgressCallback:(SDWebImageDownloaderProgressBlock)progressBlock completedBlock:(SDWebImageDownloaderCompletedBlock)completedBlock forURL:(NSURL *)url createCallback:(SDWebImageNoParamsBlock)createCallback {
    // The URL will be used as the key to the callbacks dictionary so it cannot be nil. If it is nil immediately call the completed block with no image or data.
    if (url == nil) {
        if (completedBlock != nil) {
            completedBlock(nil, nil, nil, NO);
        }
        return;
    }

    dispatch_barrier_sync(self.barrierQueue, ^{

        // 判断是否有该 URL 的请求对象, 只有 第一个进来的下载请求,才会创建
        BOOL first = NO;
        if (!self.URLCallbacks[url]) {
            self.URLCallbacks[url] = [NSMutableArray new];
            first = YES;
        }

        // Handle single download of simultaneous download request for the same URL
        NSMutableArray *callbacksForURL = self.URLCallbacks[url];
        NSMutableDictionary *callbacks = [NSMutableDictionary new];

        // 把相应回调存在 array 中
        if (progressBlock) callbacks[kProgressCallbackKey] = [progressBlock copy];
        if (completedBlock) callbacks[kCompletedCallbackKey] = [completedBlock copy];
        
        [callbacksForURL addObject:callbacks];
        self.URLCallbacks[url] = callbacksForURL;

        if (first) {
            createCallback();
        }
    });
}

其实整体代码并没有什么问题 , 再来看创建 operation 的代码

// 当命中归并下载逻辑后,后续返回给外部的 operation 都是 nil,并不会走到 createCallback 内部
__block SDWebImageDownloaderOperation *operation;
__weak __typeof(self)wself = self;

[self addProgressCallback:progressBlock completedBlock:completedBlock forURL:url createCallback:^{

... 一堆 request 初始化代码
operation = [[wself.operationClass alloc] initWithRequest:request
    inSession:self.session
      options:options
     progress:^(NSInteger receivedSize, NSInteger expectedSize) {
         SDWebImageDownloader *sself = wself;
         if (!sself) return;
         __block NSArray *callbacksForURL;
         dispatch_sync(sself.barrierQueue, ^{
             callbacksForURL = [sself.URLCallbacks[url] copy];
         });
         for (NSDictionary *callbacks in callbacksForURL) {
             dispatch_async(dispatch_get_main_queue(), ^{
                 SDWebImageDownloaderProgressBlock callback = callbacks[kProgressCallbackKey];
                 if (callback) callback(receivedSize, expectedSize);
             });
         }
     }
    completed:^(UIImage *image, NSData *data, NSError *error, BOOL finished) {
        SDWebImageDownloader *sself = wself;
        if (!sself) return;
        __block NSArray *callbacksForURL;
        dispatch_barrier_sync(sself.barrierQueue, ^{
            callbacksForURL = [sself.URLCallbacks[url] copy];
            if (finished) {
                [sself.URLCallbacks removeObjectForKey:url];
            }
        });
        for (NSDictionary *callbacks in callbacksForURL) {
            SDWebImageDownloaderCompletedBlock callback = callbacks[kCompletedCallbackKey];
            if (callback) callback(image, data, error, finished);
        }
    }
    cancelled:^{
        // 当第一个 operation 被cancel 掉时,其他回调就统一没了【加红加粗】
        SDWebImageDownloader *sself = wself;
        if (!sself) return;
        dispatch_barrier_async(sself.barrierQueue, ^{
            [sself.URLCallbacks removeObjectForKey:url];
        });
    }];
                                                        
    operation.shouldDecompressImages = wself.shouldDecompressImages;

发现的问题

  1. 当命中归并下载逻辑后,后续返回给外部的 operation 都是 nil
  2. 当第一个operation执行cancel后,后续的 operation 都被取消了, 相当于同时两个View都在下载,第一个取消了,导致第二个也显示不出来
  3. 当后续的 imageView 下载新图片时,旧的 operation 的回调并不会清除,有概率出现图片显示错乱的问题 (官方3.x后续版本已修复)

原因如图: cancel old operation 根本就没用的

SDWebImage 图片归并下载的 Bug修复_第2张图片
2.png

// 逻辑跟我们后续做的类似,也是用个 operation 对真实的 downloadOperation 进行包装
__block SDWebImageCombinedOperation *operation = [SDWebImageCombinedOperation new];
__weak SDWebImageCombinedOperation *weakOperation = operation;

... 在回调的 complatedBlock 中判断,封装的operation 是否被调用了取消,如果已经取消就不操作了

id  subOperation = [self.imageDownloader downloadImageWithURL:url options:downloaderOptions progress:progressBlock completed:^(UIImage *downloadedImage, NSData *data, NSError *error, BOOL finished) {
                __strong __typeof(weakOperation) strongOperation = weakOperation;
                if (!strongOperation || strongOperation.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
                }

    

解决方案:应该全部 operation 都取消了,才能取消下载

那要怎么修改呢?

  1. 首先要把 operation 的返回方式改掉,因为返回 nil,外部执行 cancel 你也感知不到。
  2. 当 一个 operation 被cancel 的时候,从 URLCallbacks {URL:Array[Operation]} 移除自己,当 array.count == 0 的时候,才真正调用 request cancel
  3. 返回假的 operation,实现 SDWebImageOperation 的方法,保证外部逻辑无需修改
@protocol SDWebImageOperation 

- (void)cancel;

@end


创建一个 wrapOperation, 对返回的 operation 进行替换

@interface IMYSDWebImageDownloaderOperation : NSObject 
@property (nonatomic, strong) SDWebImageDownloaderOperation *operation;
@property (nonatomic, copy) void (^cancelBlock)(id weakOperation);
@property (nonatomic, copy) SDWebImageDownloaderResponseBlock responseBlock;
@property (nonatomic, copy) SDWebImageDownloaderProgressBlock progressBlock;
@property (nonatomic, copy) SDWebImageDownloaderCompletedBlock completedBlock;
@end

抛弃了之前 SDWebImage 存 Map 的方法,直接改用存对象,扩展性和性能都更强


- (IMYSDWebImageDownloaderOperation *)addDownloaderOperationWithParmas:(IMYWebImageDownloadParams *)params
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        if ([self.downloadQueue respondsToSelector:@selector(setQualityOfService:)]) {
            [self.downloadQueue setQualityOfService:NSQualityOfServiceUserInitiated];
        }
    });

    __block IMYSDWebImageDownloaderOperation *operation = nil;
    NSURL *url = params.url ?: params.request.URL;
    NSString *callbacksKey = url.absoluteString;
    dispatch_barrier_sync(self.barrierQueue, ^{
        NSMutableArray *callbacksForURL = self.URLCallbacks[callbacksKey];
        if (!callbacksForURL) {
            callbacksForURL = [NSMutableArray array];
            self.URLCallbacks[callbacksKey] = callbacksForURL;
        }
        SDWebImageDownloaderOperation *subOperation = callbacksForURL.lastObject.operation;
        if (!subOperation) {
            subOperation = [self createDownloaderOperationWithParmas:params callbacksKey:callbacksKey];
        }
        if ((params.options & SDWebImageDownloaderHighPriority) && NSOperationQueuePriorityHigh != subOperation.queuePriority) {
            subOperation.queuePriority = NSOperationQueuePriorityHigh;
        }

        operation = [[IMYSDWebImageDownloaderOperation alloc] init];
        operation.operation = subOperation;
        operation.progressBlock = params.progressBlock;
        operation.responseBlock = params.responseBlock;
        operation.completedBlock = params.completedBlock;
        [operation setCancelBlock:^(IMYSDWebImageDownloaderOperation *weakOperation) {
            __block BOOL shouldCancel = NO;
            id downloadOperation = weakOperation.operation;
            // 线程安全
            dispatch_barrier_sync(self.barrierQueue, ^{
                // 防止一直持有 callbacksForURL 引起的不释放问题,其实也可以用 weak 声明
                // 当外部 执行 cancel 方法时, 只移除自己,并且判断是否停止 真实request
                NSMutableArray *callbacksForURL = self.URLCallbacks[callbacksKey];
                [callbacksForURL removeObject:weakOperation];
                if (!callbacksForURL.count) {
                    [self.URLCallbacks removeObjectForKey:callbacksKey];
                    shouldCancel = YES;
                }
            });
            if (shouldCancel) {
                [downloadOperation cancel];
            }
        }];
        [callbacksForURL addObject:operation];
    });
    return operation;
}


SDWebImage 图片归并下载的 Bug修复_第3张图片
3.png

顺便把整个 SDWebImage 参数改为对象化,方便扩展,


@interface IMYWebImageDownloadParams : NSObject

@property (nonatomic, strong) NSURL *url;
@property (nonatomic, strong) NSURLRequest *request;
@property (nonatomic, strong) NSDictionary *header;
@property (nonatomic, assign) BOOL shouldDecompressImages;
@property (nonatomic, assign) BOOL shouldCreatesImages;
@property (nonatomic, assign) SDWebImageDownloaderOptions options;

@property (nonatomic, copy) SDWebImageDownloaderResponseBlock responseBlock;
@property (nonatomic, copy) SDWebImageDownloaderProgressBlock progressBlock;
@property (nonatomic, copy) SDWebImageDownloaderCompletedBlock completedBlock;

@end

// 原有的 SD 方法都转为走 downloadImageWithParmas

@interface SDWebImageDownloader (IMYWebImage)

- (id)downloadImageWithParmas:(IMYWebImageDownloadParams *)params;

@end

// 整体方法覆盖没有采用 method swizzle ,而是直接采用 category 覆盖

#pragma mark - 覆盖 .m 方法
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wobjc-protocol-method-implementation"

- (id)downloadImageWithURL:(NSURL *)url options:(SDWebImageDownloaderOptions)options progress:(SDWebImageDownloaderProgressBlock)progressBlock completed:(SDWebImageDownloaderCompletedBlock)completedBlock
{
    return [self downloadImageWithURL:url header:nil options:options response:nil progress:progressBlock completed:completedBlock];
}

#pragma clang diagnostic pop

@end

完结

你可能感兴趣的:(SDWebImage 图片归并下载的 Bug修复)