ioS開發之多線程-- 第三方庫SDWebImage

SDWebImage是一個開源的第三方庫,它提供了UIImageView的一個分類,以支持從遠程服務器下載並緩存圖片的功能。它具有以下功能:

  1. 提供UIImageView的一個分類,以支持網絡圖片的加載與緩存管理
  2. 一個異步的圖片加載器
  3. 一個異步的內存+磁盤圖片緩存
  4. 支持GIF圖片
  5. 支持WebP圖片
  6. 後臺圖片解壓縮處理
  7. 確保同一個URL的圖片不被下載多次
  8. 確保虛假的URL不會被反覆加載
  9. 確保下載及緩存時,主線程不被阻塞

從github上對SDWebImage使用情況就可以看出,SDWebImage在圖片下載及緩存的處理方面還是很被認可的。在本文中,我們主要從源碼的角度來分析一下SDWebImage的實現機制。討論的內容將主要集中在圖片的下載及緩存,而不包含對GIF圖片及WebP圖片的支持操作。

下載

在SDWebImage中,圖片的下載是由SDWebImageDownloader類來完成的。它是一個異步下載器,並對圖像加載做了優化處理。下面我們就來看看它的具體實現。

下載選項

在下載的過程中,程序會根據設置的不同的下載選項,而執行不同的操作。下載選項由枚舉SDWebImageDownloaderOptions定義,具體如下

typedef NS_OPTIONS(NSUInteger, SDWebImageDownloaderOptions) {
    SDWebImageDownloaderLowPriority = 1 << 0,
    SDWebImageDownloaderProgressiveDownload = 1 << 1,

    // 默認情況下請求不使用NSURLCache,如果設置該選項,則以默認的緩存策略來使用NSURLCache
    SDWebImageDownloaderUseNSURLCache = 1 << 2,

    // 如果從NSURLCache緩存中讀取圖片,則使用nil作爲參數來調用完成block
    SDWebImageDownloaderIgnoreCachedResponse = 1 << 3,

    // 在iOS 4+系統上,允許程序進入後臺後繼續下載圖片。該操作通過向系統申請額外的時間來完成後臺下載。如果後臺任務終止,則操作會被取消
    SDWebImageDownloaderContinueInBackground = 1 << 4,

    // 通過設置NSMutableURLRequest.HTTPShouldHandleCookies = YES來處理存儲在NSHTTPCookieStore中的cookie
    SDWebImageDownloaderHandleCookies = 1 << 5,

    // 允許不受信任的SSL證書。主要用於測試目的。
    SDWebImageDownloaderAllowInvalidSSLCertificates = 1 << 6,

    // 將圖片下載放到高優先級隊列中
    SDWebImageDownloaderHighPriority = 1 << 7,
};

可以看出,這些選項主要涉及到下載的優先級、緩存、後臺任務執行、cookie處理以認證幾個方面。

下載順序

SDWebImage的下載操作是按一定順序來處理的,它定義了兩種下載順序,如下所示

typedef NS_ENUM(NSInteger, SDWebImageDownloaderExecutionOrder) {

    // 以隊列的方式,按照先進先出的順序下載。這是默認的下載順序
    SDWebImageDownloaderFIFOExecutionOrder,

    // 以棧的方式,按照後進先出的順序下載。
    SDWebImageDownloaderLIFOExecutionOrder
};

下載管理器

SDWebImageDownloader下載管理器是一個單例類,它主要負責圖片的下載操作的管理。圖片的下載是放在一個NSOperationQueue操作隊列中來完成的,其聲明如下:

@property (strong, nonatomic) NSOperationQueue *downloadQueue;

默認情況下,隊列最大併發數是6。如果需要的話,我們可以通過SDWebImageDownloader類的maxConcurrentDownloads屬性來修改。

所有下載操作的網絡響應序列化處理是放在一個自定義的並行調度隊列中來處理的,其聲明及定義如下:

@property (SDDispatchQueueSetterSementics, nonatomic) dispatch_queue_t barrierQueue;

