SDWebImage源碼解讀之干貨大總結

這是我認為的一些重要的知識點進行的總結。

1.圖片編碼簡介

大家都知道,數據在網絡中是以二進制流的形式傳播的,那么我們該如何把那些1和0解析成我們需要的數據格式呢?

說的簡單一點就是,當文件都使用二進制流作為傳輸時,需要制定一套規范,用來區分該文件到底是什么類型的。 文件頭有很多個,我們在這里就介紹一些主流的且跟圖片相關的文件頭。

  • JPEG (jpg),文件頭:FFD8FFE1
  • PNG (png),文件頭:89504E47
  • GIF (gif),文件頭:47494638
  • TIFF tif;tiff 0x49492A00
  • TIFF tif;tiff 0x4D4D002A
  • RAR Archive (rar),文件頭:52617221
  • WebP : 524946462A73010057454250

可以看出來我們通過每個文件頭的第一個字節就能判斷出是什么類型。但是值得注意的是52開頭的。這個要做特別的判斷。

WebP這種格式很特別。是由12個字節組成的文件頭,我們如果把這些字節通過ASCII編碼后會得到下邊這樣一張表格:


這里有一個小技巧:我們如何獲取NSData中的第一個字節呢?答案是:

uint8_t c;
[data getBytes:&c length:1];

2.如何提醒某個方法已廢棄

+ (NSString *)contentTypeForImageData:(NSData *)data __deprecated_msg("Use `sd_contentTypeForImageData:`");

通過__deprecated_msg宏告訴開發者該方法不建議使用,那么在平時的開發中,如果一個方法被另一個新的方法替代了,就可以使用這個宏來告訴其他的開發者,我有了一個更好的方案。

- (void)oldTest __deprecated_msg("該方法已被`newTest`替代,請使用新方法!");

3.修改圖片到指定尺寸

為什么要修改圖片到指定尺寸呢?一個最主要的原因就是我們確實需要某個尺寸的圖片,但是還有一個與性能優化相關的原因。

大家都知道屏幕是由一個個的像素組成的,像素的原理先不說,假如一個控件的大小是(100, 100),我們下載的圖片是(200, 200),若要把200*200的圖片放到100*100的屏幕上是需要額外的計算的。這涉及了像素對其的問題,我們可以把下載下載的圖片在異步線程修改為100*100的尺寸,在主線程顯示,能夠一定程度的提升性能。

如果我們想獲取一個img的實際尺寸,應該使用image.size*image.scaleSDWebImage通過- (UIImage *)sd_animatedImageByScalingAndCroppingToSize:(CGSize)size這個方法可以實現修改圖片到指定尺寸的功能。這個方法的內部主要是通過計算和繪制實現。

有一個重要的知識點:我們該如何把NSData轉為UIImage呢?UIImage有一個方法可以轉換,但是如果這個NSData存放了很多張圖片呢?這個時候應該如何解析?

這個時候就需要使用CGImageSourceRef了,我們應該要記住,把NSData轉成CGImageSourceRef后,我們就能獲得很多屬性。包括圖片數量,duration等很多信息。

有這樣一個方法:UIImage *SDScaledImageForKey(NSString *key, UIImage *image),目的是根據圖片的名字來scale圖片,這是什么意思呢?假如我們把兩張圖片導入到工程中,他們的名字是img@2x.pngimg@3x.png,他們的大小分別為:200*200, 300*300。我們通過imageNamed賦值的時候,會發現其實這個圖片只有100*100的大小。那么在不同的設備上,Apple是怎么選擇出正確的圖片呢?

4.配置文件

我們平時可能很少做跨平臺的開發工作,因此配置文件就顯得沒那么必要,我們在這里簡要的介紹下SD中配置文件的組成:

#ifdef __OBJC_GC__
    #error SDWebImage does not support Objective-C Garbage Collection
#endif

SDWebImage不支持垃圾回收機制,垃圾回收(Gargage-collection)是Objective-c提供的一種自動內存回收機制。在iPad/iPhone環境中不支持垃圾回收功能。
當啟動這個功能后,所有的retain,autorelease,release和dealloc方法都將被系統忽略。

#if !TARGET_OS_IPHONE && !TARGET_OS_IOS && !TARGET_OS_TV && !TARGET_OS_WATCH
    #define SD_MAC 1
#else
    #define SD_MAC 0
#endif

使用預編譯的一個最大的好處就是:在代碼編譯階段可以把不需要的代碼不進行編譯。

5.dispatch_main_async_safe

我們來看看這個宏,按理說我使用dispatch_main_async就可以了,為什么要加入safe呢?那么這個safe主要是解決那些不安全的問題呢?

