iOS從 Auto Layout 的布局算法談性能

uigl1418 8年前發布 | 21K 次閱讀 算法 iOS開發 移動開發

這是使用 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 的性能建立一條基準線 以消除對象的創建和銷毀、視圖的渲染、視圖層級的改變帶來的影響 。

代碼分別使用 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 倍 左右,如果去掉設置 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: 方法返回一個用于布局的對象。

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: 方法對它指定的視圖進行布局,不過通過覆寫一節中的其它方法也都是可以的。

如果我們使用 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 是另一個 非死book 團隊開發的用于布局的框架)。

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 框架進行。

References

 

來自:http://draveness.me/layout-performance/

 

 本文由用戶 uigl1418 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!