SDWebImage 源碼分析
首先我 fork 了 SDWebImage 的源碼,見 conintet/SDWebImage,這樣在本文的鏈接中都是鏈到我的 fork 中,這么做的目的是防止將來 SDWebImage 代碼發(fā)生變化導(dǎo)致本文的鏈接不準(zhǔn)確。
有關(guān) SD (SDWebImage 簡稱為 SD) 的使用方式還是得參考其 README 或者 wiki。本文只是閱讀其源碼的筆記。
圖片下載
最先分析的就是圖片下載部分的代碼,因?yàn)檫@是最核心的功能。
因?yàn)?SD 在 UIImageView 上通過 Category 的方式增加了簡單易用的 API,類似下面:
- (void)sd_setImageWithURL:(NSURL *)url placeholderImage:(UIImage *)placeholder;
于是通過幾步 Jump to Definition 就可以發(fā)現(xiàn),SD 的圖片下載操作是由 SDWebImageDownloaderOperation 來完成的,于是看一下它的初始化方法:
- (id)initWithRequest:(NSURLRequest *)request
options:(SDWebImageDownloaderOptions)options
progress:(SDWebImageDownloaderProgressBlock)progressBlock
completed:(SDWebImageDownloaderCompletedBlock)completedBlock
cancelled:(SDWebImageNoParamsBlock)cancelBlock;
通過上面的方法簽名,可以大概反向的知道:
- 使用了
NSURLRequest,那么很可能內(nèi)部就使用的NSURLConnection來完成的下載 - 既然提供了
progress、completed這兩個 callback,那么內(nèi)部勢必需要知道下載的進(jìn)度 - 因?yàn)樘峁┝?
cancelled這個 callback,那么內(nèi)部的下載操作還需要可以取消
再看一下 SDWebImageDownloaderOperation 是繼承于 NSOperation,因?yàn)橄螺d是一個可以獨(dú)立出來的計(jì)算單元,所以作為 Opreation 是很好理解的。然后在實(shí)際的圖片下載中,為了下載的效率,下載的 Opreations 之間肯定是需要并發(fā)的。Operation 默認(rèn)在其被調(diào)用的線程中是同步執(zhí)行的,不過由于 Operation Queue 的存在,它可以將其中的 Operations 分別 attach 到由系統(tǒng)控制的線程中,而這些由系統(tǒng)控制的線程之間是并發(fā)執(zhí)行的。
查看 SDWebImageDownloaderOperation 源碼發(fā)現(xiàn)內(nèi)部果然是使用的 NSURLConnection,那么由于需要提供 cancelled 的功能以及需要監(jiān)聽下載進(jìn)度,故必須將 NSURLConnection 的實(shí)例配置成異步的方式:
具體代碼在 L96
// 配置異步 NSURLConnection 的方式
// 實(shí)例化一個 NSURLConnection,并將自身(SDWebImageDownloaderOperation)設(shè)置為 NSURLConnection 實(shí)例的委托
self.connection = [[NSURLConnection alloc] initWithRequest:self.request delegate:self startImmediately:NO];
// 因?yàn)樯弦徊降?startImmediately:NO,所以這里手動的觸發(fā) start
// 這樣的效果和直接 startImmediately:YES 是一樣的
[self.connection start];
// 因?yàn)樯厦鎯刹浇Y(jié)合起來或者直接 startImmediately:YES 的結(jié)果就是下載例程將會在當(dāng)前 Run Loop 上以默認(rèn)的模式進(jìn)行調(diào)度,
// 而在 iOS 中除了主線程之外的線程都是默認(rèn)沒有運(yùn)行 Run Loop 的,所以需要手動的運(yùn)行一下
CFRunLoopRun();
// 之后的代碼將會被 CFRunLoopRun() 所阻塞,這樣 operation 所在的線程
// 就不會自動的退出,于是需要額外的代碼在下載完成之后手動的停止 RunLoop 使得
// operation 所在的線程可以退出
對于下載進(jìn)度的監(jiān)聽,SDWebImageDownloaderOperation 是通過將自身設(shè)置為 NSURLConnection 委托的形式完成的:
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response 在這一委托方法的實(shí)現(xiàn)中,SDWebImageDownloaderOperation 主要是獲取服務(wù)端響應(yīng)的 meta 信息,嘗試根據(jù)響應(yīng)的 statusCode 對下載過程進(jìn)行預(yù)判,比如如果是 304 狀態(tài)碼直接從本地緩存中返回圖片。但是這里的代碼寫的有些繁瑣了,并且性能上也是存在些問題。首先可以看下這幅概覽圖:

上面就是 URL Loading System 的層次結(jié)構(gòu),可見 NSHTTPURLResponse 是 NSURLResponse 唯一的子類,并且含有其父類沒有的 statusCode 方法。于是使用 isKindOfClass: 來判斷參數(shù)是否是 NSHTTPURLResponse 就可以了,使用 respondsToSelector: 沒有額外的好處而且丟失了性能,見 Performance penalty using respondsToSelector
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data 通過實(shí)現(xiàn)這個委托方法,就可以知道有 new response chunk 被接收,于是可以向外提供 progress,另外 SD 還實(shí)現(xiàn)了 display image progressively,按照代碼中的描述,出自于這里 Progressive image download with ImageIO,其中有一小段是說 iOS 的實(shí)現(xiàn)相對于 Mac 需要點(diǎn)額外的步驟,而我將其示例代碼下載了之后,在注釋掉其中關(guān)于 iOS 適配的部分代碼后運(yùn)行,發(fā)現(xiàn)注釋掉也是可以的:
/// Create the image
CGImageRef image = CGImageSourceCreateImageAtIndex(_imageSource, 0, NULL);
if (image) {
//#ifdef __IPHONE_4_0 // iOS
// CGImageRef imgTmp = [self createTransitoryImage:image];
// if (imgTmp) {
// [_delegate downloadedImageUpdated:imgTmp];
// CGImageRelease(imgTmp);
// }
//#else // Mac OS
// [_delegate downloadedImageUpdated:image];
//#endif
[_delegate downloadedImageUpdated:image];
CGImageRelease(image);
}
也就是說這段 L290 代碼實(shí)際是有一點(diǎn)性能問題的,應(yīng)該找到一個臨界的版本號以此適配老版本,而不是直接 TARGET_OS_IPHONE。
還有一點(diǎn)在使用時需要注意的就是,如果需要獲得具體的 progress 百分比,那么在 new chunk 到達(dá)的時候,除了需要知道已經(jīng)下載了的 chunks 的 size 總和之外,還需要知道 Content-Length,也就是在這里試圖通過響應(yīng)的 meta 信息(HTTP Headers)中獲取 expectedContentLength。
而根據(jù) HTTP 協(xié)議的描述 [1, 2],如果服務(wù)端的響應(yīng)采用了 chunked 的方式,那么客戶端實(shí)現(xiàn)必須忽略服務(wù)端響應(yīng)中的 Content-Length(如果有的話。按照標(biāo)準(zhǔn)定義,在使用 chunked 時,服務(wù)端也應(yīng)該不返回 Content-Length,當(dāng)然一般情況下也沒法返回),換句話說,如果服務(wù)端響應(yīng)的圖片信息使用 chunked transfer encoding 的話,那么客戶端在圖片沒有完全下載好之前就無法知道圖片的總大小,于是試圖顯示一個下載百分比的進(jìn)度條就不行了。這段算是 tips 吧。
- (void)connectionDidFinishLoading:(NSURLConnection *)aConnection,需要知道下載完成的時間點(diǎn),故實(shí)現(xiàn)了這個委托方法
在另外的一些委托方法中,SD 完成了取消下載的相應(yīng)操作,以及當(dāng)請求的 HTTPS 證書不可信時的操作,以及當(dāng)服務(wù)端資源需要訪問授權(quán)時的操作。
小結(jié)
SD 通過 SDWebImageDownloaderOperation 將圖片的下載操作封裝成 NSOperation,在內(nèi)部通過設(shè)置 NSURLConnection 為異步的方式,并將自身設(shè)置為 NSURLConnection 委托,從而向外部提供下載進(jìn)度控制的功能。
圖片緩存
下一步需要分析的就是 SD 的緩存機(jī)制,首先從 SD 的 README 中得知 SD 提供了常見了 two-levels cache 機(jī)制,即 memory-disk 的方式。在上一段分析下載的過程里,發(fā)現(xiàn) SD 下載圖片還是借由的 NSURLConnection,從 Understanding Cache Access 得知,iOS 中的 URL loading system 已經(jīng)自帶了 two-levels cache 的機(jī)制,那么為什么 SD 需要自己再實(shí)現(xiàn)一套呢?SD 自己是這樣解釋的,完整的解釋見 How is SDWebImage better than X?,大概的意思就是:
雖然 NSURLCache 提供了 two-levels cache,但是它緩存的內(nèi)容是 raw bytes,也就是說從 NSURLCache 中取出的是圖片的 raw bytes,如果需要使用圖片還需要進(jìn)行進(jìn)一步的操作,比如解析圖片的信息,使其成為在 iOS 中可以使用的形式。而 SD 的緩存的則是將解析后的可以在 iOS 中直接使用的圖片,這樣從緩存中取回的內(nèi)容就不需要在解析一遍了,從而進(jìn)一步節(jié)約了系統(tǒng)資源。
進(jìn)一步了解 two-levels cache 或者 N-levels cache,其核心思想就是將需要緩存的內(nèi)容放到多個 cache storages 中,然后在取出緩存內(nèi)容時,盡量的從響應(yīng)速度較快的 storage 中取回。那么很明顯,對于 memory-disk 這樣的 two-levels cache,無非就是將需要緩存的內(nèi)容同時放到 memory 和 disk 中,然后取回的時候先嘗試較快的 storage,那么勢必先檢索 memory cache storage,如果 memory cache 沒有命中的話,則嘗試 disk cache storage。下一步就是分析 SD 中具體是如何完成這些工作的。
首先 SD 中使用 SDWebImageManager 去集中管理圖片的下載操作,并且 SDWebImageManager 使用了單例的模式,在其初始化操作是這樣的:
- (id)init {
if ((self = [super init])) {
// 初始化 two-levels cache,它以 SDImageCache 的單例去操作
_imageCache = [self createCache];
// 以單例的形式初始化 SDWebImageDownloader
_imageDownloader = [SDWebImageDownloader sharedDownloader];
// 存放失敗的 URLs,為了 re-try 的判斷
_failedURLs = [NSMutableSet new];
// 正在運(yùn)行的 operations,方便統(tǒng)一的管理
_runningOperations = [NSMutableArray new];
}
return self;
}
執(zhí)行下載操作的是 SDWebImageManager 中的這個方法(具體的實(shí)現(xiàn)在 L110):
- (id <SDWebImageOperation>)downloadImageWithURL:(NSURL *)url
options:(SDWebImageOptions)options
progress:(SDWebImageDownloaderProgressBlock)progressBlock
completed:(SDWebImageCompletionWithFinishedBlock)completedBlock;
在 downloadImageWithURL 的具體實(shí)現(xiàn)中,使用了 SDWebImageCombinedOperation 來統(tǒng)一管理兩個操作(主要是取消的功能),一個操作就是先嘗試從緩存中取回圖片,另一個操作就是如果緩存沒有命中,嘗試從源地址下載的操作。這樣只要取消 SDWebImageCombinedOperation 就會同時取消那兩個操作。
在下載的 subOperation 中,使用了 weakOperation 見 L183
這是因?yàn)?這里,如果在 subOperation 中沒有使用 weakOperation 的話,那么就會發(fā)生 retain cycle:
retain retain
+---------------------------------+ +---------------------+ +----------------------+
| SDWebImageCombinedOperation +-----------> cancelBlock +-----------> subOperation |
+----------------^----------------+ +---------------------+ +-----------+----------+
| |
| |
| |
| retain |
+--------------------------------------------------------------------------+
另外由于需要在 self.runningOperation 中 add/remove SDWebImageCombinedOperation 的實(shí)例,所以加上了 __block 修飾。
由于 SDWebImageManager 是單例的形式,而其可能在多線程的情況下被調(diào)用,所以對于其非線程安全的屬性,在操作時使用了 @synchronized 來確保數(shù)據(jù)的完整性。
具體的業(yè)務(wù)邏輯是這樣的:
- 首先從 SD 自己的緩存存儲中嘗試取回圖片 L149
- 如果在 SD 自己的緩存存儲中沒有取到圖片,或者選項(xiàng)中標(biāo)記需要刷新緩存,那么此時就需要從源地址下載圖片,但是之前還需要判斷下源地址是否允許被下載 L158
-
L159 的意思是,如果選項(xiàng)標(biāo)記需要刷新緩存,但是在本地緩存中找到了相關(guān)圖片,那么就先使用這個緩存的圖片調(diào)用下
completedBlock,然后再繼續(xù)進(jìn)行下載操作。
其實(shí)這一步放得有些散了,它是和 L180 以及 L216 搭配起來的。通過 L180,當(dāng)發(fā)現(xiàn) Response 是被 NSURLCache 緩存的,那么 L216 的條件就會滿足,為什么會滿足呢?因?yàn)?這里,于是 downloadedImage 是 nil。
滿足條件了于是就什么也沒做(要做的在 L159 已經(jīng)被做了)。也就是說一旦設(shè)置了 SDWebImageRefreshCached 選項(xiàng),那么在使用 NSURLConnection 下載的時候,發(fā)現(xiàn) Response 是此前緩存的,那么就直接從 SD 的緩存中返回處理好的圖片,這么做的原因上文已經(jīng)說過了 NSURLCache 的緩存是數(shù)據(jù)的 raw bytes,而 SD 中緩存的圖片數(shù)據(jù)是 out of the box。
- 如果新下載了圖片,那么肯定是要先將其存儲在 SD 緩存中,SD 提供了緩存選項(xiàng)可以讓調(diào)用者決定是單存 memory 或 disk 或 both,見 L237。
上面主要是分析了 SDWebImageManager 在下載圖片時的操作,即先檢索本地 SD 緩存,然后再根據(jù)下載選項(xiàng)決定是否從源地址進(jìn)行下載,以及下載好圖片之后將其存放到 SD 緩存中。
并發(fā)下載
在第一節(jié)中介紹了 SD 將下載操作封裝為了 SDWebImageDownloaderOperation。SD 內(nèi)部在使用時,并不是直接操作 SDWebImageDownloaderOperation 的,而是使用的 SDWebImageDownloader 單例,在 SDWebImageDownloader 單例初始化的時候,產(chǎn)生了一個 NSOperationQueue,見 L67,并且設(shè)置了對了的并發(fā)數(shù)為 6,見 L68。然后在需要下載的時候,將 SDWebImageDownloaderOperation 實(shí)例添加到了其內(nèi)部的下載隊(duì)列中,這要就完成了并發(fā)下載的功能。
緩存的細(xì)節(jié)
現(xiàn)在開始分析下 SD 中的一些關(guān)于緩存操作的細(xì)節(jié)。檢索本地 SD 緩存分為兩步,當(dāng)檢索 memory cache storage 時,采用的是同步的方式,這是因?yàn)閮?nèi)存緩存的操作速度是很快的,當(dāng)檢索 disk cache storage 時,SD 使用的是異步的方式,見 L372。SD 將緩存存儲以及其相關(guān)的操作封裝為 SDImageCache 并且以單例的模式進(jìn)行操作,SDImageCache 的初始化在 SDWebImageManager 的初始化中進(jìn)行調(diào)用。
有一點(diǎn)需要注意的就是,SD 中實(shí)現(xiàn)的 sharedXXX 方法并不能表示一個確切的單例模式,具體的描述見 Create singleton using GCD's dispatch_once in Objective C,如果用其他面向?qū)ο笳Z言描述的話就是,必須將構(gòu)造函數(shù)隱藏起來不要讓外部調(diào)用到,比如設(shè)置成 private,然后提供一個類似 getSingleton 的靜態(tài)方法。不過就像上面的鏈接中描述的一樣,如果口頭約定總是使用 sharedXXX 方法來獲取實(shí)例對象的話那也沒有太大的問題。
對于異步的檢索磁盤的方式,SD 采用的是 GCD,首先在 SDImageCache 初始化時創(chuàng)建了一個 ioQueue,注意 SD 中采用的是一個 serial queue,見 L99。使用 serial queue 的目的就是省得使用鎖去管理磁盤數(shù)據(jù)的讀寫了。
對于內(nèi)存緩存,SD 實(shí)現(xiàn)了一個內(nèi)部類 AutoPurgeCache,它繼承自 NSCache,功能就是在通過 Notification 來接受內(nèi)存不足的通知,然后清除自身存儲緩存所占用的內(nèi)存空間。但是注意到一個細(xì)節(jié),比如在 L106,看到下面的代碼:
dispatch_async(dispatch_get_main_queue(), ^{
[[NSNotificationCenter defaultCenter] postNotificationName:SDWebImageDownloadStartNotification object:self];
});
為什么需要在主線程上 postNotificationName:(注:如遇到方法的簽名我沒有寫全的情況請不必在意) 呢?
具體的內(nèi)容在 Notification Programming Topics,大概的意思就是:
Regular notification centers deliver notifications on the thread in which the notification was posted. Distributed notification centers deliver notifications on the main thread. At times, you may require notifications to be delivered on a particular thread that is determined by you instead of the notification center. For example, if an object running in a background thread is listening for notifications from the user interface, such as a window closing, you would like to receive the notifications in the background thread instead of the main thread. In these cases, you must capture the notifications as they are delivered on the default thread and redirect them to the appropriate thread.
上面的一段引用其實(shí)說了幾點(diǎn)內(nèi)容,不過當(dāng)前只需要知道第一句的意思:通常情況下 notification center 會把 posted notifications 派送給與 post 動作所在的同一線程中的 observers。而上面的 L106 中的代碼可以看出,它期望的 observers 是在主線程的,那么 observers 就可以在主線程中更新 UI 來給用戶相關(guān)的進(jìn)度提示。
那為什么需要 dispatch_async 呢?這是因?yàn)?Notification Centers 中描述的:
A notification center delivers notifications to observers synchronously. In other words, when posting a notification, control does not return to the poster until all observers have received and processed the notification. To send notifications asynchronously use a notification queue, which is described in Notification Queues
再看 AutoPurgeCache 中注冊的 observer L24,observer 注冊在 AutoPurgeCache 運(yùn)行時所在的線程,根據(jù)上面的第一段引用中的描述,對于 local notification 而言,postor 和 receiver 需要在同一線程,于是就猜測是不是對于系統(tǒng)通知而言,會在所有的線程上進(jìn)行 notify。但是沒有在 Apple Doc 中找到明確的相關(guān)文字描述,不過進(jìn)過測試確實(shí)對于系統(tǒng)通知而言,notifition center 會對進(jìn)程中的所有線程進(jìn)行 notify。下面是測試的代碼:
@interface Worker : NSThread
@end
@implementation Worker
- (void)main
{
NSLog(@"Worker is running...");
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(testNotification) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
[[NSRunLoop currentRunLoop] addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
[[NSRunLoop currentRunLoop] run];
NSLog(@"Worker is exiting...");
}
- (void)testNotification
{
NSLog(@"testNotification");
}
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
Worker* worker = [[Worker alloc] init];
[worker start];
}
可以運(yùn)行模擬器然后 Hardware -> Simulate Memory Warning 就可以看到子線程是可以接收到通知的。
以上就是我閱讀源碼后的分析,雖然沒有面面俱到,也還是希望能有所幫助。
[2015-11-24 修正]
上面有一段這樣說到:
另外由于需要在 self.runningOperation
中 add/remove
SDWebImageCombinedOperation
的實(shí)例,所以加上了 __block
修飾
我今天回頭看了一下,發(fā)現(xiàn)我之前那樣的描述是不對的。
首先可以看下這里的描述,大概意思就是說如果需要讓那些被 block 所 captured 變量是 mutable 的,那么就需要使用 __block 前綴去修飾。
那么看看上面提到的 SD 中的代碼,簡化后就是這樣:
// 這里的 __block 不需要
__block SDWebImageCombinedOperation *operation = [SDWebImageCombinedOperation new];
operation.cacheOperation = [self.imageCache queryDiskCacheForKey:key done:^(UIImage *image, SDImageCacheType cacheType) {
@synchronized (self.runningOperations) {
[self.runningOperations addObject:operation];
}
@synchronized (self.runningOperations) {
[self.runningOperations removeObject:operation];
}
}];
return operation;
注意到在 cacheOperation 那一行產(chǎn)生的 block,它對 operation 進(jìn)行了 capture,但是在 block 內(nèi)部并沒有改變 operation 的指向。所以這里的 __block 是不需要的。Obj 對象在 block 是以引用去操作的,可以想象是對象的內(nèi)存地址被捕獲,如果是這樣就需要加上 __block:
__block SDWebImageCombinedOperation *operation = nil;
operation.cacheOperation = [self.imageCache queryDiskCacheForKey:key done:^(UIImage *image, SDImageCacheType cacheType) {
// 捕獲這 operation,然而我們需要改變它的內(nèi)容
// 把它的內(nèi)容變成新對象的地址
// 所以上面使用了 __block 前綴修飾
operation = [SDWebImageCombinedOperation new]
@synchronized (self.runningOperations) {
[self.runningOperations addObject:operation];
}
@synchronized (self.runningOperations) {
[self.runningOperations removeObject:operation];
}
}];
return operation;
我看可以使用下面的代碼來驗(yàn)證下上面的說法:
//
// main.m
// __block
//
// Created by mconintet on 11/24/15.
// Copyright ? 2015 mconintet. All rights reserved.
//
#import <Foundation/Foundation.h>
int main(int argc, const char* argv[])
{
@autoreleasepool
{
static NSMutableArray* arr;
static dispatch_once_t once;
dispatch_once(&once, ^{
arr = [[NSMutableArray alloc] init];
});
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
dispatch_queue_t queue = dispatch_queue_create("queue", DISPATCH_QUEUE_SERIAL);
NSInteger opCount = 3;
for (NSInteger i = opCount; i > 0; i--) {
NSOperation* op = [[NSOperation alloc] init];
dispatch_async(queue, ^{
[arr addObject:op];
});
dispatch_async(queue, ^{
[arr removeObject:op];
if (![arr count]) {
dispatch_semaphore_signal(sema);
}
});
}
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
NSLog(@"arr count: %ld", [arr count]);
}
return 0;
}
對比下這段代碼:
//
// main.m
// __block
//
// Created by mconintet on 11/24/15.
// Copyright ? 2015 mconintet. All rights reserved.
//
#import <Foundation/Foundation.h>
int main(int argc, const char* argv[])
{
@autoreleasepool
{
static NSMutableArray* arr;
static dispatch_once_t once;
dispatch_once(&once, ^{
arr = [[NSMutableArray alloc] init];
});
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
dispatch_queue_t queue = dispatch_queue_create("queue", DISPATCH_QUEUE_SERIAL);
NSInteger opCount = 3;
for (NSInteger i = opCount; i > 0; i--) {
NSOperation* op;
dispatch_async(queue, ^{
op = [[NSOperation alloc] init]
[arr addObject:op];
});
dispatch_async(queue, ^{
[arr removeObject:op];
if (![arr count]) {
dispatch_semaphore_signal(sema);
}
});
}
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
NSLog(@"arr count: %ld", [arr count]);
}
return 0;
}
你會發(fā)現(xiàn)后一段代碼會被 IDE 提示:

為什么不能賦值?因?yàn)橹羔樀牟东@也是作為了 const,和基本類型一樣。
總結(jié)起來說就是,objc 對象在 block 中捕獲的是指向其真實(shí)地址的指針,指針以 const 的形式被捕獲,不使用 __block 修飾就無法改變指針的內(nèi)容,但是對于指針指向的對象,它們的內(nèi)容還是可以改變的。
[2015-11-26 修正]
上面的關(guān)于 NSNotification 的說明有些紕漏,修正見 NSNotificationCenter