- (id)init {
    if ((self = [super init])) {
        ...
        _barrierQueue = dispatch_queue_create("com.hackemist.SDWebImageDownloaderBarrierQueue", DISPATCH_QUEUE_CONCURRENT);
        ...
    }
    return self;
}

每一個圖片的下載都會對應一些回調操作,如下載進度回調,下載完成回調等,這些回調操作是以block形式來呈現,爲此在SDWebImageDownloader.h中定義了幾個block,如下所示:

// 下載進度
typedef void(^SDWebImageDownloaderProgressBlock)(NSInteger receivedSize, NSInteger expectedSize);
// 下載完成
typedef void(^SDWebImageDownloaderCompletedBlock)(UIImage *image, NSData *data, NSError *error, BOOL finished);
// Header過濾
typedef NSDictionary *(^SDWebImageDownloaderHeadersFilterBlock)(NSURL *url, NSDictionary *headers);

圖片下載的這些回調信息存儲在SDWebImageDownloader類的URLCallbacks屬性中,該屬性是一個字典,key是圖片的URL地址,value則是一個數組,包含每個圖片的多組回調信息。由於我們允許多個圖片同時下載,因此可能會有多個線程同時操作URLCallbacks屬性。爲了保證URLCallbacks操作(添加、刪除)的線程安全性,SDWebImageDownloader將這些操作作爲一個個任務放到barrierQueue隊列中,並設置屏障來確保同一時間只有一個線程操作URLCallbacks屬性,我們以添加操作爲例,如下代碼所示:

- (void)addProgressCallback:(SDWebImageDownloaderProgressBlock)progressBlock andCompletedBlock:(SDWebImageDownloaderCompletedBlock)completedBlock forURL:(NSURL *)url createCallback:(SDWebImageNoParamsBlock)createCallback {

    ...

    // 1. 以dispatch_barrier_sync操作來保證同一時間只有一個線程能對URLCallbacks進行操作
    dispatch_barrier_sync(self.barrierQueue, ^{
        ...

        // 2. 處理同一URL的同步下載請求的單個下載
        NSMutableArray *callbacksForURL = self.URLCallbacks[url];
        NSMutableDictionary *callbacks = [NSMutableDictionary new];
        if (progressBlock) callbacks[kProgressCallbackKey] = [progressBlock copy];
        if (completedBlock) callbacks[kCompletedCallbackKey] = [completedBlock copy];
        [callbacksForURL addObject:callbacks];
        self.URLCallbacks[url] = callbacksForURL;

        ...
    });
}

整個下載管理器對於下載請求的管理都是放在downloadImageWithURL:options:progress:completed:方法裏面來處理的,該方法調用了上面所提到的addProgressCallback:andCompletedBlock:forURL:createCallback:方法來將請求的信息存入管理器中,同時在創建回調的block中創建新的操作,配置之後將其放入downloadQueue操作隊列中,最後方法返回新創建的操作。其具體實現如下:

- (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;
}

另外,每個下載操作的超時時間可以通過downloadTimeout屬性來設置,默認值爲15秒。

下載操作

每個圖片的下載都是一個Operation操作。我們在上面分析過這個操作的創建及加入操作隊列的過程。現在我們來看看單個操作的具體實現。

SDWebImage定義了一個協議,即SDWebImageOperation作爲圖片下載操作的基礎協議。它只聲明瞭一個cancel方法,用於取消操作。協議的具體聲明如下:

@protocol SDWebImageOperation <NSObject>

- (void)cancel;

@end

SDWebImage自定義了一個Operation類,即SDWebImageDownloaderOperation,它繼承自NSOperation,並採用了SDWebImageOperation協議。除了繼承而來的方法,該類只向外暴露了一個方法,即上面所用到的初始化方法initWithRequest:options:progress:completed:cancelled:。

對於圖片的下載,SDWebImageDownloaderOperation完全依賴於URL加載系統中的NSURLConnection類(並未使用7.0以後的NSURLSession類)。我們先來分析一下SDWebImageDownloaderOperation類中對於圖片實際數據的下載處理,即NSURLConnection各代理方法的實現。

首先,SDWebImageDownloaderOperation在分類中採用了NSURLConnectionDataDelegate協議,並實現了該協議的以下幾個方法:

