YYAsyncLayer 源碼剖析:異步繪製

YYKit 系列源碼剖析文章:ios

引言

性能優化一直是 iOS 開發中的一個重頭戲,其中界面流暢度的優化是相當重要的,由於它直接關係到用戶體驗。從最熟悉和簡單的 UIKit 框架到 CoreAnimation、CoreGraphics、CoreText 甚至是 OpenGL,優化彷佛是無窮無盡,也很是考驗開發者的水平。git

YYAsyncLayer 是 ibireme 寫的一個異步繪製的輪子,雖然代碼加起來才 300 行左右,但質量比較高,涉及到不少優化思惟,值得學習。github

可能不少人學習優秀源碼陷入了一個誤區,僅僅是閱讀而不理解。算法

咱們應該多思考做者爲何這樣寫,而不是僅僅看懂代碼的表面意思。由於看懂 API 很簡單,這不該該是閱讀源碼最關注的東西,關注的層次不一樣天然決定了開發者的高度。編程

源碼基於 1.0.0 版本。數組

1、框架概述

YYAsyncLayer 庫代碼很清晰,就幾個文件:緩存

YYAsyncLayer.h (.m)
YYSentinel.h (.m)
YYTransaction.h (.m)
複製代碼
  • YYAsyncLayer 類繼承自 CALayer ,不一樣的是做者封裝了異步繪製的邏輯便於使用。
  • YYSentinel 類是一個計數的類,是爲了記錄最新的佈局請求標識,便於及時的放棄多餘的繪製邏輯以減小開銷。
  • YYTransaction 類是事務類,捕獲主線程 runloop 的某個時機回調,用於處理異步繪製事件。

可能有些讀者會迷糊,不過不要緊,後文會詳細剖析代碼細節,這裏只須要對框架有個大體的認識就能夠了。安全

