YYWebImage 原始碼剖析:執行緒處理與快取策略

波兒菜發表於2019-07-30

YYKit 系列原始碼剖析文章:

引言

在 iOS 開發中,非同步網路圖片下載框架可以說是很大的解放了生產力,通常情況下開發者只需要簡單的程式碼就能將網路圖片非同步下載並顯示到手機螢幕上,並且還帶有快取優化。

業界名氣最高的非同步圖片下載框架是 SDWebImage,而後 ibireme 前輩開源了 YYWebImage,對效能有所優化。之前有粗略的瀏覽過 SDWebImage 的原始碼,對比 YYWebImage 原始碼過後,實際上筆者更喜歡 YYWebImage,因為其程式碼風格很簡潔、程式碼結構更清晰。

技術層面來看,兩者對執行緒處理的處理方式有所不同,快取策略也有細節上的差異,雖然筆者的理解來看 YYWebImage 效能更為優越,但是並沒有充分的測試用例來驗證。有些遺憾的是,YYWebImage 似乎挺久沒有維護了,作者在 這條 issues 說過計劃會將NSURLConnection替換為NSURLSession,到現在都沒有動作?。

所以實際開發中為了穩定性可能還是會首選 SDWebImage,但是這絲毫不影響我們學習 YYWebImage 的優秀原始碼,本文主要是分析 YYWebImage 的核心思路和亮點。

原始碼版本:1.0.5

一、框架總覽

//包含所有檔案的標頭檔案
YYWebImage.h
//快取相關
YYImageCache.h (.m)
//請求任務預處理類
_YYWebImageSetter.h (.m)
//請求任務管理類
YYWebImageManager.h (.m)
//自定義請求類(繼承自NSOperation)
YYWebImageOperation.h (.m)
//方便業務呼叫的分類
CALayer+YYWebImage.h (.m)
MKAnnotationView+YYWebImage.h (.m)
UIButton+YYWebImage.h (.m)
UIImage+YYWebImage.h (.m)
UIImageView+YYWebImage.h (.m)
複製程式碼

上面這些方便業務呼叫的分類,它們的實現大同小異,使用最多的是UIImageView+YYWebImage.h,完全可以以其為入口探究框架的原理。

正如作者框架的簡短說明:

Asynchronous image loading framework.

該框架的核心就是非同步下載網路圖片。

  • 既然是非同步下載,就涉及到執行緒的高效排程問題,由於在業務場景中下載圖片的任務可能是繁重的,所以執行緒處理的效能至關重要。
  • 圖片下載成功過後,為了避免顯示圖片時在主執行緒解壓,框架做了非同步解壓,對於gif、APNG、WebP等都有支援,這部分功能是基於作者的另一個框架 YYImage,筆者之前寫過原始碼分析:YYImage 原始碼剖析:圖片處理技巧
  • 為了不重複下載和重複解壓,框架做了快取優化,至於是否快取解壓過後的圖片,可以由開發者選擇,當然,快取分記憶體快取和磁碟快取,讀寫速度一般也是記憶體大於磁碟,這部分功能是基於作者的另一個框架 YYCache,筆者之前也寫過原始碼分析:YYCache 原始碼剖析:一覽亮點

二、重複下載請求處理

該處理主要是基於_YYWebImageSetter.h下的一個屬性:

@property (nonatomic, readonly) int32_t sentinel;
複製程式碼

UIImageView+YYWebImage.h的一個方法看起:

- (void)yy_setImageWithURL:(NSURL *)imageURL
               placeholder:(UIImage *)placeholder
                   options:(YYWebImageOptions)options
                   manager:(YYWebImageManager *)manager
                  progress:(YYWebImageProgressBlock)progress
                 transform:(YYWebImageTransformBlock)transform
                completion:(YYWebImageCompletionBlock)completion {
    ...
    //第一步:為 UIImageView 繫結一個 _YYWebImageSetter 物件
    _YYWebImageSetter *setter = objc_getAssociatedObject(self, &_YYWebImageSetterKey);
    if (!setter) {
        setter = [_YYWebImageSetter new];
        objc_setAssociatedObject(self, &_YYWebImageSetterKey, setter, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    int32_t sentinel = [setter cancelWithNewURL:imageURL];
    
    _yy_dispatch_sync_on_main_queue(^{
        ...
        __weak typeof(self) _self = self;
        dispatch_async([_YYWebImageSetter setterQueue], ^{
            ...
    //第二步:開始下載任務
            newSentinel = [setter setOperationWithSentinel:sentinel url:imageURL options:options manager:manager progress:_progress transform:transform completion:_completion];
            weakSetter = setter;
        });
    });
}
複製程式碼

筆者省略了大部分程式碼,不用在意這些執行緒操作,現在只關注重複請求的處理。

第一步 中,利用 runtime 為UIImageView繫結一個_YYWebImageSetter物件,然後呼叫了一個方法cancelWithNewURL:,該方法實現如下:

- (int32_t)cancelWithNewURL:(NSURL *)imageURL {
    int32_t sentinel;
    dispatch_semaphore_wait(_lock, DISPATCH_TIME_FOREVER);
    if (_operation) {
        [_operation cancel];
        _operation = nil;
    }
    _imageURL = imageURL;
    sentinel = OSAtomicIncrement32(&_sentinel);
    dispatch_semaphore_signal(_lock);
    return sentinel;
}
複製程式碼

可以看到作者取消了_operation任務,對於同一個UIImageView的重複請求時,取消_operation任務也就是取消上一次請求的任務。

然後有一句至關重要的程式碼:sentinel = OSAtomicIncrement32(&_sentinel);,使用原子自增保證全域性變數_sentinel的執行緒安全和讀取效能。也就是說,對於同一個UIImageView每次呼叫yy_setImageWithURL: ...方法都會取消上次的請求並且將其_sentinel加一。

這麼做的意義,往下面看。

第二步 中,呼叫了_YYWebImageSettersetOperationWithSentinel: ...方法:

- (int32_t)setOperationWithSentinel:(int32_t)sentinel
                                url:(NSURL *)imageURL
                            options:(YYWebImageOptions)options
                            manager:(YYWebImageManager *)manager
                           progress:(YYWebImageProgressBlock)progress
                          transform:(YYWebImageTransformBlock)transform
                         completion:(YYWebImageCompletionBlock)completion {
//1、判斷當前請求是否是最新請求
    if (sentinel != _sentinel) {
        if (completion) completion(nil, imageURL, YYWebImageFromNone, YYWebImageStageCancelled, nil);
        return _sentinel;
    }
    
    NSOperation *operation = ... //省略實際網路請求邏輯
    
//2、判斷當前請求是否是最新請求
    dispatch_semaphore_wait(_lock, DISPATCH_TIME_FOREVER);
    if (sentinel == _sentinel) {
        if (_operation) [_operation cancel];
        _operation = operation;
        sentinel = OSAtomicIncrement32(&_sentinel);
    } else {
        [operation cancel];
    }
    dispatch_semaphore_signal(_lock);
    return sentinel;
}
複製程式碼

可以看到兩個地方都有 判斷當前請求是否是最新請求 的邏輯。對於第 1 個地方,因為在該方法入棧的時候,可能該UIImageView的下一次yy_setImageWithURL: ...又一次入棧,也就是說_sentinel可能已經加一了,那麼這裡就沒有必要繼續下面的網路請求邏輯了(程式碼已省略);對於第 2 個地方,也是同樣的考慮,若此刻_sentinel已經加一了,就取消掉當前已經建立好的NSOperation,若此刻_sentinel沒變,就取消掉上一次的_operation,然後_sentinel自增。

值得注意的是,這裡的訊號量使用是為了保證_operation讀寫安全,而不是為了保護_sentinel(因為原子自增本身就是執行緒安全的)。

大致重複請求的處理就是如此,若看得有些費解建議多看幾遍原始碼裡面完整的程式碼。

三、執行緒的處理

1、下載任務的預處理

同樣是在UIImageView+YYWebImage.h下的入口方法:

- (void)yy_setImageWithURL:(NSURL *)imageURL
               placeholder:(UIImage *)placeholder
                   options:(YYWebImageOptions)options
                   manager:(YYWebImageManager *)manager
                  progress:(YYWebImageProgressBlock)progress
                 transform:(YYWebImageTransformBlock)transform
                completion:(YYWebImageCompletionBlock)completion {
    ...
    
    _yy_dispatch_sync_on_main_queue(^{
        ...
//第一步:在主執行緒讀取記憶體快取
        // get the image from memory as quickly as possible
        UIImage *imageFromMemory = nil;
        if (manager.cache &&
            !(options & YYWebImageOptionUseNSURLCache) &&
            !(options & YYWebImageOptionRefreshImageCache)) {
            imageFromMemory = [manager.cache getImageForKey:[manager cacheKeyForURL:imageURL] withType:YYImageCacheTypeMemory];
        }
        if (imageFromMemory) {
            if (!(options & YYWebImageOptionAvoidSetImage)) {
                self.image = imageFromMemory;
            }
            if(completion) completion(imageFromMemory, imageURL, YYWebImageFromMemoryCacheFast, YYWebImageStageFinished, nil);
            return;
        }
        ...
        __weak typeof(self) _self = self;
//第二步:在非同步執行緒做下載任務的預處理
        dispatch_async([_YYWebImageSetter setterQueue], ^{
            ...
            newSentinel = [setter setOperationWithSentinel:sentinel url:imageURL options:options manager:manager progress:_progress transform:transform completion:_completion];
            weakSetter = setter;
        });
    });
}
複製程式碼

第一步

可以看到作者的一句英文註釋,也就是儘可能快的從記憶體讀取快取 (如果有),這裡是一個很有意思的優化點。瞭解 YYCache 框架的讀者應該知道,作者是使用 雙向連結串列+hash 的方式實現的記憶體快取,直接查詢的開銷比切換後臺執行緒查詢而後返回主執行緒的開銷要小。

第二步

下載任務的預處理是在一個[_YYWebImageSetter setterQueue]佇列,程式碼如下:

+ (dispatch_queue_t)setterQueue {
    static dispatch_queue_t queue;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        queue = dispatch_queue_create("com.ibireme.webimage.setter", DISPATCH_QUEUE_SERIAL);
        dispatch_set_target_queue(queue, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));
    });
    return queue;
}
複製程式碼