- connection:didReceiveResponse:
- connection:didReceiveData:
- connectionDidFinishLoading:
- connection:didFailWithError:
- connection:willCacheResponse:
- connectionShouldUseCredentialStorage:
- connection:willSendRequestForAuthenticationChallenge:

我們在此不逐一分析每個方法的實現,就重點分析一下-connection:didReceiveData:方法。該方法的主要任務是接收數據。每次接收到數據時,都會用現有的數據創建一個CGImageSourceRef對象以做處理。在首次獲取到數據時(width+height==0)會從這些包含圖像信息的數據中取出圖像的長、寬、方向等信息以備使用。而後在圖片下載完成之前,會使用CGImageSourceRef對象創建一個圖片對象,經過縮放、解壓縮操作後生成一個UIImage對象供完成回調使用。當然,在這個方法中還需要處理的就是進度信息。如果我們有設置進度回調的話,就調用這個進度回調以處理當前圖片的下載進度。

注:縮放操作可以查看SDWebImageCompat文件中的SDScaledImageForKey函數;解壓縮操作可以查看SDWebImageDecoder文件+decodedImageWithImage方法

- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
    // 1. 附加數據
    [self.imageData appendData:data];

    if ((self.options & SDWebImageDownloaderProgressiveDownload) && self.expectedSize > 0 && self.completedBlock) {

        // 2. 獲取已下載數據總大小
        const NSInteger totalSize = self.imageData.length;

        // 3. 更新數據源,我們需要傳入所有數據,而不僅僅是新數據
        CGImageSourceRef imageSource = CGImageSourceCreateWithData((__bridge CFDataRef)self.imageData, NULL);

        // 4. 首次獲取到數據時,從這些數據中獲取圖片的長、寬、方向屬性值
        if (width + height == 0) {
            CFDictionaryRef properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, NULL);
            if (properties) {
                NSInteger orientationValue = -1;
                CFTypeRef val = CFDictionaryGetValue(properties, kCGImagePropertyPixelHeight);
                if (val) CFNumberGetValue(val, kCFNumberLongType, &height);
                ...
                CFRelease(properties);

                // 5. 當繪製到Core Graphics時,我們會丟失方向信息,這意味着有時候由initWithCGIImage創建的圖片
                //    的方向會不對,所以在這邊我們先保存這個信息並在後面使用。
                orientation = [[self class] orientationFromPropertyValue:(orientationValue == -1 ? 1 : orientationValue)];
            }
        }

        // 6. 圖片還未下載完成
        if (width + height > 0 && totalSize < self.expectedSize) {
            // 7. 使用現有的數據創建圖片對象,如果數據中存有多張圖片,則取第一張
            CGImageRef partialImageRef = CGImageSourceCreateImageAtIndex(imageSource, 0, NULL);

#ifdef TARGET_OS_IPHONE
            // 8. 適用於iOS變形圖像的解決方案。我的理解是由於iOS只支持RGB顏色空間,所以在此對下載下來的圖片做個顏色空間轉換處理。
            if (partialImageRef) {
                const size_t partialHeight = CGImageGetHeight(partialImageRef);
                CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
                CGContextRef bmContext = CGBitmapContextCreate(NULL, width, height, 8, width * 4, colorSpace, kCGBitmapByteOrderDefault | kCGImageAlphaPremultipliedFirst);
                CGColorSpaceRelease(colorSpace);

                if (bmContext) {
                    CGContextDrawImage(bmContext, (CGRect){.origin.x = 0.0f, .origin.y = 0.0f, .size.width = width, .size.height = partialHeight}, partialImageRef);
                    CGImageRelease(partialImageRef);
                    partialImageRef = CGBitmapContextCreateImage(bmContext);
                    CGContextRelease(bmContext);
                }
                else {
                    CGImageRelease(partialImageRef);
                    partialImageRef = nil;
                }
            }
#endif

            // 9. 對圖片進行縮放、解碼操作
            if (partialImageRef) {
                UIImage *image = [UIImage imageWithCGImage:partialImageRef scale:1 orientation:orientation];
                NSString *key = [[SDWebImageManager sharedManager] cacheKeyForURL:self.request.URL];
                UIImage *scaledImage = [self scaledImageForKey:key image:image];
                image = [UIImage decodedImageWithImage:scaledImage];
                CGImageRelease(partialImageRef);
                dispatch_main_sync_safe(^{
                    if (self.completedBlock) {
                        self.completedBlock(image, nil, nil, NO);
                    }
                });
            }
        }

        CFRelease(imageSource);
    }

    if (self.progressBlock) {
        self.progressBlock(self.imageData.length, self.expectedSize);
    }
}