瀏覽一下源碼即可以知道,該框架的用法不過是使用一個 CALayer 的子類 —— YYAsyncLayer。(須要實現 YYAsyncLayer 類指定的代理方法,對整個繪製流程作管理,詳細使用方法能夠看看框架的 README性能優化

2、爲何須要異步繪製?

一、界面卡頓的實質

iOS 設備顯示器每繪製完一幀畫面,復位時就會發送一個 VSync (垂直同步信號) ,而且此時切換幀緩衝區 (iOS 設備是雙緩存+垂直同步);在讀取經 GPU 渲染完成的幀緩衝區數據進行繪製的同時,還會經過 CADisplayLink 等機制通知 APP 內部能夠提交結果到另外一個空閒的幀緩衝區了;接着 CPU 計算 APP 佈局,計算完成交由 GPU 渲染,渲染完成提交到幀緩衝區;當 VSync 再一次到來的時候,切換幀緩衝區...... (ps: 上面這段描述是筆者的理解,參考 iOS 保持界面流暢的技巧bash

當 VSync 到來準備切換幀緩衝區時,若空閒的幀緩存區並未收到來自 GPU 的提交,這次切換就會做罷,設備顯示系統會放棄這次繪製,從而引發掉幀。

由此可知,無論是 CPU 仍是 GPU 哪個出現問題致使不能及時的提交渲染結果到幀緩衝區,都會致使掉幀。優化界面流暢程度,實際上就是減小掉幀(iOS設備上大體是 60 FPS),也就是減少 CPU 和 GPU 的壓力提升性能。

二、UIKit 性能瓶頸

大部分 UIKit 組件的繪製是在主線程進行,須要 CPU 來進行繪製,當同一時刻過多組件須要繪製或者組件元素過於複雜時,必然會給 CPU 帶來壓力,這個時候就很容易掉幀(主要是文本控件,大量文本內容的計算和繪製過程都至關繁瑣)。

三、UIKit 替代方案:CoreAnimation 或 CoreGraphics

固然,首選優化方案是 CoreAnimation 框架。CALayer 的大部分屬性都是由 GPU 繪製的 (硬件層面),不須要 CPU (軟件層面) 作任何繪製。CA 框架下的 CAShapeLayer (多邊形繪製)、CATextLayer(文本繪製)、CAGradientLayer (漸變繪製) 等都有較高的效率,很是實用。

再來看一下 CoreGraphics 框架,實際上它是依託於 CPU 的軟件繪製。在實現CALayerDelegate 協議的 -drawLayer:inContext: 方法時(等同於UIView 二次封裝的 -drawRect:方法),須要分配一個內存佔用較高的上下文context,與此同時,CALayer 或者其子類須要建立一個等大的寄宿圖contents。當基於 CPU 的軟件繪製完成,還須要經過 IPC (進程間通訊) 傳遞給設備顯示系統。值得注意的是:當重繪時須要抹除這個上下文從新分配內存。

無論是建立上下文、重繪帶來的內存從新分配、IPC 都會帶來性能上的較大開銷。因此 CoreGraphics 的性能比較差,平常開發中要儘可能避免直接在主線程使用。一般狀況下,直接給 CALayercontents 賦值 CGImage 圖片或者使用 CALayer 的衍生類就能實現大部分需求,還能充分利用硬件支持,圖像處理交給 GPU 固然更加放心。

四、多核設備帶來的可能性

經過以上說明,能夠了解 CoreGraphics 較爲糟糕的性能。然而可喜的是,市面上的設備都已經不是單核了,這就意味着能夠經過後臺線程處理耗時任務,主線程只須要負責調度顯示。

ps:關於多核設備的線程性能問題,後面分析源碼會講到

CoreGraphics 框架能夠經過圖片上下文將繪製內容製做爲一張位圖,而且這個操做能夠在非主線程執行。那麼,當有 n 個繪製任務時,能夠開闢多個線程在後臺異步繪製,繪製成功拿到位圖回到主線程賦值給 CALayer 的寄宿圖屬性。

這就是 YYAsyncLayer 框架的核心思想,該框架還有其餘的亮點後文慢慢闡述。

雖然多個線程異步繪製會消耗大量的內存,可是對於性能敏感界面來講,只要工程師控制好內存峯值,能夠極大的提升交互流暢度。優化不少時候就是空間換時間,所謂魚和熊掌不可兼得。這也說明了一個問題,實際開發中要作有針對性的優化,不可盲目跟風。

3、YYSentinel

該類很是簡單:

.h
@interface YYSentinel : NSObject
@property (readonly) int32_t value;
- (int32_t)increase;
@end

.m
@implementation YYSentinel { int32_t _value; }
- (int32_t)value { return _value; }
- (int32_t)increase { return OSAtomicIncrement32(&_value); }
@end
複製代碼

一看便知,該類扮演的是計數的角色,值得注意的是,-increase方法是使用 OSAtomicIncrement32() 方法來對value執行自增。

OSAtomicIncrement32()是原子自增方法,線程安全。在平常開發中,若須要保證整形數值變量的線程安全,可使用 OSAtomic 框架下的方法,它每每性能比使用各類「鎖」更爲優越,而且代碼優雅。

至於該類的實際做用後文會解釋。

4、YYTransaction

YYTransaction 貌似和系統的 CATransaction 很像,他們同爲「事務」,但實際上很不同。經過 CATransaction 的嵌套用法猜想 CATransaction 對任務的管理是使用的一個棧結構,而 YYTransaction 是使用的集合來管理任務。

YYTransaction 作的事情就是記錄一系列事件,而且在合適的時機調用這些事件。至於爲何這麼作,須要先了解 YYTransaction 作了些什麼,最終你會恍然大悟😁。

一、提交任務

YYTransaction 有兩個屬性:

@interface YYTransaction()
@property (nonatomic, strong) id target;
@property (nonatomic, assign) SEL selector;
@end
static NSMutableSet *transactionSet = nil;
複製代碼

很簡單,方法接收者 (target) 和方法 (selector),實際上一個 YYTransaction 就是一個任務,而全局區的 transactionSet 集合就是用來存儲這些任務。提交方法-commit 不過是初始配置而且將任務裝入集合。

二、合適的回調時機

static void YYTransactionSetup() {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        transactionSet = [NSMutableSet new];
        CFRunLoopRef runloop = CFRunLoopGetMain();
        CFRunLoopObserverRef observer;
        observer = CFRunLoopObserverCreate(CFAllocatorGetDefault(),
                                           kCFRunLoopBeforeWaiting | kCFRunLoopExit,
                                           true,      // repeat
                                           0xFFFFFF,  // after CATransaction(2000000)
                                           YYRunLoopObserverCallBack, NULL);
        CFRunLoopAddObserver(runloop, observer, kCFRunLoopCommonModes);
        CFRelease(observer);
    });
}
複製代碼