#ifndef dispatch_main_async_safe
#define dispatch_main_async_safe(block)\
    if (strcmp(dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL), dispatch_queue_get_label(dispatch_get_main_queue())) == 0) {\
        block();\
    } else {\
        dispatch_async(dispatch_get_main_queue(), block);\
    }
#endif
  • 我們可以像這樣在定義宏的時候使用換行,但需要添加 \ 操作符
  • 如果當前線程已經是主線程了,那么在調用dispatch_async(dispatch_get_main_queue(), block)有可能會出現crash
  • 如果當前線程是主線程,直接調用,如果不是,調用dispatch_async(dispatch_get_main_queue(), block)

6.圖像存儲

首先圖像的存儲是二維的,所以我們需要考慮如何表示圖像中某個特定位置的值。然后,我們需要考慮具體的值應該如何量化。另外,根據我們捕捉圖像的途徑,也會有不同的方式來編碼圖形數據。一般來說,最直觀的方式是將其存為位圖數據,可如果你想處理一組幾何圖形,效率就會偏低。一個圓形可以只由三個值 (兩個坐標值和半徑) 來表示,使用位圖會使文件更大,卻只能做粗略的近似。

不同于位圖把值存在陣列中,矢量格式存儲的是繪圖圖像的指令。在處理一些可以被歸納為幾何形狀的簡單圖像時,這樣做顯然更有效率;但面對照片數據時矢量儲存就會顯得乏力了。建筑師設計房屋更傾向于使用矢量的方式,因為矢量格式并不僅僅局限于線條的繪制,也可以用漸變或圖案的填充作為展示,所以利用矢量方式完全可以生成房屋的擬真渲染圖。

用于填充的圖案單元則更適合被儲存為一個位圖,在這種情況下,我們可能需要一個混合格式。一個非常普遍的混合格式的一個例子是 PostScript,(或者時下比較流行的衍生格式,PDF),它基本上是一個用于繪制圖像的描述語言。上述格式主要針對印刷業,而 NeXT 和 Adobe 開發的 Display Postscript 則是進行屏幕繪制的指令集。PostScript 能夠排布字母,甚至位圖,這使得它成為了一個非常靈活的格式。

7.矢量圖像

矢量格式的一大優點是縮放。矢量格式的圖像其實是一組繪圖指令,這些指令通常是獨立于尺寸的。如果你想擴大一個圓形,只需在繪制前擴大它的半徑就可以了。位圖則沒這么容易。最起碼,如果擴大的比例不是二的倍數,就會涉及到重繪圖像,并且各個元素都只是簡單地增加尺寸,成為一個色塊。由于我們不知道這圖像是一個圓形,所以無法確保弧線的準確描繪,效果看起來肯定不如按比例繪制的線條那樣好。也因此,在像素密度不同的設備中,矢量圖像作為圖形資源會非常有用。位圖的話,同樣的圖標,在視網膜屏幕之前的 iPhone 上看起來并沒有問題,在拉伸兩倍后的視網膜屏幕上看起來就會發虛。就好像僅適配了 iPhone 的 App 運行在 iPad 的 2x 模式下就不再那么清晰了。

雖然 Xcode 6 已經支持了 PDF 格式,但迄今仍不完善,只是在編譯時將其創建成了位圖圖像。最常見的矢量圖像格式為 SVG,在 iOS 中也有一個渲染 SVG 文件的庫,SVGKit。

8.位圖

大部分圖像都是以位圖方式處理的,從這里開始,我們就將重點放在如何處理它們上。第一個問題,是如何表示兩個維度。所有的格式都以一系列連續的行作為單元,而每一行則水平地按順序存儲了每個像素。大多數格式會按照行的順序進行存儲,但是這并不絕對,比如常見的交叉格式,就不嚴格按照行順序。其優點是當圖像被部分加載時,可以更好的顯示預覽圖像。在互聯網初期,這是一個問題,隨著數據的傳輸速度提升,現在已經不再被當做重點。

表示位圖最簡單的方法是將二進制作為每個像素的值:一個像素只有開、關兩種狀態,我們可以在一個字節中存儲八個像素,效率非常高。不過,由于每一位只有最多兩個值,我們只能儲存兩種顏色。考慮到現實中的顏色數以百萬計,上述方法聽起來并不是很有用。不過有一種情況還是需要用到這樣的方法:遮罩。比如,圖像的遮罩可以被用于透明性,在 iOS 中,遮罩被應用在 tab bar 的圖標上 (即便實際圖標不是單像素位圖)。