我們前面說過SDWebImageDownloaderOperation類是繼承自NSOperation類。它沒有簡單的實現main方法,而是採用更加靈活的start方法,以便自己管理下載的狀態。

在start方法中,創建了我們下載所使用的NSURLConnection對象,開啓了圖片的下載,同時拋出一個下載開始的通知。當然,如果我們期望下載在後臺處理,則只需要配置我們的下載選項,使其包含SDWebImageDownloaderContinueInBackground選項。start方法的具體實現如下:

- (void)start {
    @synchronized (self) {
        // 管理下載狀態,如果已取消,則重置當前下載並設置完成狀態爲YES
        if (self.isCancelled) {
            self.finished = YES;
            [self reset];
            return;
        }

#if TARGET_OS_IPHONE && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_4_0
        // 1. 如果設置了在後臺執行,則進行後臺執行
        if ([self shouldContinueWhenAppEntersBackground]) {
            __weak __typeof__ (self) wself = self;
            self.backgroundTaskId = [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:^{
                ...
                }
            }];
        }
#endif

        self.executing = YES;
        self.connection = [[NSURLConnection alloc] initWithRequest:self.request delegate:self startImmediately:NO];
        self.thread = [NSThread currentThread];
    }

    [self.connection start];

    if (self.connection) {
        if (self.progressBlock) {
            self.progressBlock(0, NSURLResponseUnknownLength);
        }

        // 2. 在主線程拋出下載開始通知
        dispatch_async(dispatch_get_main_queue(), ^{
            [[NSNotificationCenter defaultCenter] postNotificationName:SDWebImageDownloadStartNotification object:self];
        });

        // 3. 啓動run loop
        if (floor(NSFoundationVersionNumber) <= NSFoundationVersionNumber_iOS_5_1) {
            CFRunLoopRunInMode(kCFRunLoopDefaultMode, 10, false);
        }
        else {
            CFRunLoopRun();
        }

        // 4. 如果未完成,則取消連接
        if (!self.isFinished) {
            [self.connection cancel];
            [self connection:self.connection didFailWithError:[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorTimedOut userInfo:@{NSURLErrorFailingURLErrorKey : self.request.URL}]];
        }
    }
    else {
        ... 
    }

#if TARGET_OS_IPHONE && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_4_0
    if (self.backgroundTaskId != UIBackgroundTaskInvalid) {
        [[UIApplication sharedApplication] endBackgroundTask:self.backgroundTaskId];
        self.backgroundTaskId = UIBackgroundTaskInvalid;
    }
#endif
}

當然,在下載完成或下載失敗後,需要停止當前線程的run loop,清除連接,並拋出下載停止的通知。如果下載成功,則會處理完整的圖片數據,對其進行適當的縮放與解壓縮操作,以提供給完成回調使用。具體可參考-connectionDidFinishLoading:與-connection:didFailWithError:的實現。

小結

下載的核心其實就是利用NSURLConnection對象來加載數據。每個圖片的下載都由一個Operation操作來完成,並將這些操作放到一個操作隊列中。這樣可以實現圖片的併發下載。

緩存

爲了減少網絡流量的消耗,我們都希望下載下來的圖片緩存到本地,下次再去獲取同一張圖片時,可以直接從本地獲取,而不再從遠程服務器獲取。這樣做的另一個好處是提升了用戶體驗,用戶第二次查看同一幅圖片時,能快速從本地獲取圖片直接呈現給用戶。