這裏在主線程的 RunLoop 中添加了一個 oberver 監聽,回調的時機是 kCFRunLoopBeforeWaitingkCFRunLoopExit ,便是主線程 RunLoop 循環即將進入休眠或者即將退出的時候。而該 oberver 的優先級是 0xFFFFFF,優先級在 CATransaction 的後面(至於 CATransaction 的優先級爲何是 2000000,應該在主線程 RunLoop 啓動的源代碼中能夠查到,筆者並無找到暴露出來的信息)。

從這裏能夠看出,做者使用一個「低姿態」侵入主線程 RunLoop,在處理完重要邏輯(即 CATransaction 管理的繪製任務)以後作異步繪製的事情,這也是做者對優先級的權衡考慮。

下面看看回調裏面作了些什麼:

static void YYRunLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    if (transactionSet.count == 0) return;
    NSSet *currentSet = transactionSet;
    transactionSet = [NSMutableSet new];
    [currentSet enumerateObjectsUsingBlock:^(YYTransaction *transaction, BOOL *stop) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [transaction.target performSelector:transaction.selector];
#pragma clang diagnostic pop
    }];
}
複製代碼

一目瞭然,只是將集合中的任務分別執行。

三、自定義 hash 算法

YYTransaction 類重寫了 hash 算法:

- (NSUInteger)hash {
    long v1 = (long)((void *)_selector);
    long v2 = (long)_target;
    return v1 ^ v2;
}
複製代碼

NSObject 類默認的 hash 值爲 10 進制的內存地址,這裏做者將_selector_target的內存地址進行一個位異或處理,意味着只要_selector_target地址都相同時,hash 值就相同。

這麼作的意義是什麼呢?

上面有提到一個集合:

static NSMutableSet *transactionSet = nil;
複製代碼

和其餘編程語言同樣 NSSet 是基於 hash 的集合,它是不能有重複元素的,而判斷是否重複毫無疑問是使用 hash。這裏將 YYTransaction 的 hash 值依託於_selector_target的內存地址,那就意味着兩點:

  1. 同一個 YYTransaction 實例,_selector_target只要有一個內存地址不一樣,就會在集合中體現爲兩個值。
  2. 不一樣的 YYTransaction 實例,_selector_target的內存地址都相同,在集合中的體現爲一個值。

熟悉 hash 的讀者應該一點即通,那麼這麼作對於業務的目的是什麼呢?

很簡單,這樣能夠避免重複的方法調用。加入transactionSet中的事件會在 Runloop 即將進入休眠或者即將退出時遍歷執行,相同的方法接收者 (_target) 和相同的方法 (_selector) 在一個 Runloop 週期內能夠視爲重複調用。

舉個例子:

在 YYText 的YYTextView中,主要是爲了將自定義的繪製邏輯裝入transactionSet,而後在 Runloop 要結束時統一執行,Runloop 回調的優先級避免與系統繪製邏輯競爭資源,使用NSSet合併了一次 Runloop 週期屢次的繪製請求爲一個。

5、YYAsyncLayer

@interface YYAsyncLayer : CALayer
@property BOOL displaysAsynchronously;
@end
複製代碼

YYAsyncLayer 繼承自 CALayer,對外暴露了一個方法可開閉是否異步繪製。

一、初始化配置