可以看到這是一個序列的佇列,優先順序為DISPATCH_QUEUE_PRIORITY_DEFAULT,小於主佇列。

可能有朋友會疑問,下載任務在非同步佇列?那豈不是同一時刻只有一個下載任務執行?

哈哈,注意看清筆者的描述:下載任務的預處理。這裡麵包含了任務的建立、重複請求處理等邏輯,並沒有耗時過多的操作,使用一個非同步的執行緒來處理也是為了減輕主執行緒的壓力。下載任務的執行緒處理後面會講到,並不是此處的序列佇列。

2、下載任務的處理

該框架使用了NSURLConnection處理下載任務,姑且不談它的用法,畢竟已經淘汰了。它的代理執行緒是如此建立的:

/// Network thread entry point.
+ (void)_networkThreadMain:(id)object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"com.ibireme.webimage.request"];
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}
/// Global image request network thread, used by NSURLConnection delegate.
+ (NSThread *)_networkThread {
    static NSThread *thread = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        thread = [[NSThread alloc] initWithTarget:self selector:@selector(_networkThreadMain:) object:nil];
        if ([thread respondsToSelector:@selector(setQualityOfService:)]) {
            thread.qualityOfService = NSQualityOfServiceBackground;
        }
        [thread start];
    });
    return thread;
}
複製程式碼