SDWebImage提供了對圖片緩存的支持,而該功能是由SDImageCache類來完成的。該類負責處理內存緩存及一個可選的磁盤緩存。其中磁盤緩存的寫操作是異步的,這樣就不會對UI操作造成影響。

內存緩存及磁盤緩存

內存緩存的處理是使用NSCache對象來實現的。NSCache是一個類似於集合的容器。它存儲key-value對,這一點類似於NSDictionary類。我們通常用使用緩存來臨時存儲短時間使用但創建昂貴的對象。重用這些對象可以優化性能,因爲它們的值不需要重新計算。另外一方面,這些對象對於程序來說不是緊要的,在內存緊張時會被丟棄。

磁盤緩存的處理則是使用NSFileManager對象來實現的。圖片存儲的位置是位於Cache文件夾。另外,SDImageCache還定義了一個串行隊列,來異步存儲圖片。

內存緩存與磁盤緩存相關變量的聲明及定義如下:

@interface SDImageCache ()

@property (strong, nonatomic) NSCache *memCache;
@property (strong, nonatomic) NSString *diskCachePath;
@property (strong, nonatomic) NSMutableArray *customPaths;
@property (SDDispatchQueueSetterSementics, nonatomic) dispatch_queue_t ioQueue;

@end

- (id)initWithNamespace:(NSString *)ns {
    if ((self = [super init])) {
        NSString *fullNamespace = [@"com.hackemist.SDWebImageCache." stringByAppendingString:ns];

        ...

        _ioQueue = dispatch_queue_create("com.hackemist.SDWebImageCache", DISPATCH_QUEUE_SERIAL);

        ...

        // Init the memory cache
        _memCache = [[NSCache alloc] init];
        _memCache.name = fullNamespace;

        // Init the disk cache
        NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
        _diskCachePath = [paths[0] stringByAppendingPathComponent:fullNamespace];

        dispatch_sync(_ioQueue, ^{
            _fileManager = [NSFileManager new];
        });

        ...
    }

    return self;
}

SDImageCache提供了大量方法來緩存、獲取、移除及清空圖片。而對於每個圖片,爲了方便地在內存或磁盤中對它進行這些操作,我們需要一個key值來索引它。在內存中,我們將其作爲NSCache的key值,而在磁盤中,我們用這個key作爲圖片的文件名。對於一個遠程服務器下載的圖片,其url是作爲這個key的最佳選擇了。我們在後面會看到這個key值的重要性。

存儲圖片

我們先來看看圖片的緩存操作,該操作會在內存中放置一份緩存,而如果確定需要緩存到磁盤,則將磁盤緩存操作作爲一個task放到串行隊列中處理。在iOS中,會先檢測圖片是PNG還是JPEG,並將其轉換爲相應的圖片數據,最後將數據寫入到磁盤中(文件名是對key值做MD5摘要後的串)。緩存操作的基礎方法是-storeImage:recalculateFromImage:imageData:forKey:toDisk,它的具體實現如下:

- (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];
            }
        });
    }
}

查詢圖片

如果我們想在內存或磁盤中查詢是否有key指定的圖片,則可以分別使用以下方法:

- (UIImage *)imageFromMemoryCacheForKey:(NSString *)key;
- (UIImage *)imageFromDiskCacheForKey:(NSString *)key;

而如果只是想查看本地是否在key指定的圖片,則不管是在內存還是在磁盤上,則可以使用以下方法:

- (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;
}

移除圖片

圖片的移除操作則可以使用以下方法:

- (void)removeImageForKey:(NSString *)key;
- (void)removeImageForKey:(NSString *)key withCompletion:(SDWebImageNoParamsBlock)completion;
- (void)removeImageForKey:(NSString *)key fromDisk:(BOOL)fromDisk;
- (void)removeImageForKey:(NSString *)key fromDisk:(BOOL)fromDisk withCompletion:(SDWebImageNoParamsBlock)completion;

我們可以選擇同時移除內存及磁盤上的圖片。

清理圖片

磁盤緩存圖片的清理操作可以分爲完全清空和部分清理。完全清空操作是直接把緩存的文件夾移除,清空操作有以下兩個方法:

