YYWebImage 源碼剖析:線程處理與緩存策略

YYKit 系列源碼剖析文章:git

引言

在 iOS 開發中,異步網絡圖片下載框架能夠說是很大的解放了生產力,一般狀況下開發者只須要簡單的代碼就能將網絡圖片異步下載並顯示到手機屏幕上,而且還帶有緩存優化。github

業界名氣最高的異步圖片下載框架是 SDWebImage,然後 ibireme 前輩開源了 YYWebImage,對性能有所優化。以前有粗略的瀏覽過 SDWebImage 的源碼,對比 YYWebImage 源碼事後,實際上筆者更喜歡 YYWebImage,由於其代碼風格很簡潔、代碼結構更清晰。web

技術層面來看,二者對線程處理的處理方式有所不一樣,緩存策略也有細節上的差別,雖然筆者的理解來看 YYWebImage 性能更爲優越,可是並無充分的測試用例來驗證。有些遺憾的是,YYWebImage 彷佛挺久沒有維護了,做者在 這條 issues 說過計劃會將NSURLConnection替換爲NSURLSession,到如今都沒有動做😂。緩存

因此實際開發中爲了穩定性可能仍是會首選 SDWebImage,可是這絲絕不影響咱們學習 YYWebImage 的優秀源碼,本文主要是分析 YYWebImage 的核心思路和亮點。安全

源碼版本:1.0.5bash

1、框架總覽

//包含全部文件的頭文件
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,筆者以前也寫過源碼分析:YYModel 源碼剖析:關注性能

2、重複下載請求處理

該處理主要是基於_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 {
//一、判斷當前請求是不是最新請求
    if (sentinel != _sentinel) {
        if (completion) completion(nil, imageURL, YYWebImageFromNone, YYWebImageStageCancelled, nil);
        return _sentinel;
    }
    
    NSOperation *operation = ... //省略實際網絡請求邏輯
    
//二、判斷當前請求是不是最新請求
    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(由於原子自增自己就是線程安全的)。

大體重複請求的處理就是如此,若看得有些費解建議多看幾遍源碼裏面完整的代碼。

3、線程的處理

一、下載任務的預處理

一樣是在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,小於主隊列。

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

哈哈,注意看清筆者的描述:下載任務的預處理。這裏麪包含了任務的建立、重複請求處理等邏輯,並無耗時過多的操做,使用一個異步的線程來處理也是爲了減輕主線程的壓力。下載任務的線程處理後面會講到,並非此處的串行隊列。

二、下載任務的處理

該框架使用了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 的主線程等)。

三、圖片讀取和解壓處理

圖片從磁盤中讀取、寫入、解壓等操做都是在下面這個隊列處理的(圖片處理具體原理可看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 源碼剖析:異步繪製 中對線程的討論,這種併發線程的處理是做者的一個常規思路,很少說。

4、緩存策略

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

  1. 優先查找內存緩存,若找到則返回
  2. 若內存緩存未找到,會異步從磁盤查找緩存,若找到則返回,而且寫入內存緩存方便下次查找
  3. 若磁盤緩存仍然未找到,發起網絡請求
  4. 網絡請求成功,同時寫入磁盤緩存和內存緩存

實際上這個邏輯和 SDWebImage 基本一致。值得注意的是,是否查找內存或磁盤緩存、是否須要緩存、緩存的大小限制等都有自定義的方法。

上層的核心邏輯就是如此,關於內存緩存和磁盤緩存的底層實現,能夠查看YYModel 源碼剖析:關注性能

5、加載指示器的處理

加載指示器是在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大於零就顯示指示器,不然就隱藏。

這思路確實挺巧妙。

6、框架的性能瓶頸

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

雖然解壓的過程是在異步線程,一般狀況下不會影響到主線程,可是當解壓的圖片過多或者圖片分辨率過大時,解壓和緩存會佔用大量的內存,致使內存峯值飆升。

因此,須要開發者作一些性能上的優化,不過可喜的是能夠經過YYWebImageOptionsYYWebImageOptionIgnoreImageDecoding值禁止下載成功後的解壓和緩存邏輯,以此下降內存峯值。

7、框架中的一些小 tips

一、自動釋放池

能夠看到框架中使用了大量的自動釋放池來避免內存峯值,可能有開發者感受如此頻繁的使用自動釋放池是否會形成性能問題,實際上影響不大。瞭解自動釋放池的底層原理的朋友都知道,添加一個自動釋放池不過是添加一個標識(哨兵),須要管理對象加入自動釋放池能夠看作是入棧操做,當棧頂的這個自動釋放池結束,會自動給池內對象發送release消息(這裏池內就是棧頂到「哨兵」的範圍)。

二、鎖的使用

YYWebImageOperation.m中使用了遞歸鎖NSRecursiveLock避免屢次獲取鎖而致使死鎖,固然,筆者認爲這裏使用pthread_mutex_t互斥鎖的遞歸實現處理性能應該更好。

在操做少許的、耗時少的代碼時,使用dispatch_semaphore_t信號量保證線程安全,有性能優點。

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

三、避免循環引用

框架中經過一箇中間類的消息轉發來達到避免循環引用的目的:

@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 源碼時很是快,幾乎沒有卡殼,可能這就是「厚積薄發」的小小體現吧。

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

相關文章
相關標籤/搜索