如果要添加更多的顏色,有兩個基本的選擇:使用一個查找表,或直接用真實的顏色值。GIF 圖像有一個顏色表 (或色彩面板),可以存儲最多 256 種顏色。存儲在位圖中的值是該查詢列表中的索引值,對應著其相應的顏色。所以,GIF 文件僅限于 256 色。對于簡單的線條圖或純色圖,這是一種不錯的解決方法。但對于照片來說,就會顯示的不夠真實,照片需要更精細的顏色深度。進一步的改進是 PNG 文件,這種格式可以使用一個預置的色板或者獨立的通道,它們都支持可變的顏色深度。在一個通道中,每個像素的顏色分量 (紅,綠,藍,即 RGB,有時添加透明度值,即RGBA) 是直接指定的。

GIF 和 PNG 對于具有大面積相同顏色的圖像是最好的選擇,因為它們使用的 (主要是基于游程長度編碼的) 壓縮算法可以減少存儲需求。這種壓縮是無損的,這意味著圖像質量不會被壓縮過程影響。

一個有損壓縮圖像格式的例子是 JPEG。創建 JPEG 圖像時,通常會指定一個與圖像質量相關的壓縮比值參數,壓縮程度過高會導致圖像質量惡化。JPEG 不適用于對比鮮明的圖像 (如線條圖),其壓縮方式對類似區域的圖像質量損害會相對嚴重。如果某張截圖中包含了文本,且保存為 JPEG 格式,就可以清楚地看到:生成的圖像中字符周圍會出現雜散的像素點。在大部分照片中不存在這個問題,所以照片主要使用 JPEG 格式。

總結:就放大縮小而言,矢量格式 (如 SVG) 是最好的。對比鮮明且顏色數量有限的線條圖最適合 GIF 或 PNG (其中 PNG 更為強大),而照片,則應該使用 JPEG。當然,這些都不是不可逾越的規則,不過通常而言,對一定的圖像質量與圖像尺寸而言,遵守規則會得到最好的結果。

9.NSCache

對于很多開發者來說,NSCache是一個陌生人,因為大家往往對NSMutableDictionary情有獨鐘。可憐的 NSCache 一直處于 NSMutableDictionary 的陰影之下。就好像沒有人知道它提供了垃圾處理的功能,而開發者們卻費勁力氣地去自己實現它。

沒錯,NSCache 基本上就是一個會自動移除對象來釋放內存的 NSMutableDictionary。無需響應內存警告或者使用計時器來清除緩存。唯一的不同之處是鍵對象不會像 NSMutableDictionary 中那樣被復制,這實際上是它的一個優點(鍵不需要實現 NSCopying 協議)。

當有緩存數據到內存的業務的時候,就應該考慮NSCache了,有緩存就有清楚緩存。

NSCache 每個方法和屬性的具體作用,請參考這篇文章NSCache

NSCache在收到內存警告的時候會釋放自身的一部分資源

如果我們想在收到內存警告時,釋放所有的內容,可以參考下邊的代碼:

@interface AutoPurgeCache : NSCache
@end

@implementation AutoPurgeCache

- (nonnull instancetype)init {
    self = [super init];
    if (self) {
#if SD_UIKIT
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(removeAllObjects) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
#endif
    }
    return self;
}