- (void)clearDisk;
- (void)clearDiskOnCompletion:(SDWebImageNoParamsBlock)completion;

而部分清理則是根據我們設定的一些參數值來移除一些文件,這裏主要有兩個指標:文件的緩存有效期及最大緩存空間大小。文件的緩存有效期可以通過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();
            });
        }
    });
}

小結

以上分析了圖片緩存操作,當然,除了上面講的幾個操作,SDImageCache類還提供了一些輔助方法。如獲取緩存大小、緩存中圖片的數量、判斷緩存中是否存在某個key指定的圖片。另外,SDImageCache類提供了一個單例方法的實現,所以我們可以將其當作單例對象來處理。

SDWebImageManager

在實際的運用中,我們並不直接使用SDWebImageDownloader類及SDImageCache類來執行圖片的下載及緩存。爲了方便用戶的使用,SDWebImage提供了SDWebImageManager對象來管理圖片的下載與緩存。而且我們經常用到的諸如UIImageView+WebCache等控件的分類都是基於SDWebImageManager對象的。該對象將一個下載器和一個圖片緩存綁定在一起,並對外提供兩個只讀屬性來獲取它們,如下代碼所示:

@interface SDWebImageManager : NSObject

@property (weak, nonatomic) id <SDWebImageManagerDelegate> delegate;

@property (strong, nonatomic, readonly) SDImageCache *imageCache;
@property (strong, nonatomic, readonly) SDWebImageDownloader *imageDownloader;

...

@end

從上面的代碼中我們還可以看到有一個delegate屬性,其是一個id<SDWebImageManagerDelegate>對象。SDWebImageManagerDelegate聲明瞭兩個可選實現的方法,如下所示:

// 控制當圖片在緩存中沒有找到時,應該下載哪個圖片
- (BOOL)imageManager:(SDWebImageManager *)imageManager shouldDownloadImageForURL:(NSURL *)imageURL;

// 允許在圖片已經被下載完成且被緩存到磁盤或內存前立即轉換
- (UIImage *)imageManager:(SDWebImageManager *)imageManager transformDownloadedImage:(UIImage *)image withURL:(NSURL *)imageURL;

這兩個代理方法會在SDWebImageManager的-downloadImageWithURL:options:progress:completed:方法中調用,而這個方法是SDWebImageManager類的核心所在。我們來看看它的具體實現:

- (id <SDWebImageOperation>)downloadImageWithURL:(NSURL *)url
                                         options:(SDWebImageOptions)options
                                        progress:(SDWebImageDownloaderProgressBlock)progressBlock
                                       completed:(SDWebImageCompletionWithFinishedBlock)completedBlock {

    ...

    // 前面省略n行。主要作了如下處理:
    // 1. 判斷url的合法性
    // 2. 創建SDWebImageCombinedOperation對象
    // 3. 查看url是否是之前下載失敗過的
    // 4. 如果url爲nil,或者在不可重試的情況下是一個下載失敗過的url,則直接返回操作對象並調用完成回調

    operation.cacheOperation = [self.imageCache queryDiskCacheForKey:key done:^(UIImage *image, SDImageCacheType cacheType) {
        ...

        if ((!image || options & SDWebImageRefreshCached) && (![self.delegate respondsToSelector:@selector(imageManager:shouldDownloadImageForURL:)] || [self.delegate imageManager:self shouldDownloadImageForURL:url])) {

            // 下載
            id <SDWebImageOperation> subOperation = [self.imageDownloader downloadImageWithURL:url options:downloaderOptions progress:progressBlock completed:^(UIImage *downloadedImage, NSData *data, NSError *error, BOOL finished) {
                if (weakOperation.isCancelled) {
                    // 操作被取消,則不做任務事情
                }
                else if (error) {
                    // 如果出錯,則調用完成回調,並將url放入下載挫敗url數組中
                    ...
                }
                else {
                    BOOL cacheOnDisk = !(options & SDWebImageCacheMemoryOnly);

                    if (options & SDWebImageRefreshCached && image && !downloadedImage) {
                        // Image refresh hit the NSURLCache cache, do not call the completion block
                    }
                    else if (downloadedImage && (!downloadedImage.images || (options & SDWebImageTransformAnimatedImage)) && [self.delegate respondsToSelector:@selector(imageManager:transformDownloadedImage:withURL:)]) {
                        // 在全局隊列中並行處理圖片的緩存
                        // 首先對圖片做個轉換操作,該操作是代理對象實現的
                        // 然後對圖片做緩存處理
                        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
                            UIImage *transformedImage = [self.delegate imageManager:self transformDownloadedImage:downloadedImage withURL:url];

                            if (transformedImage && finished) {
                                BOOL imageWasTransformed = ![transformedImage isEqual:downloadedImage];
                                [self.imageCache storeImage:transformedImage recalculateFromImage:imageWasTransformed imageData:data forKey:key toDisk:cacheOnDisk];
                            }

                            ...
                        });
                    }
                    else {
                        if (downloadedImage && finished) {
                            [self.imageCache storeImage:downloadedImage recalculateFromImage:NO imageData:data forKey:key toDisk:cacheOnDisk];
                        }

                        ...
                    }
                }

                // 下載完成並緩存後,將操作從隊列中移除
                if (finished) {
                    @synchronized (self.runningOperations) {
                        [self.runningOperations removeObject:operation];
                    }
                }
            }];

            // 設置取消回調
            operation.cancelBlock = ^{
                [subOperation cancel];

                @synchronized (self.runningOperations) {
                    [self.runningOperations removeObject:weakOperation];
                }
            };
        }
        else if (image) {
            ...
        }
        else {
            ...
        }
    }];

    return operation;
}