這段程式碼在老版本的 AFNetwork 和 SDWebImage 裡面都出現過,建立一個常駐執行緒來處理下載任務的回撥,通過新增一個 NSMachPort 埠保證該執行緒的 runloop 的正常執行不退出,由於手動建立的執行緒不包含自動釋放池,所以作者加了一個。

這裡的亮點其實是這麼一句方法:thread.qualityOfService = NSQualityOfServiceBackground;

作者很細心的將執行緒的優先順序設定為NSQualityOfServiceBackground,這是一個比較低的優先順序,作者希望圖片的下載回撥相關處理不會和其他執行緒競爭 CPU 的資源(比如操作 UI 的主執行緒等)。

3、圖片讀取和解壓處理

圖片從磁碟中讀取、寫入、解壓等操作都是在下面這個佇列處理的(圖片處理具體原理可看YYImage 原始碼剖析:圖片處理技巧

+ (dispatch_queue_t)_imageQueue {
    #define MAX_QUEUE_COUNT 16
    static int queueCount;
    static dispatch_queue_t queues[MAX_QUEUE_COUNT];
    static dispatch_once_t onceToken;
    static int32_t counter = 0;
    dispatch_once(&onceToken, ^{
        queueCount = (int)[NSProcessInfo processInfo].activeProcessorCount;
        queueCount = queueCount < 1 ? 1 : queueCount > MAX_QUEUE_COUNT ? MAX_QUEUE_COUNT : queueCount;
        if ([UIDevice currentDevice].systemVersion.floatValue >= 8.0) {
            for (NSUInteger i = 0; i < queueCount; i++) {
                dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_UTILITY, 0);
                queues[i] = dispatch_queue_create("com.ibireme.image.decode", attr);
            }
        } else {
            for (NSUInteger i = 0; i < queueCount; i++) {
                queues[i] = dispatch_queue_create("com.ibireme.image.decode", DISPATCH_QUEUE_SERIAL);
                dispatch_set_target_queue(queues[i], dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0));
            }
        }
    });
    int32_t cur = OSAtomicIncrement32(&counter);
    if (cur < 0) cur = -cur;
    return queues[(cur) % queueCount];
    #undef MAX_QUEUE_COUNT
}
複製程式碼

建立與處理器相同的序列佇列模擬併發控制,具體的原理分析可以看筆者的一篇文章:YYAsyncLayer 原始碼剖析:非同步繪製 中對執行緒的討論,這種併發執行緒的處理是作者的一個常規思路,不多說。

四、快取策略

在該框架中的體現,上層的業務邏輯是這樣的:

  1. 優先查詢記憶體快取,若找到則返回
  2. 若記憶體快取未找到,會非同步從磁碟查詢快取,若找到則返回,並且寫入記憶體快取方便下次查詢
  3. 若磁碟快取仍然未找到,發起網路請求
  4. 網路請求成功,同時寫入磁碟快取和記憶體快取

實際上這個邏輯和 SDWebImage 基本一致。值得注意的是,是否查詢記憶體或磁碟快取、是否需要快取、快取的大小限制等都有自定義的方法。

上層的核心邏輯就是如此,關於記憶體快取和磁碟快取的底層實現,可以檢視YYCache 原始碼剖析:一覽亮點

五、載入指示器的處理

載入指示器是在YYWebImageManager.m中處理的,其他程式碼就不貼出來了

@interface _YYWebImageApplicationNetworkIndicatorInfo : NSObject
@property (nonatomic, assign) NSInteger count;
@property (nonatomic, strong) NSTimer *timer;
@end

+ (_YYWebImageApplicationNetworkIndicatorInfo *)_networkIndicatorInfo {
    return objc_getAssociatedObject(self, @selector(_networkIndicatorInfo));
}
+ (void)_setNetworkIndicatorInfo:(_YYWebImageApplicationNetworkIndicatorInfo *)info {
    objc_setAssociatedObject(self, @selector(_networkIndicatorInfo), info, OBJC_ASSOCIATION_RETAIN);
}
...
複製程式碼