- (void)dealloc {
#if SD_UIKIT
    [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
#endif
}

@end

10.NSOperation

NSOperation想必大家都知道,為了讓程序執行的更快,我們用多線程異步的方式解決這個問題,GCDNSOperation都能實現多線程,我們這里只介紹NSOperation。如果大家想了解更多NSOperation的知識,我覺得這篇文章寫得挺好:多線程之NSOperation簡介

我們把NSOperation最核心的使用方法總結一下:

  1. NSOperation有兩個方法:main()start()。如果想使用同步,那么最簡單方法的就是把邏輯寫在main()中,使用異步,需要把邏輯寫到start()中,然后加入到隊列之中。
  2. 大家有沒有想過NSOperation什么時候執行呢?按照正常想法,難道要我們自己手動調用main()start()嗎?這樣肯定也是行的。當調用start()的時候,默認的是在當前線程執行同步操作,如果是在主線程調用了,那么必然會導致程序死鎖。另外一種方法就是加入到operationQueue中,operationQueue會盡快執行NSOperation,如果operationQueue是同步的,那么它會等到NSOperation的isFinished等于YES后,在執行下一個任務,如果是異步的,通過設置maxConcurrentOperationCount來控制同事執行的最大操作,某個操作完成后,繼續其他的操作。
  3. 并不是調用了canche就一定取消了,如果NSOperation沒有執行,那么就會取消,如果執行了,只會將isCancelled設置為YES。所以,在我們的操作中,我們應該在每個操作開始前,或者在每個有意義的實際操作完成后,先檢查下這個屬性是不是已經設置為YES。如果是YES,則后面操作都可以不用在執行了。

能夠引起思考的地方就是,比如說我有一系列的任務要執行,我有兩種選擇,一種是通過數組控制數據的取出順序,另外一種就是使用隊列

11.dispatch_barrier_async

我們可以創建兩種類型的隊列,串行和并行,也就是DISPATCH_QUEUE_SERIAL,DISPATCH_QUEUE_CONCURRENT。那么dispatch_barrier_async和dispatch_barrier_sync究竟有什么不同之處呢?

barrier這個詞是柵欄的意思,也就是說是用來做攔截功能的,上邊的這另種都能夠攔截任務,換句話說,就是只有我的任務完成后,隊列后邊的任務才能完成。

不同之處就是,dispatch_barrier_sync控制了任務往隊列添加這一過程,只有當我的任務完成之后,才能往隊列中添加任務。dispatch_barrier_async不會控制隊列添加任務。但是只有當我的任務完成后,隊列中后邊的任務才會執行。

那么在這里的任務是往數組中添加數據,對順序沒什么要求,我們采取dispatch_barrier_async就可以了,已經能保證數據添加的安全性了。

- (nullable NSArray<id> *)callbacksForKey:(NSString *)key {
    __block NSMutableArray<id> *callbacks = nil;
    dispatch_sync(self.barrierQueue, ^{
        // We need to remove [NSNull null] because there might not always be a progress block for each callback
        callbacks = [[self.callbackBlocks valueForKey:key] mutableCopy];
        [callbacks removeObjectIdenticalTo:[NSNull null]];
    });
    return [callbacks copy];    // strip mutability here
}

這個方法是根據key取出所有符合key的block,這里采用了同步的方式,相當于加鎖。比較有意思的是[self.callbackBlocks valueForKey:key]這段代碼,self.callbackBlocks是一個數組,我們假定他的結構是這樣的:

@[@{@"completed" : Block1}, 
@{@"progress" : Block2}, 
@{@"completed" : Block3}, 
@{@"progress" : Block4}, 
@{@"completed" : Block5}, 
@{@"progress" : Block6}]

調用[self.callbackBlocks valueForKey:@"progress"]后會得到[Block2, Block4, Block6].
removeObjectIdenticalTo:這個方法會移除數組中指定相同地址的元素。

- (BOOL)cancel:(nullable id)token {
    __block BOOL shouldCancel = NO;
    dispatch_barrier_sync(self.barrierQueue, ^{
        [self.callbackBlocks removeObjectIdenticalTo:token];
        if (self.callbackBlocks.count == 0) {
            shouldCancel = YES;
        }
    });
    if (shouldCancel) {
        [self cancel];
    }
    return shouldCancel;
}

這個函數,就是取消某一回調。使用了dispatch_barrier_sync,保證,必須該隊列之前的任務都完成,且該取消任務結束后,在將其他的任務加入隊列。

12.對我的一點聯想

如果有人問你:你怎么看待編程這件事?你怎么回答。這個問題是我在看這個類的時候,忽然出現在我腦子中的。我突然意識到,其實不管是函數還是屬性,他們都是數據。我們編寫的所有程序都是在處理數據。函數本身也是一種特殊的數據

真正難的是生產數據的這一過程。舉個例子,給你一堆菜籽,要求生產出油來。怎么辦?我們首先為這個任務設計一個函數:

- (油)用菜籽生產油(菜籽);

這就是我們最外層的函數,也應該是我們最開始想到的函數。然后經過我們的研究發現,這個生產過程很復雜,必須分工合作才能實現。于是我們把這個任務分割為好幾個小任務:

1. - (干凈的菜籽)取出雜質(菜籽);
2. - (炒熟的菜籽)把菜籽炒一下(干凈的菜籽);
3. - (蒸了的菜籽)把菜籽蒸一下(炒熟的菜籽);
4. - (捆好的菜籽)把菜籽包捆成一塊(蒸了的菜籽);
5. - (油)撞擊菜籽包(捆好的菜籽);

大家有沒有發現,整個榨油的過程就是對數據的處理。這一點其實很重要。如果沒有把- (油)用菜籽生產油(菜籽);這一任務進行拆分,我們就會寫出復雜無比的函數。那么就有人要問了,只要實現這個功能就行了唄。其實這往往是寫不出好代碼的原因。

整個任務的設計應該是事先就設計好的。任務被分割成更小更簡單的部分,然后再去實現這些最小的任務,不應該是編寫邊分割任務,往往臨時分割的任務(也算是私有函數吧)沒有最正確的界限。

有了上邊合理的分工之后呢,我們就可以進行任務安排了。我們回到現實開發中來。上邊5個子任務的難度是不同的。有的人可能基礎比較差,那么讓他去干篩菜籽這種體力活,應該沒問題。那些炒或者蒸的子任務是要掌握火候的,也就是說有點技術含量。那么就交給能勝任這項工作的人去做。所有的這一切,我們只要事先定義好各自的生產結果就行了,完全不影響每個程序的執行。

怎么樣?大家體會到這種編程設計的好處了嗎?我還可以進行合并,把炒和煮合成一個小組,完全可行啊。好了這方面的思考就說這么多吧。如果我想買煮熟了的菜籽,是不是也很簡單?

有的人用原始的撞擊菜籽包榨油,有的人卻用最先進的儀器榨油,這就是編程技術和知識深度的區別啊。

13.initialize和load的區別

initializeload 這兩個方法比較特殊,我們通過下邊這個表格來看看他們的區別

.. +(void)load +(void)initialize
執行時機 在程序運行后立即執行 在類的方法第一次被調時執行
若自身未定義,是否沿用父類的方法?
類別中的定義 全都執行,但后于類中的方法 覆蓋類中的方法,只執行一個

14權重系數q

_HTTPHeaders = [@{@"Accept": @"image/webp,image/*;q=0.8"} mutableCopy];

我們看看image/webp,image/*;q=0.8是什么意思,image/webp是web格式的圖片,q=0.8指的是權重系數為0.8,q的取值范圍是0 - 1, 默認值為1,q作用于它前邊分號;前邊的內容。在這里,image/webp,image/*;q=0.8表示優先接受image/webp,其次接受image/*的圖片。

15.dispatch_group_enter和dispatch_group_leave

SDWebImage源碼解讀之SDWebImageDownloader的評論區,有小伙伴提出了SD在特定使用場景會崩潰的情況,我也做了一些實驗。

- (void)test {
    NSURL *url = [NSURL URLWithString:@"http://upload-images.jianshu.io/upload_images/1432482-dcc38746f56a89ab.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240"];
    
    SDWebImageManager *manager = [SDWebImageManager sharedManager];
    
    dispatch_group_t group = dispatch_group_create();
    
    dispatch_group_enter(group);
    [manager loadImageWithURL:url options:SDWebImageRefreshCached progress:nil completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, SDImageCacheType cacheType, BOOL finished, NSURL * _Nullable imageURL) {
        dispatch_group_leave(group);
    }];
    
    dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        NSLog(@"下載完成了");
    });
}

在上邊的函數中,我使用了dispatch_group_t dispatch_group_enter dispatch_group_leave 目的是等待所有的異步任務完成。

enter和leave方法必須成對出現,如果調用leave的次數多于enter就會崩潰,當我們使用SD時,如果Options設置為SDWebImageRefreshCached,那么這個completionBlock至少會調用兩次,首先返回緩存中的圖片。其次在下載完成后再次調用Block,這也就是崩潰的原因。

要想重現上邊方法的崩潰,等圖片下載完之后,再從新調用該方法就行。

16.SDWebImage主要組成模塊

總結

以上就是SDWebImage中的一些小知識點,下一篇我會帶來Alamofire的源碼解讀。

由于個人知識有限,如有錯誤之處,還望各路大俠給予指出啊

  1. SDWebImage源碼解讀 之 NSData+ImageContentType 簡書 博客園
  2. SDWebImage源碼解讀 之 UIImage+GIF 簡書 博客園
  3. SDWebImage源碼解讀 之 SDWebImageCompat 簡書 博客園
  4. SDWebImage源碼解讀 之SDWebImageDecoder 簡書 博客園
  5. SDWebImage源碼解讀 之SDWebImageCache(上) 簡書 博客園
  6. SDWebImage源碼解讀之SDWebImageCache(下) 簡書 博客園
  7. SDWebImage源碼解讀之SDWebImageDownloaderOperation 簡書 博客園
  8. SDWebImage源碼解讀之SDWebImageDownloader 簡書 博客園
  9. SDWebImage源碼解讀之SDWebImageManager 簡書 博客園
  10. SDWebImage源碼解讀之SDWebImagePrefetcher 簡書 博客園
  11. SDWebImage源碼解讀之分類 簡書 博客園
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容