對於這個方法,我們沒有做過多的解釋。其主要就是下載圖片並根據操作選項來緩存圖片。上面這個下載方法中的操作選項參數是由枚舉SDWebImageOptions來定義的,這個操作中的一些選項是與SDWebImageDownloaderOptions中的選項對應的。我們來看看這個SDWebImageOptions選項都有哪些:

typedef NS_OPTIONS(NSUInteger, SDWebImageOptions) {

    // 默認情況下,當URL下載失敗時,URL會被列入黑名單,導致庫不會再去重試,該標記用於禁用黑名單
    SDWebImageRetryFailed = 1 << 0,

    // 默認情況下,圖片下載開始於UI交互,該標記禁用這一特性,這樣下載延遲到UIScrollView減速時
    SDWebImageLowPriority = 1 << 1,

    // 該標記禁用磁盤緩存
    SDWebImageCacheMemoryOnly = 1 << 2,

    // 該標記啓用漸進式下載,圖片在下載過程中是漸漸顯示的,如同瀏覽器一下。
    // 默認情況下,圖像在下載完成後一次性顯示
    SDWebImageProgressiveDownload = 1 << 3,

    // 即使圖片緩存了,也期望HTTP響應cache control,並在需要的情況下從遠程刷新圖片。
    // 磁盤緩存將被NSURLCache處理而不是SDWebImage,因爲SDWebImage會導致輕微的性能下載。
    // 該標記幫助處理在相同請求URL後面改變的圖片。如果緩存圖片被刷新,則完成block會使用緩存圖片調用一次
    // 然後再用最終圖片調用一次
    SDWebImageRefreshCached = 1 << 4,

    // 在iOS 4+系統中,當程序進入後臺後繼續下載圖片。這將要求系統給予額外的時間讓請求完成
    // 如果後臺任務超時,則操作被取消
    SDWebImageContinueInBackground = 1 << 5,

    // 通過設置NSMutableURLRequest.HTTPShouldHandleCookies = YES;來處理存儲在NSHTTPCookieStore中的cookie
    SDWebImageHandleCookies = 1 << 6,

    // 允許不受信任的SSL認證
    SDWebImageAllowInvalidSSLCertificates = 1 << 7,

    // 默認情況下,圖片下載按入隊的順序來執行。該標記將其移到隊列的前面,
    // 以便圖片能立即下載而不是等到當前隊列被加載
    SDWebImageHighPriority = 1 << 8,

    // 默認情況下,佔位圖片在加載圖片的同時被加載。該標記延遲佔位圖片的加載直到圖片已以被加載完成
    SDWebImageDelayPlaceholder = 1 << 9,

    // 通常我們不調用動畫圖片的transformDownloadedImage代理方法,因爲大多數轉換代碼可以管理它。
    // 使用這個票房則不任何情況下都進行轉換。
    SDWebImageTransformAnimatedImage = 1 << 10,
};