- (instancetype)init {
    self = [super init];
    static CGFloat scale; //global
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        scale = [UIScreen mainScreen].scale;
    });
    self.contentsScale = scale;
    _sentinel = [YYSentinel new];
    _displaysAsynchronously = YES;
    return self;
}
複製代碼

這裏設置了YYAsyncLayercontentsScale爲屏幕的scale,該屬性是 物理像素 / 邏輯像素,這樣能夠充分利用不一樣設備的顯示器分辨率,繪製更清晰的圖像。可是若contentsGravity設置了可拉伸的類型,CoreAnimation 將會優先知足,而忽略掉contentsScale

同時還建立了一個YYSentinel實例。

@2x和@3x圖

實際上 iPhone4 及其以上的 iPhone 設備scale都是 2 及以上,也就是說至少都是每一個邏輯像素長度對應兩個物理像素長度。因此不少美工會只切 @2x 和 @3x 圖給你,而不切一倍圖。

@2x和@3x圖是蘋果一個優化顯示效果的機制,當 iPhone 設備scale爲 2 時會優先讀取 @2x 圖,當scale爲 3 時會優先讀取 @3x 圖,這就意味着,CALayercontentsScale要和設備的scale對應才能達到預期的效果(不一樣設備顯示相同的邏輯像素大小)。

幸運的是,UIViewUIImageView默認處理了它們內部CALayercontentsScale,因此除非是直接使用CALayer及其衍生類,都不用顯式的配置contentsScale

重寫繪製方法

- (void)setNeedsDisplay {
    [self _cancelAsyncDisplay];
    [super setNeedsDisplay];
}
- (void)display {
    super.contents = super.contents;
    [self _displayAsync:_displaysAsynchronously];
}
複製代碼

能夠看到兩個方法,-_cancelAsyncDisplay是取消繪製,稍後解析實現邏輯;-_displayAsync是異步繪製的核心方法。

二、YYAsyncLayerDelegate 代理

@protocol YYAsyncLayerDelegate <NSObject>
@required
- (YYAsyncLayerDisplayTask *)newAsyncDisplayTask;
@end
複製代碼
@interface YYAsyncLayerDisplayTask : NSObject
@property (nullable, nonatomic, copy) void (^willDisplay)(CALayer *layer);
@property (nullable, nonatomic, copy) void (^display)(CGContextRef context, CGSize size, BOOL(^isCancelled)(void));
@property (nullable, nonatomic, copy) void (^didDisplay)(CALayer *layer, BOOL finished);
@end
複製代碼

YYAsyncLayerDisplayTask是繪製任務管理類,能夠經過willDisplaydidDisplay回調將要繪製和結束繪製時機,最重要的是display,須要實現這個代碼塊,在代碼塊裏面寫業務繪製邏輯。

這個代理實際上就是框架和業務交互的橋樑,不過這個設計筆者我的認爲有一些冗餘,這裏若是直接經過代理方法與業務交互而不使用中間類可能看起來更舒服。

三、異步繪製的核心邏輯

刪減了部分代碼:

- (void)_displayAsync:(BOOL)async {
    __strong id<YYAsyncLayerDelegate> delegate = self.delegate;
    YYAsyncLayerDisplayTask *task = [delegate newAsyncDisplayTask];
    ...
        dispatch_async(YYAsyncLayerGetDisplayQueue(), ^{
            if (isCancelled()) return;
            UIGraphicsBeginImageContextWithOptions(size, opaque, scale);
            CGContextRef context = UIGraphicsGetCurrentContext();
            task.display(context, size, isCancelled);
            if (isCancelled()) {
                UIGraphicsEndImageContext();
                dispatch_async(dispatch_get_main_queue(), ^{
                    if (task.didDisplay) task.didDisplay(self, NO);
                });
                return;
            }
            UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
            UIGraphicsEndImageContext();
            if (isCancelled()) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    if (task.didDisplay) task.didDisplay(self, NO);
                });
                return;
            }
            dispatch_async(dispatch_get_main_queue(), ^{
                if (isCancelled()) {
                    if (task.didDisplay) task.didDisplay(self, NO);
                } else {
                    self.contents = (__bridge id)(image.CGImage);
                    if (task.didDisplay) task.didDisplay(self, YES);
                }
            });
        });
    ...
}
複製代碼

