原文位址:http://draveness.me/layout-performance/?hmsr=toutiao.io&utm_medium=toutiao.io&utm_source=toutiao.io
這是使用 ASDK 性能調優系列的第二篇文章,前一篇文章中講到了如何提升 iOS 應用的渲染性能,你可以點選 這裡 了解這部分的内容。
在上一篇文章中,我們提到了 iOS 界面的渲染過程以及如何對渲染過程進行優化。ASDK 的做法是将渲染繪制的工作抛到背景線程進行,并在每次 Runloop 結束時,将繪制結果交給
CALayer
進行展示。
而這篇文章就要從 iOS 中影響性能的另一大殺手,也就是萬惡之源 Auto Layout(自動布局)來分析如何對 iOS 應用的性能進行優化以及 Auto Layout 到底為什麼會影響性能?
把 Auto Layout 批判一番
由于在 2012 年蘋果釋出了 4.0 寸的 iPhone5,在 iOS 平台上出現了不同尺寸的移動裝置,使得原有的
frame
布局方式無法很好地适配不同尺寸的螢幕,是以,為了解決這一問題 Auto Layout 就誕生了。
Auto Layout 的誕生并沒有如同蘋果的其它架構一樣收到開發者的好評,它自誕生的第一天起就飽受 iOS 開發者的批評,其蹩腳、冗長的文法使得它在剛剛面世就被無數開發者吐槽,寫了幾個螢幕的代碼都不能完成一個簡單的布局,哪怕是 VFL(Visual Format Language)也拯救不了它。
真正使 Auto Layout 大規模投入使用的應該還是 Masonry,它使用了鍊式的文法對 Auto Layout 進行了很好的封裝,使得 Auto Layout 更加簡單易用;時至今日,開發者也在日常使用中發現了 Masonry 的各種問題,于是出現了各種各樣的布局架構,不過這都是後話了。
Auto Layout 的原理和 Cassowary
Auto Layout 的原理其實非常簡單,在這裡通過一個例子先簡單的解釋一下:
iOS 中視圖所需要的布局資訊隻有兩個,分别是
origin/center
和
size
,在這裡我們以
origin & size
為例,也就是
frame
時代下布局的需要的兩個資訊;這兩個資訊由四部分組成:
-
&x
y
-
&width
height
以左上角的
(0, 0)
為坐标的原點,找到坐标
(x, y)
,然後繪制一個大小為
(width, height)
的矩形,這樣就完成了一個最簡單的布局。而 Auto Layout 的布局方式與上面所說的
frame
有些不同,
frame
表示與父視圖之間的絕對距離,但是 Auto Layout 中大部分的限制都是描述性的,表示視圖間相對距離,以上圖為例:
A.left = Superview.left + 50
A.top = Superview.top + 30
A.width = 100
A.height = 100
B.left = (A.left + A.width)/(A.right) + 30
B.top = A.top
B.width = A.width
B.height = A.height
雖然上面的限制很好的表示了各個視圖之間的關系,但是 Auto Layout 實際上并沒有改變原有的 Hard-Coded 形式的布局方式,隻是将原有沒有太多意義的
(x, y)
值,變成了描述性的代碼。
我們仍然需要知道布局資訊所需要的四部分
x
、
y
、
width
以及
height
。換句話說,我們要求解上述的八元一次方程組,将每個視圖所需要的資訊解出來;Cocoa 會在運作時求解上述的方程組,最終使用
frame
來繪制視圖。
Cassowary 算法
在上世紀 90 年代,一個名叫 Cassowary 的布局算法解決了使用者界面的布局問題,它通過将布局問題抽象成線性等式和不等式限制來進行求解。
Auto Layout 其實就是對 Cassowary 算法的一種實作,但是這裡并不會對它展開介紹,有興趣的讀者可以在文章最後的 Reference 中了解一下 Cassowary 算法相關的文章。
Auto Layout 的原理就是對線性方程組或者不等式的求解。
Auto Layout 的性能
在使用 Auto Layout 進行布局時,可以指定一系列的限制,比如視圖的高度、寬度等等。而每一個限制其實都是一個簡單的線性等式或不等式,整個界面上的所有限制在一起就明确地(沒有沖突)定義了整個系統的布局。
在涉及沖突發生時,Auto Layout 會嘗試 break 一些優先級低的限制,盡量滿足最多并且優先級最高的限制。
因為布局系統在最後仍然需要通過
frame
來進行,是以 Auto Layout 雖然為開發者在描述布局時帶來了一些好處,不過它相比原有的布局系統加入了從限制計算
frame
的過程,而在這裡,我們需要了解 Auto Layout 的布局性能如何。
因為使用 Cassowary 算法解決限制問題就是對線性等式或不等式求解,是以其時間複雜度就是多項式時間的,不難推測出,在處理極其複雜的 UI 界面時,會造成性能上的巨大損失。
在這裡我們會對 Auto Layout 的性能進行測試,為了更明顯的展示 Auto Layout 的性能,我們通過
frame
的性能建立一條基準線以消除對象的建立和銷毀、視圖的渲染、視圖層級的改變帶來的影響。
你可以在 這裡 找到這次對 Layout 性能測量使用的代碼。
代碼分别使用 Auto Layout 和
frame
對 N 個視圖進行布局,測算其運作時間。
使用 AutoLayout 時,每個視圖會随機選擇兩個視圖對它的
top
和
left
進行限制,随機生成一個數字作為
offset
;同時,還會用幾個優先級高的限制保證視圖的布局不會超出整個
keyWindow
。
而下圖就是對 100~1000 個視圖布局所需要的時間的折線圖。
這裡的資料是在 OS X EL Captain,Macbook Air (13-inch Mid 2013)上的 iPhone 6s Plus 模拟器上采集的, Xcode 版本為 7.3.1。在其他裝置上可能不會獲得一緻的資訊,由于筆者的 iPhone 更新到了 iOS 10,是以沒有辦法真機測試,最後的結果可能會有一定的偏差。
從圖中可以看到,使用 Auto Layout 進行布局的時間會是隻使用
frame
的 16 倍左右,雖然這裡的測試結果可能受外界條件影響差異比較大,不過 Auto Layout 的性能相比
frame
确實差很多,如果去掉設定
frame
的過程消耗的時間,Auto Layout 過程進行的計算量也是非常巨大的。
在上一篇文章中,我們曾經提到,想要讓 iOS 應用的視圖保持 60 FPS 的重新整理頻率,我們必須在 1/60 = 16.67 ms 之内完成包括布局、繪制以及渲染等操作。
也就是說如果目前界面上的視圖大于 100 的話,使用 Auto Layout 是很難達到絕對流暢的要求的;而在使用
frame
時,同一個界面下哪怕有 500 個視圖,也是可以在 16.67 ms 之内完成布局的。不過在一般情況下,在 iOS 的整個
UIWindow
中也不會一次性出現如此多的視圖。
我們更關心的是,在日常開發中難免會使用 Auto Layout 進行布局,既然有 16.67 ms 這個限制,那麼在界面上出現了多少個視圖時,我才需要考慮其它的布局方式呢?在這裡,我們将需要布局的視圖數量減少一個量級,重新繪制一個圖表:
從圖中可以看出,當對 30 個左右視圖使用 Auto Layout 進行布局時,所需要的時間就會在 16.67 ms 左右,當然這裡不排除一些其它因素的影響;到目前為止,會得出一個大緻的結論,使用 Auto Layout 對複雜的 UI 界面進行布局時(大于 30 個視圖)就會對性能有嚴重的影響(同時與裝置有關,文章中不會考慮裝置性能的差異性)。
上述對 Auto Layout 的使用還是比較簡單的,而在日常使用中,使用嵌套的視圖層級又非常正常。
在筆者對嵌套視圖層級中使用 Auto Layout 進行布局時,當視圖的數量超過了 500 時,模拟器直接就 crash 了,是以這裡沒有超過 500 個視圖的資料。
我們對嵌套視圖數量在 100~500 之間布局時間進行測量,并與 Auto Layout 進行比較:
在視圖數量大于 200 之後,随着視圖數量的增加,使用 Auto Layout 對嵌套視圖進行布局的時間相比非嵌套的布局成倍增長。
雖然說 Auto Layout 為開發者在多尺寸布局上提供了周遊,而且支援跨越視圖層級的限制,但是由于其實作原理導緻其時間複雜度為多項式時間,其性能損耗是僅使用
frame
的十幾倍,是以在處理龐大的 UI 界面時表現差強人意。
在三年以前,有一篇關于 Auto Layout 性能分析的文章,可以點選這裡了解這篇文章的内容 Auto Layout Performance on iOS。
ASDK 的布局引擎
Auto Layout 不止在複雜 UI 界面布局的表現不佳,它還會強制視圖在主線程上布局;是以在 ASDK 中提供了另一種可以在背景線程中運作的布局引擎,它的結構大緻是這樣的:
ASLayoutSpec
與下面的所有的 Spec 類都是繼承關系,在視圖需要布局時,會調用
ASLayoutSpec
或者它的子類的
- measureWithSizeRange:
方法傳回一個用于布局的對象 ASLayout。
ASLayoutable
是 ASDK 中一個協定,遵循該協定的類實作了一系列的布局方法。
當我們使用 ASDK 布局時,需要做下面四件事情中的一件:
- 提供
layoutSpecBlock
- 覆寫
方法- layoutSpecThatFits:
- 覆寫
方法- calculateSizeThatFits:
- 覆寫
方法- calculateLayoutThatFits:
隻有做上面四件事情中的其中一件才能對 ASDK 中的視圖或者說結點進行布局。
方法
- calculateSizeThatFits:
提供了手動布局的方式,通過在該方法内對
frame
進行計算,傳回一個目前視圖的
CGSize
。
而
- layoutSpecThatFits:
與
layoutSpecBlock
其實沒什麼不同,隻是前者通過覆寫方法傳回
ASLayoutSpec
;後者通過 block 的形式提供一種不需要子類化就可以完成布局的方法,兩者可以看做是完全等價的。
- calculateLayoutThatFits:
方法有一些不同,它把上面的兩種布局方式:手動布局和 Spec 布局封裝成了一個接口,這樣,無論是
CGSize
還是
ASLayoutSpec
最後都會以
ASLayout
的形式傳回給方法調用者。
手動布局
這裡簡單介紹一下手動布局使用的
-[ASDisplayNode calculatedSizeThatFits:]
方法,這個方法與
UIView
中的
-[UIView sizeThatFits:]
非常相似,其差別隻是在 ASDK 中,所有的計算出的大小都會通過緩存來提升性能。
- (CGSize)calculateSizeThatFits:(CGSize)constrainedSize {
return _preferredFrameSize;
}
子類可以在這個方法中進行計算,通過覆寫這個方法傳回一個合适的大小,不過一般情況下都不會使用手動布局的方式。
使用 ASLayoutSpec 布局
在 ASDK 中,更加常用的是使用
ASLayoutSpec
布局,在上面提到的
ASLayout
是一個儲存布局資訊的媒介,而真正計算視圖布局的代碼都在
ASLayoutSpec
中;所有 ASDK 中的布局(手動 / Spec)都是由
-[ASLayoutable measureWithSizeRange:]
方法觸發的,在這裡我們以
ASDisplayNode
的調用棧為例看一下方法的執行過程:
-[ASDisplayNode measureWithSizeRange:]
-[ASDisplayNode shouldMeasureWithSizeRange:]
-[ASDisplayNode calculateLayoutThatFits:]
-[ASDisplayNode layoutSpecThatFits:]
-[ASLayoutSpec measureWithSizeRange:]
+[ASLayout layoutWithLayoutableObject:constrainedSizeRange:size:sublayouts:]
-[ASLayout filteredNodeLayoutTree]
ASDK 的文檔中推薦在子類中覆寫
- layoutSpecThatFits:
方法,傳回一個用于布局的
ASLayoutSpec
對象,然後使用
ASLayoutSpec
中的
- measureWithSizeRange:
方法對它指定的視圖進行布局,不過通過覆寫 ASDK 的布局引擎一節中的其它方法也都是可以的。
如果我們使用
ASStackLayoutSpec
對視圖進行布局的話,方法調用棧大概是這樣的:
-[ASDisplayNode measureWithSizeRange:]
-[ASDisplayNode shouldMeasureWithSizeRange:]
-[ASDisplayNode calculateLayoutThatFits:]
-[ASDisplayNode layoutSpecThatFits:]
-[ASStackLayoutSpec measureWithSizeRange:]
ASStackUnpositionedLayout::compute
ASStackPositionedLayout::compute ASStackBaselinePositionedLayout::compute +[ASLayout layoutWithLayoutableObject:constrainedSizeRange:size:sublayouts:]
-[ASLayout filteredNodeLayoutTree]
這裡隻是執行了
ASStackLayoutSpec
對應的
- measureWithSizeRange:
方法,對其中的視圖進行布局。在
- measureWithSizeRange:
中調用了一些 C++ 方法
ASStackUnpositionedLayout
、
ASStackPositionedLayout
以及
ASStackBaselinePositionedLayout
的
compute
方法,這些方法完成了對
ASStackLayoutSpec
中視圖的布局。
相比于 Auto Layout,ASDK 實作了一種完全不同的布局方式;比較類似與前端開發中的
Flexbox
模型,而 ASDK 其實就實作了
Flexbox
的一個子集。
在 ASDK 1.0 時代,很多開發者都表示希望 ASDK 中加入 ComponentKit 的布局引擎;而現在,ASDK 布局引擎的大部分代碼都是從 ComponentKit 中移植過來的(ComponentKit 是另一個 Facebook 團隊開發的用于布局的架構)。
ASLayout
ASLayout
表示目前的結點在布局樹中的大小和位置;當然,它還有一些其它的奇怪的屬性:
@interface ASLayout : NSObject
@property (nonatomic, weak, readonly) id<ASLayoutable> layoutableObject;
@property (nonatomic, readonly) CGSize size;
@property (nonatomic, readwrite) CGPoint position;
@property (nonatomic, readonly) NSArray<ASLayout *> *sublayouts;
@property (nonatomic, readonly) CGRect frame;
...
@end
代碼中的
layoutableObject
表示目前的對象,
sublayouts
表示目前視圖的子布局
ASLayout
數組。
整個類的實作都沒有什麼值得多說的,除了大量的構造方法,唯一一個做了一些事情的就是
-[ASLayout filteredNodeLayoutTree]
方法了:
- (ASLayout *)filteredNodeLayoutTree {
NSMutableArray *flattenedSublayouts = [NSMutableArray array];
struct Context {
ASLayout *layout;
CGPoint absolutePosition;
};
std::queue<Context> queue;
queue.push({self, CGPointMake(0, 0)});
while (!queue.empty()) {
Context context = queue.front();
queue.pop();
if (self != context.layout && context.layout.type == ASLayoutableTypeDisplayNode) {
ASLayout *layout = [ASLayout layoutWithLayout:context.layout position:context.absolutePosition];
layout.flattened = YES;
[flattenedSublayouts addObject:layout];
}
for (ASLayout *sublayout in context.layout.sublayouts) {
if (sublayout.isFlattened == NO) queue.push({sublayout, context.absolutePosition + sublayout.position});
}
return [ASLayout layoutWithLayoutableObject:_layoutableObject
constrainedSizeRange:_constrainedSizeRange
size:_size
sublayouts:flattenedSublayouts];
}
而這個方法也隻是将
sublayouts
中的内容展平,然後執行個體化一個新的
ASLayout
對象。
ASLayoutSpec
ASLayoutSpec
的作用更像是一個抽象類,在真正使用 ASDK 的布局引擎時,都不會直接使用這個類,而是會用類似
ASStackLayoutSpec
、
ASRelativeLayoutSpec
、
ASOverlayLayoutSpec
以及
ASRatioLayoutSpec
等子類。
筆者不打算一行一行代碼深入講解其内容,簡單介紹一下最重要的
ASStackLayoutSpec
。
ASStackLayoutSpec
從
Flexbox
中獲得了非常多的靈感,比如說
justifyContent
、
alignItems
等屬性,它和蘋果的
UIStackView
比較類似,不過底層并沒有使用 Auto Layout 進行計算。如果沒有接觸過
ASStackLayoutSpec
的開發者,可以通過這個小遊戲 Foggy-ASDK-Layout 快速學習
ASStackLayoutSpec
的使用。
關于緩存以及異步并發
因為計算視圖的
CGRect
進行布局是一種非常昂貴的操作,是以 ASDK 在這裡面加入了緩存機制,在每次執行
- measureWithSizeRange:
方法時,都會通過
-shouldMeasureWithSizeRange:
判斷是否需要重新計算布局:
- (BOOL)shouldMeasureWithSizeRange:(ASSizeRange)constrainedSize {
return [self _hasDirtyLayout] || !ASSizeRangeEqualToSizeRange(constrainedSize, _calculatedLayout.constrainedSizeRange);
}
- (BOOL)_hasDirtyLayout {
return _calculatedLayout == nil || _calculatedLayout.isDirty;
}
在一般情況下,隻有目前結點被标記為
dirty
或者這一次布局傳入的
constrainedSize
不同時,才需要進行重新計算。在不需要重新計算布局的情況下,隻需要直接傳回
_calculatedLayout
布局對象就可以了。
因為 ASDK 實作的布局引擎其實隻是對
frame
的計算,是以無論是在主線程還是背景的異步并發程序中都是可以執行的,也就是說,你可以在任意線程中調用
- measureWithSizeRange:
方法,ASDK 中的一些
ViewController
比如:
ASDataViewController
就會在背景并發程序中執行該方法:
- (NSArray<ASCellNode *> *)_layoutNodesFromContexts:(NSArray<ASIndexedNodeContext *> *)contexts {
...
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_apply(nodeCount, queue, ^(size_t i) {
ASIndexedNodeContext *context = contexts[i];
ASCellNode *node = [context allocateNode];
if (node == nil) node = [[ASCellNode alloc] init];
CGRect frame = CGRectZero;
frame.size = [node measureWithSizeRange:context.constrainedSize].size;
node.frame = frame;
[ASDataController _didLayoutNode];
});
...
return nodes;
}
上述代碼做了比較大的修改,将原有一些方法調用放到了目前方法中,并省略了大量的代碼。
關于性能的對比
由于 ASDK 的布局引擎的問題,其性能比較難以測試,在這裡隻對 ASDK 使用
ASStackLayoutSpec
的布局計算時間進行了測試,不包括視圖的渲染以及其它時間:
測試結果表明
ASStackLayoutSpec
花費的布局時間與結點的數量成正比,哪怕計算 100 個視圖的布局也隻需要 8.89 ms,雖然這裡沒有包括視圖的渲染時間,不過與 Auto Layout 相比性能還是有比較大的提升。
總結
其實 ASDK 的布局引擎大部分都是對 ComponentKit 的封裝,不過由于擺脫了 Auto Layout 這一套低效但是通用的布局方式,ASDK 的布局計算不僅在背景并發線程中進行、而且通過引入
Flexbox
提升了布局的性能,但是 ASDK 的使用相對比較複雜,如果隻想對布局性能進行優化,更推薦單獨使用 ComponentKit 架構。