系列文章:
YYText 源碼剖析:CoreText 與異步繪制
YYAsyncLayer 源碼剖析:異步繪制
YYCache 源碼剖析:一覽亮點
YYModel 源碼剖析:關注性能
YYImage 源碼剖析:圖片處理技巧
YYWebImage 源碼剖析:線程處理與緩存策略
引言
性能優化一直是 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 的性能比較差,日常開發中要盡量避免直接在主線程使用。通常情況下,直接給 CALayer
的 contents
賦值 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 監聽,回調的時機是 kCFRunLoopBeforeWaiting
和 kCFRunLoopExit
,即是主線程 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
的內存地址,那就意味著兩點:
- 同一個 YYTransaction 實例,
_selector
和_target
只要有一個內存地址不同,就會在集合中體現為兩個值。 - 不同的 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;
}
這里設置了YYAsyncLayer
的contentsScale
為屏幕的scale
,該屬性是 物理像素 / 邏輯像素,這樣可以充分利用不同設備的顯示器分辨率,繪制更清晰的圖像。但是若contentsGravity
設置了可拉伸的類型,CoreAnimation 將會優先滿足,而忽略掉contentsScale
。
同時還創建了一個YYSentinel
實例。
@2x和@3x圖
實際上 iPhone4 及其以上的 iPhone 設備scale
都是 2 及以上,也就是說至少都是每個邏輯像素長度對應兩個物理像素長度。所以很多美工會只切 @2x 和 @3x 圖給你,而不切一倍圖。
@2x和@3x圖是蘋果一個優化顯示效果的機制,當 iPhone 設備scale
為 2 時會優先讀取 @2x 圖,當scale
為 3 時會優先讀取 @3x 圖,這就意味著,CALayer
的contentsScale
要和設備的scale
對應才能達到預期的效果(不同設備顯示相同的邏輯像素大小)。
幸運的是,UIView
和UIImageView
默認處理了它們內部CALayer
的contentsScale
,所以除非是直接使用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
是繪制任務管理類,可以通過willDisplay
和didDisplay
回調將要繪制和結束繪制時機,最重要的是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);
});
}];
...
}
此時就很清晰了,在異步線程創建一個位圖上下文,調用task
的display
代碼塊進行繪制(業務代碼),然后生成一個位圖,最終進入主隊列給YYAsyncLayer
的contents
賦值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 行左右代碼所涵蓋的東西。實際上學習一份優秀源碼需要在過程中去了解和學習源碼之外的其它很多知識,這也是優秀源碼的價值所在。
沉下心來感受代碼的藝術。