先不用管 YYAsyncLayerGetDisplayQueue()方法如何獲取的異步隊列,也先不用管isCancelled()判斷作的一些提早結束繪製的邏輯,這些後面會講。

那麼,實際上核心代碼能夠更少:

- (void)_displayAsync:(BOOL)async {
    ...
    dispatch_async(YYAsyncLayerGetDisplayQueue(), ^{
        UIGraphicsBeginImageContextWithOptions(size, opaque, scale);
        CGContextRef context = UIGraphicsGetCurrentContext();
        task.display(context, size, isCancelled);
        UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
        UIGraphicsEndImageContext();
        dispatch_async(dispatch_get_main_queue(), ^{
            self.contents = (__bridge id)(image.CGImage);
        });
    }];
    ...
}
複製代碼

此時就很清晰了,在異步線程建立一個位圖上下文,調用taskdisplay代碼塊進行繪製(業務代碼),而後生成一個位圖,最終進入主隊列給YYAsyncLayercontents賦值CGImage由 GPU 渲染事後提交到顯示系統。

四、及時的結束無用的繪製

針對同一個YYAsyncLayer,頗有可能新的繪製請求到來時,當前的繪製任務還未完成,而當前的繪製任務是無用的,會繼續消耗過多的 CPU (GPU) 資源。固然,這種場景主要是出如今列表界面快速滾動時,因爲視圖的複用機制,致使從新繪製的請求很是頻繁。

爲了解決這個問題,做者使用了大量的判斷來及時的結束無用的繪製,能夠看看源碼或者是上文貼出的異步繪製核心邏輯代碼,會發現一個頻繁的操做:

if (isCancelled()) {...}
複製代碼

看看這個代碼塊的實現:

YYSentinel *sentinel = _sentinel;
int32_t value = sentinel.value;
BOOL (^isCancelled)(void) = ^BOOL() {
  return value != sentinel.value;
};
複製代碼

這就是YYSentinel計數類起做用的時候了,這裏用一個局部變量value來保持當前繪製邏輯的計數值,保證其餘線程改變了全局變量_sentinel的值也不會影響當前的value;若當前value不等於最新的_sentinel .value時,說明當前繪製任務已經被放棄,就須要及時的作返回邏輯。

那麼,什麼時候改變這個計數?

- (void)setNeedsDisplay {
    [self _cancelAsyncDisplay];
    [super setNeedsDisplay];
}
- (void)_cancelAsyncDisplay {
    [_sentinel increase];
}
複製代碼

很明顯,在提交重繪請求時,計數器加一。

😁不得不說,這確實是一個使人興奮的優化技巧。

五、異步線程的管理

筆者去除了判斷 YYDispatchQueuePool 庫是否存在的代碼,實際上那就是做者提取的隊列管理封裝,思想和如下代碼同樣。

static dispatch_queue_t YYAsyncLayerGetDisplayQueue() {
//最大隊列數量
#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, ^{
//要點 1 :串行隊列數量和處理器數量相同
        queueCount = (int)[NSProcessInfo processInfo].activeProcessorCount;
        queueCount = queueCount < 1 ? 1 : queueCount > MAX_QUEUE_COUNT ? MAX_QUEUE_COUNT : queueCount;
//要點 2 :建立串行隊列,設置優先級
        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_USER_INITIATED, 0);
                queues[i] = dispatch_queue_create("com.ibireme.yykit.render", attr);
            }
        } else {
            for (NSUInteger i = 0; i < queueCount; i++) {
                queues[i] = dispatch_queue_create("com.ibireme.yykit.render", DISPATCH_QUEUE_SERIAL);
                dispatch_set_target_queue(queues[i], dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));
            }
        }
    });
//要點 3 :輪詢返回隊列
    int32_t cur = OSAtomicIncrement32(&counter);
    if (cur < 0) cur = -cur;
    return queues[(cur) % queueCount];
