YYAsyncLayer 原始碼剖析:非同步繪製

波兒菜發表於2019-07-30

YYKit 系列原始碼剖析文章:

引言

效能優化一直是 iOS 開發中的一個重頭戲,其中介面流暢度的優化是至關重要的,因為它直接關係到使用者體驗。從最熟悉和簡單的 UIKit 框架到 CoreAnimation、CoreGraphics、CoreText 甚至是 OpenGL,優化似乎是無窮無盡,也非常考驗開發者的水平。

YYAsyncLayer 是 ibireme 寫的一個非同步繪製的輪子,雖然程式碼加起來才 300 行左右,但質量比較高,涉及到很多優化思維,值得學習。

可能很多人學習優秀原始碼陷入了一個誤區,僅僅是閱讀而不理解。

我們應該多思考作者為什麼這樣寫,而不是僅僅看懂程式碼的表面意思。因為看懂 API 很簡單,這不應該是閱讀原始碼最關注的東西,關注的層次不同自然決定了開發者的高度。

原始碼基於 1.0.0 版本。

一、框架概述

YYAsyncLayer 庫程式碼很清晰,就幾個檔案:

YYAsyncLayer.h (.m)
YYSentinel.h (.m)
YYTransaction.h (.m)
複製程式碼
  • YYAsyncLayer 類繼承自 CALayer ,不同的是作者封裝了非同步繪製的邏輯便於使用。
  • YYSentinel 類是一個計數的類,是為了記錄最新的佈局請求標識,便於及時的放棄多餘的繪製邏輯以減少開銷。
  • YYTransaction 類是事務類,捕獲主執行緒 runloop 的某個時機回撥,用於處理非同步繪製事件。

可能有些讀者會迷糊,不過沒關係,後文會詳細剖析程式碼細節,這裡只需要對框架有個大致的認識就可以了。

瀏覽一下原始碼便可以知道,該框架的用法不過是使用一個 CALayer 的子類 —— YYAsyncLayer。(需要實現 YYAsyncLayer 類指定的代理方法,對整個繪製流程做管理,詳細使用方法可以看看框架的 README

二、為什麼需要非同步繪製?

1、介面卡頓的實質

iOS 裝置顯示器每繪製完一幀畫面,復位時就會傳送一個 VSync (垂直同步訊號) ,並且此時切換幀緩衝區 (iOS 裝置是雙快取+垂直同步);在讀取經 GPU 渲染完成的幀緩衝區資料進行繪製的同時,還會通過 CADisplayLink 等機制通知 APP 內部可以提交結果到另一個空閒的幀緩衝區了;接著 CPU 計算 APP 佈局,計算完成交由 GPU 渲染,渲染完成提交到幀緩衝區;當 VSync 再一次到來的時候,切換幀緩衝區...... (ps: 上面這段描述是筆者的理解,參考 iOS 保持介面流暢的技巧

當 VSync 到來準備切換幀緩衝區時,若空閒的幀快取區並未收到來自 GPU 的提交,此次切換就會作罷,裝置顯示系統會放棄此次繪製,從而引起掉幀。

由此可知,不管是 CPU 還是 GPU 哪一個出現問題導致不能及時的提交渲染結果到幀緩衝區,都會導致掉幀。優化介面流暢程度,實際上就是減少掉幀(iOS裝置上大致是 60 FPS),也就是減小 CPU 和 GPU 的壓力提高效能。

2、UIKit 效能瓶頸

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

3、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 當然更加放心。

4、多核裝置帶來的可能性

通過以上說明,可以瞭解 CoreGraphics 較為糟糕的效能。然而可喜的是,市面上的裝置都已經不是單核了,這就意味著可以通過後臺執行緒處理耗時任務,主執行緒只需要負責排程顯示。

ps:關於多核裝置的執行緒效能問題,後面分析原始碼會講到

CoreGraphics 框架可以通過圖片上下文將繪製內容製作為一張點陣圖,並且這個操作可以在非主執行緒執行。那麼,當有 n 個繪製任務時,可以開闢多個執行緒在後臺非同步繪製,繪製成功拿到點陣圖回到主執行緒賦值給 CALayer 的寄宿圖屬性。

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

雖然多個執行緒非同步繪製會消耗大量的記憶體,但是對於效能敏感介面來說,只要工程師控制好記憶體峰值,可以極大的提高互動流暢度。優化很多時候就是空間換時間,所謂魚和熊掌不可兼得。這也說明了一個問題,實際開發中要做有針對性的優化,不可盲目跟風。

三、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 框架下的方法,它往往效能比使用各種“鎖”更為優越,並且程式碼優雅。

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

四、YYTransaction

YYTransaction 貌似和系統的 CATransaction 很像,他們同為“事務”,但實際上很不一樣。通過 CATransaction 的巢狀用法猜測 CATransaction 對任務的管理是使用的一個棧結構,而 YYTransaction 是使用的集合來管理任務。

YYTransaction 做的事情就是記錄一系列事件,並且在合適的時機呼叫這些事件。至於為什麼這麼做,需要先了解 YYTransaction 做了些什麼,最終你會恍然大悟?。

1、提交任務

YYTransaction 有兩個屬性:

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

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

2、合適的回撥時機

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
    }];
}
複製程式碼

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

3、自定義 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 週期多次的繪製請求為一個。

五、YYAsyncLayer

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

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

1、初始化配置

- (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是非同步繪製的核心方法。

2、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,需要實現這個程式碼塊,在程式碼塊裡面寫業務繪製邏輯。

這個代理實際上就是框架和業務互動的橋樑,不過這個設計筆者個人認為有一些冗餘,這裡如果直接通過代理方法與業務互動而不使用中間類可能看起來更舒服。

3、非同步繪製的核心邏輯

刪減了部分程式碼:

- (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 渲染過後提交到顯示系統。

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

針對同一個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];
}
複製程式碼

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

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

5、非同步執行緒的管理

筆者去除了判斷 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 行左右程式碼所涵蓋的東西。實際上學習一份優秀原始碼需要在過程中去了解和學習原始碼之外的其它很多知識,這也是優秀原始碼的價值所在。

沉下心來感受程式碼的藝術。

相關文章