大家在看-downloadImageWithURL:options:progress:completed:,可以看到兩個SDWebImageOptions與SDWebImageDownloaderOptions中的選項是如何對應起來的,在此不多做解釋。

視圖擴展

我在使用SDWebImage的時候,使用得最多的是UIImageView+WebCache中的針對UIImageView的擴展方法,這些擴展方法將UIImageView與WebCache集成在一起,來讓UIImageView對象擁有異步下載和緩存遠程圖片的能力。其中最核心的方法是-sd_setImageWithURL:placeholderImage:options:progress:completed:,其使用SDWebImageManager單例對象下載並緩存圖片,完成後將圖片賦值給UIImageView對象的image屬性,以使圖片顯示出來,其具體實現如下:

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

    if (url) {
        __weak UIImageView *wself = self;

        // 使用SDWebImageManager單例對象來下載圖片
        id <SDWebImageOperation> operation = [SDWebImageManager.sharedManager downloadImageWithURL:url options:options progress:progressBlock completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) {
            if (!wself) return;
            dispatch_main_sync_safe(^{
                if (!wself) return;

                // 圖片下載完後顯示圖片
                if (image) {
                    wself.image = image;
                    [wself setNeedsLayout];
                } else {
                    if ((options & SDWebImageDelayPlaceholder)) {
                        wself.image = placeholder;
                        [wself setNeedsLayout];
                    }
                }
                if (completedBlock && finished) {
                    completedBlock(image, error, cacheType, url);
                }
            });
        }];
        [self sd_setImageLoadOperation:operation forKey:@"UIImageViewImageLoad"];
    } else {
        ...
    }
}

除了擴展UIImageView之外,SDWebImage還擴展了UIView、UIButton、MKAnnotationView等視圖類,大家可以參考源碼。

當然,如果不想使用這些擴展,則可以直接使用SDWebImageManager來下載圖片,這也是很OK的。

技術點

SDWebImage的主要任務就是圖片的下載和緩存。爲了支持這些操作,它主要使用了以下知識點:

  1. dispatch_barrier_sync函數:該方法用於對操作設置屏幕,確保在執行完任務後纔會執行後續操作。該方法常用於確保類的線程安全性操作。

  2. NSMutableURLRequest:用於創建一個網絡請求對象,我們可以根據需要來配置請求報頭等信息。

  3. NSOperation及NSOperationQueue:操作隊列是Objective-C中一種高級的併發處理方法,現在它是基於GCD來實現的。相對於GCD來說,操作隊列的優點是可以取消在任務處理隊列中的任務,另外在管理操作間的依賴關係方面也容易一些。對SDWebImage中我們就看到了如何使用依賴將下載順序設置成後進先出的順序。

  4. NSURLConnection:用於網絡請求及響應處理。在iOS7.0後,蘋果推出了一套新的網絡請求接口,即NSURLSession類。

  5. 開啓一個後臺任務。

  6. NSCache類:一個類似於集合的容器。它存儲key-value對,這一點類似於NSDictionary類。我們通常用使用緩存來臨時存儲短時間使用但創建昂貴的對象。重用這些對象可以優化性能,因爲它們的值不需要重新計算。另外一方面,這些對象對於程序來說不是緊要的,在內存緊張時會被丟棄。

  7. 清理緩存圖片的策略:特別是最大緩存空間大小的設置。如果所有緩存文件的總大小超過這一大小,則會按照文件最後修改時間的逆序,以每次一半的遞歸來移除那些過早的文件,直到緩存的實際大小小於我們設置的最大使用空間。

  8. 對圖片的解壓縮操作:這一操作可以查看SDWebImageDecoder.m中+decodedImageWithImage方法的實現。

  9. 對GIF圖片的處理

  10. 對WebP圖片的處理

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章