SDWebImage 图片归并下载的 Bug修复

图片下载中的归并下载

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

1.png

SDWebImage 比较早就实现了该方案

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

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

- (id <SDWebImageOperation>)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 <SDWebImageOperation> 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 <SDWebImageOperation>)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 根本就没用的

2.png

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

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

id <SDWebImageOperation> 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 <NSObject>

- (void)cancel;

@end


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

@interface IMYSDWebImageDownloaderOperation : NSObject <SDWebImageOperation>
@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<IMYSDWebImageDownloaderOperation *> *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<SDWebImageOperation> 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;
}


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<SDWebImageOperation>)downloadImageWithParmas:(IMYWebImageDownloadParams *)params;

@end

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

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

- (id<SDWebImageOperation>)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

完结

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,125评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,293评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,054评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,077评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,096评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,062评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,988评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,817评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,266评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,486评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,646评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,375评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,974评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,621评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,642评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,538评论 2 352