#undef MAX_QUEUE_COUNT
}
複製代碼
要點 1 :串行隊列數量和處理器數量相同

首先要明白,併發並行 的區別: 並行必定併發,併發不必定並行。在單核設備上,CPU經過頻繁的切換上下文來運行不一樣的線程,速度足夠快以致於咱們看起來它是‘並行’處理的,然而咱們只能說這種狀況是併發而非並行。例如:你和兩我的一塊兒百米賽跑,你一直在不停的切換跑道,而其餘兩人就在本身的跑道上,最終,大家三人同時到達了終點。咱們把跑道看作任務,那麼,其餘兩人就是並行執行任務的,而你只能的說是併發執行任務。

因此,實際上一個 n 核設備同一時刻最多能 並行 執行 n 個任務,也就是最多有 n 個線程是相互不競爭 CPU 資源的。

當你開闢的線程過多,超過了處理器核心數量,實際上某些並行的線程之間就可能競爭同一個處理器的資源,頻繁的切換上下文也會消耗處理器資源。

因此,筆者認爲:超過處理器核心數量的線程沒有處理速度上的優點,只是在業務上便於管理,而且能最大化的利用處理器資源。

而串行隊列中只有一個線程,該框架中,做者使用和處理器核心相同數量的串行隊列來輪詢處理異步任務,有效的減小了線程調度操做。

要點 2 :建立串行隊列,設置優先級

在 8.0 以上的系統,隊列的優先級爲 QOS_CLASS_USER_INITIATED,低於用戶交互相關的QOS_CLASS_USER_INTERACTIVE

在 8.0 如下的系統,經過dispatch_set_target_queue()函數設置優先級爲DISPATCH_QUEUE_PRIORITY_DEFAULT(第二個參數若是使用串行隊列會強行將咱們建立的全部線程串行執行任務)。

能夠猜想主隊列的優先級是大於或等於QOS_CLASS_USER_INTERACTIVE的,讓這些串行隊列的優先級低於主隊列,避免框架建立的線程和主線程競爭資源。

關於兩種類型優先級的對應關係是這樣的:

*  - DISPATCH_QUEUE_PRIORITY_HIGH:         QOS_CLASS_USER_INITIATED
 *  - DISPATCH_QUEUE_PRIORITY_DEFAULT:      QOS_CLASS_DEFAULT
 *  - DISPATCH_QUEUE_PRIORITY_LOW:          QOS_CLASS_UTILITY
 *  - DISPATCH_QUEUE_PRIORITY_BACKGROUND:   QOS_CLASS_BACKGROUND
複製代碼
要點 3 :輪詢返回隊列

使用原子自增函數OSAtomicIncrement32()對局部靜態變量counter進行自增,而後經過取模運算輪詢返回隊列。

注意這裏使用了一個判斷:if (cur < 0) cur = -cur;,當cur自增越界時就會變爲負數最大值(在二進制層面,是用正整數的反碼加一來表示其負數的)。

爲何要使用 n 個串行隊列實現併發

可能有人會有疑惑,爲何這裏須要使用 n 個串行隊列來調度,而不用一個並行隊列。

主要是由於並行隊列沒法精確的控制線程數量,頗有可能建立過多的線程,致使 CPU 線程調度過於頻繁,影響交互性能。

可能會想到用信號量 (dispatch_semaphore_t) 來控制併發,然而這樣只能控制併發的任務數量,而不能控制線程數量,而且使用起來不是很優雅。而使用串行隊列就很簡單了,咱們能夠很明確的知道本身建立的線程數量,一切皆在掌控之中。

以上就是 YYKit 對線程處理的核心思想。

結語

不知道讀者朋友有沒有感覺到 YYAsyncLayer 的 300 行左右代碼所涵蓋的東西。實際上學習一份優秀源碼須要在過程當中去了解和學習源碼以外的其它不少知識,這也是優秀源碼的價值所在。

沉下心來感覺代碼的藝術。

相關文章
相關標籤/搜索