繫結到YYWebImageManager的一個類變數_YYWebImageApplicationNetworkIndicatorInfo,也就是說變數的timercount都是全域性的。

。處理指示器本質是容易的,但是作者的思路挺有意思。

一是作者通過一個NSTimer來延時 1/30 秒開啟或者關閉載入指示器。

二是作者通過“計數”來控制指示器是否顯示,也就是上面的count,當有網路任務開始的時候計數加一,當有網路任務結束或者異常取消時計數減一,那麼,只要count大於零就顯示指示器,否則就隱藏。

這思路確實挺巧妙。

六、框架的效能瓶頸

YYWebImageOperation.m下的-connectionDidFinishLoading:代理方法中可以看到圖片的解壓邏輯,它是在_imageQueue中執行的,解壓完成就快取起來方便顯示。

雖然解壓的過程是在非同步執行緒,通常情況下不會影響到主執行緒,但是當解壓的圖片過多或者圖片解析度過大時,解壓和快取會佔用大量的記憶體,導致記憶體峰值飆升。

所以,需要開發者做一些效能上的優化,不過可喜的是可以通過YYWebImageOptionsYYWebImageOptionIgnoreImageDecoding值禁止下載成功後的解壓和快取邏輯,以此降低記憶體峰值。

七、框架中的一些小 tips

1、自動釋放池

可以看到框架中使用了大量的自動釋放池來避免記憶體峰值,可能有開發者感覺如此頻繁的使用自動釋放池是否會造成效能問題,實際上影響不大。瞭解自動釋放池的底層原理的朋友都知道,新增一個自動釋放池不過是新增一個標識(哨兵),需要管理物件加入自動釋放池可以看做是入棧操作,當棧頂的這個自動釋放池結束,會自動給池內物件傳送release訊息(這裡池內就是棧頂到“哨兵”的範圍)。

2、鎖的使用

YYWebImageOperation.m中使用了遞迴鎖NSRecursiveLock避免多次獲取鎖而導致死鎖,當然,筆者認為這裡使用pthread_mutex_t互斥鎖的遞迴實現處理效能應該更好。

在操作少量的、耗時少的程式碼時,使用dispatch_semaphore_t訊號量保證執行緒安全,有效能優勢。

在對int32_t型別變數進行安全保護時,使用OSAtomicIncrement32()原子方法無疑是很好的選擇。

3、避免迴圈引用

框架中通過一箇中間類的訊息轉發來達到避免迴圈引用的目的:

@interface _YYWebImageWeakProxy : NSProxy
@property (nonatomic, weak, readonly) id target;
- (instancetype)initWithTarget:(id)target;
+ (instancetype)proxyWithTarget:(id)target;
@end
@implementation _YYWebImageWeakProxy
- (instancetype)initWithTarget:(id)target {
    _target = target;
    return self;
}
+ (instancetype)proxyWithTarget:(id)target {
    return [[_YYWebImageWeakProxy alloc] initWithTarget:target];
}
- (id)forwardingTargetForSelector:(SEL)selector {
    return _target;
}
- (void)forwardInvocation:(NSInvocation *)invocation {
    void *null = NULL;
    [invocation setReturnValue:&null];
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector {
    return [NSObject instanceMethodSignatureForSelector:@selector(init)];
}
複製程式碼

關於具體的分析可以看筆者的文章YYImage 原始碼剖析:圖片處理技巧有相應的解析。

結語

不得不說,框架都是有套路的。在閱讀 YYKit 系列的程式碼中,也懂了作者的套路,所以筆者在閱讀 YYWebImage 原始碼時非常快,幾乎沒有卡殼,可能這就是“厚積薄發”的小小體現吧。

考慮到篇幅和碼字太累,筆者的分析文章都是剝繭抽絲的,若讀者朋友閱讀有障礙,請沉下心來,多結合原始碼,多思考?。

相關文章