這篇文章會經過對 autolayout 內部實現的探索和數據分析和對 autolayout 的性能問題作一個詳細的分析,並在最後給出一個高性能 autolayout
的解決方案。開始看文章以前,能夠先試試這個 demo ,使用 YYKit demo 數據作的微博 Feed 列表。使用我本身寫的異步繪製組件 Panda 和和 ‘autolayout’ 框架 Layoutable 寫的 ,cell 代碼只有 五百多行,可是流暢度很高。node
Text layout 對性能的影響github
Autolayout 的一些結論swift
Panda緩存
Autolayout 會將約束條件轉換成線性規劃問題,經過 Cassowary 算法求解線性規劃問題獲得 frame。所以分析 autolayout 性能都繞不開 Cassowary 算法。大部分分析最後都會給出結論 「autolayout 性能差是 cassowary 算法的多項式的時間複雜度形成的」。也有一些會給出 autolayout 的 benchmark 來證實 cassowary 算法的問題。可是性能優化
想到本身實現 cassowary 算法和 autolayout 也是由對這兩個問題的不解引出的。bash
Cassowary is an incremental constraint solving toolkit that efficiently solves systems of linear equalities and inequalities.多線程
線性規劃問題的求解很早就有通用解法--單純型法,有興趣的同窗能夠看看這篇文章 AutoLayout 中的線性規劃 - Simplex 算法 。《算法導論》也有一章專門介紹單純型法的(因此誰說算法對 iOS 開發沒用🐶)。Cassowary 則是單純型法在用戶界面實踐中的應用和改進算法,解決一些實際使用的問題,最重要的增長了增量的概念(Autolayout 實現中 Cassowary 相關的代碼是以 NSIS
做爲前綴的,IS
就是 incremental Simplex
增量單純型的縮寫 ),單純型法經過創建單純型表,在對單純形表進行 pivot 和 optimize 操做獲得最優解;Cassowary 則是能夠在已經創建單純型表上,高效的進行添加修改更新操做。由於用戶界面應用中,大部分約束已經固定,界面變化只須要對其中的部分約束進行更新或者進行少許的增減操做。Cassowary 的高效是創建在增量跟新的基礎上的。
完整介紹 Cassowary 須要很長篇幅,有時間單獨介紹,這裏用數聽說話
一組 benchmark: (MacBook Pro 2016 i5,iPhone6S 模擬器)
由於這裏沒有不含 UILabel,UIView 等有 intrincContentSize 的 UIView,update constant 基本就是 Cassowary 更新約束的耗時。Applelayout 和 Apple NestLayout 則也包含 UIView 建立,約束建立和求解的時間。
能夠看到 update 約束是很是高效的, 80 個 view,160 條約束更新約束也只須要 2.5 個毫秒,這個數量在實際使用中基本上是用不到的。實際使用中,同時更新 40 個 view 80 條約束已經算是不少的了,也只耗時 1.25 ms。
列表滾動中,通常狀況下頁面加載的時候 cell 和 約束已經建立,性能應該主要和更新約束相關(更新約束包括 UILabel。UIView 更改 text ,image 形成的 size 變化,更新系統默認的約束;也包括手動調整 NSLayoutConstraint 的 constant 屬性等)。爲何實際表現卻差不少呢?
Autolayout 構建在 Cassowary 之上,可是 autolayout 的一些機制沒有充分利用 Cassowary 更新高效的特色。咱們能夠經過私有類和方法來研究系統內部的實現。這裏有一個網站 iOS SDK Header Dump 能夠查看 iOS 的私有頭文件。其中 NSIS
開頭的類都是 Autolayout 相關的頭文件。我把 iOS 11 Autolayout 相關的頭文件下載下來並作成了一個能夠運行的工程。能夠 hook 內部實現或者打印變量來觀察系統的調用,能夠這裏下載 ExplorAutolayout 。後面一些測試代碼會基於這個工程。
NSContentSizeLayoutConstraint
這是 FDTemplateLayoutCell profile 的一段結果,展開部分是 cellForRowAIndex 裏運行的代碼。
理論上 cellForRowAIndex
是不須要建立 NSLayoutConstraint 的,畢竟 cell 已經建立過了, 更新數據的時候代碼中並無新加約束。但這裏建立了 UIContentSizeLayoutConstraint
對象,UIContentSizeLayoutConstraint
繼承自 NSLayoutConstraint
,是專門用來約束 contentSize 的約束。
來一段測試代碼,咱們在 NSLayoutConstraint
對象建立的時候輸出建立的約束類型:
// 子類化 UIlabel,每次調用 intrinsicContentSize 輸出大小
@implementation TestLabel
- (CGSize)intrinsicContentSize{
NSLog(@"width: %f, height: %f",size.width,size.height);
return [super intrinsicContentSize];
}
@end
// 替換 NSLayoutConstraint init 方法,每次輸出建立的類型
@implementation NSLayoutConstraint (methodSwizze)
+ (void)load{
[self replace:@selector(init) byNew:@selector(new_init)];
}
- (instancetype)new_init{
NSLog(@"New %@",[self class]);
return [self new_init];
}
@end
複製代碼
一個多行文字的 label 給一個寬度約束,而後設置 text, layoutIfNeeded
強制佈局 輸出結果:
width: 1073741824.000000, height: 20.500000
New NSContentSizeLayoutConstraint
New NSContentSizeLayoutConstraint
width: 296.500000, height: 41.000000
New NSContentSizeLayoutConstraint
New NSContentSizeLayoutConstraint
複製代碼
建立的兩個約束是根據 intrinsicContentSize
值給的寬度和高度約束。也就是每次 intrinsicContentSize
變化的時候,Autolayout 都會建立兩個新的 NSContentSizeLayoutConstraint
約束分別約束寬和高,添加到 NSISEnginer
中求解, 而不是直接更新已經建立好的約束。
水果公司一邊告訴咱們從新添加約束比更新約束低效,一邊在頻繁調用的地方用着低效的方法😂。
systemLayoutSizeFittingSize
NSContentSizeLayoutConstraint
只是蘋果浪費 Cassowary 算法優勢的一個地方,
看另外一組不包含 intrinsicContentSize
的 UIView
的數據,都是單純的更新約束,區別只在於有沒有添加到 window 上,以及強制佈局的方法:
Apple constant
是 view 沒有並添加到 window 上,更新約束後調用 layoutIfNeeded
的數據。Apple In Window constant
是把 view 添加到當前 window 上,更新約束後調用 layoutIfNeeded
的數據SystemFitSize constant
是調用 systemlayoutFitSize
獲取高度的數據。一樣是更新約束,耗時差距卻很是大,添加到 window 上再調用 layoutIfNeeded
的耗時遠小於沒有加到 window 上。一樣沒有加到 window 上,systemlayoutFitSize
耗時又要小於 layoutIfNeeded
.
再以 FDTemplateLayoutCell 爲例,咱們在同一方法中同事調用 systemLayoutSizeFittingSize
和 layoutIfNeeded
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
[self measure:^{
[self configureCell:self.cell atIndexPath:indexPath];
[self.cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
} log:@"heightForRow"];
FDFeedCell *cell = [tableView dequeueReusableCellWithIdentifier:@"FDFeedCell"];
[self measure:^{
[self configureCell:cell atIndexPath:indexPath];
[cell.contentView layoutIfNeeded];
} log:@"cellForRowAtIndexPath"];
return cell;
}
複製代碼
profile 下
systemLayoutSizeFittingSize
總耗時 276 ms, layoutIfNeeded
總耗時 161 ms
多了 70% 的耗時
看一下 autolayout 調用的過程:
替換 NSISEnginer
(NSISEnginer
就是 autolayout 的 線性規劃求解器)的 init
方法,每次建立 NSISEnginer
打印 New NSISEnginer
+ (void)load{
[self replace:@selector(init) byNew:@selector(new_init)];
}
- (id)new_init{
NSLog(@"New NSISEnginer");
return [self new_init];
}
...
@implementation NSObject(methodExchange)
+ (void)replace:(SEL)old byNew:(SEL)new{
Method oldMethod = class_getInstanceMethod([self class], old);
Method newMethod = class_getInstanceMethod([self class], new);
method_exchangeImplementations(oldMethod, newMethod);
}
複製代碼
調用方法觀察輸出:
UIView * view3 = [[UIView alloc] init];
view3.translatesAutoresizingMaskIntoConstraints = false;
NSLayoutConstraint *c3 = [view3.widthAnchor constraintEqualToConstant:10];
c3.priority = UILayoutPriorityDefaultHigh;
c3.active = true;
for(NSUInteger i = 0; i < 3; i++){
[view3 setNeedsLayout];
[view3 layoutIfNeeded];
NSLog(@"View3LayoutIfNeeded");
}
for(NSUInteger i = 0; i < 3; i++){
[view3 setNeedsLayout];
[view3 systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
NSLog(@"No superview systemLayoutSizeFittingSize");
}
[self.view addSubview:view3];
for(NSUInteger i = 0; i < 3; i++){
c3.constant = rand()%20;
[view3 setNeedsLayout];
[view3 layoutIfNeeded];
NSLog(@"View3LayoutIfNeededSecondPass");
}
for(NSUInteger i = 0; i < 3; i++){
c3.constant = rand()%20;
CGSize size = [view3 systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
NSLog(@"w :%f",size.width);
NSLog(@"systemLayoutSizeFittingSize");
}
複製代碼
打印結果是
View3LayoutIfNeeded
New NSISEnginer
View3LayoutIfNeeded
New NSISEnginer
View3LayoutIfNeeded
New NSISEnginer
No superview systemLayoutSizeFittingSize
New NSISEnginer
No superview systemLayoutSizeFittingSize
New NSISEnginer
No superview systemLayoutSizeFittingSize
New NSISEnginer
View3LayoutIfNeededSecondPass
View3LayoutIfNeededSecondPass
View3LayoutIfNeededSecondPass
systemLayoutSizeFittingSize
New NSISEnginer
systemLayoutSizeFittingSize
New NSISEnginer
systemLayoutSizeFittingSize
New NSISEnginer
複製代碼
能夠看到,沒有添加到 window
以前, 調用 layoutIfNeeded
和 systemLayoutSizeFittingSize
每次都會建立 NSISEnginer
;添加到 window 上之後,layoutIfNeeded
並不會建立 NSISEnginer, 而systemLayoutSizeFittingSize
仍是每次都會建立 NSISEnginer
。建立新的 NSISEnginer
則意味着對應的全部約束,也會從新添加到 NSISEnginer
,從新進行優化求解,這時候的耗時就變成了初次添加約束的時間。在列表的使用中,咱們通常會在 heightForRowAtIndexPath
中建立一個不會添加到 window 上的 cell
調用 systemLayoutSizeFittingSize
來計算高度。這個的計算耗時就要比 cellForRowAtIndexPath
中的耗時大不少。
systemLayoutSizeFittingSize
會從新建立 NSISEnginer
和 WWDC 《High performance Autolayout》 所講也是一致的。使用 systemLayoutSizeFittingSize
時,Autolayout 會建立新的 NSISEnginer 對象,從新添加約束求解,而後釋放掉 NSISEnginer 對象。而對於 layoutIfNeeded
也很好理解,Autolayout 中,一個 window 層級下的 view 會共用 window 節點的 NSISEnginer
對象,沒有添加到 window 上的 view 沒有父 window 也就沒辦法共用,只能從新建立.
而在 WWDC 介紹中 systemLayoutSizeFitting
是提供給 autolayout 和 frame 混合使用的,也不建議經常使用,彷佛不是給計算高度來用的。
那麼能不能在算高度時候把 cell 添加到 window 上,隱藏,而後用 layoutIfNeeded
來提升效率?
🍎:呵呵 🙃
systemLayoutSizeFittingSize
對計算作了優化,計算好之後不會對 view 的 frame 進行操做,也就避免 layer 調整的相關耗時。因此一樣是建立 NSISEnginer
從新添加約束, systemLayoutSizeFittingSize
比 layoutIfNeeded
要高效;添加到 window 上之後,layoutIfNeeded
計算的效率高於 systemLayoutSizeFittingSize
,可是 setFrame
和觸發的 layer 相關操做又會有額外的耗時,不必定會比直接使用 systemLayoutSizeFittingSize
耗時少 。
The Enginer is a layout cache and dependency tracker
Cassowary 的增量更新機制其實也算是某種程度上的緩存機制,從新建立 Enginer 的設計也就丟掉了 cache 的能力,下降了性能。
雖然因爲上述種種問題, 但如上圖所示 heightForRowAtIndexPath
裏調用 systemLayoutSizeFittingSize
再加上 cellForRowAtIndexPath
裏調用 layoutIfNeeded
總耗時看起來也並非不少,40 個 view 左右耗時也不到 4 ms,看起來還能夠,爲何實際使用起來表現卻差不少呢。
text layout 纔是性能殺手
以 FDTemplateLayoutCell demo,爲例,咱們對同一個 cell 連續執行三次同樣的代碼,
[self measure:^{
[self configureCell:self.cell atIndexPath:indexPath];
[self.cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
} log:@"heightForRow"];
[self measure:^{
[self configureCell:self.cell atIndexPath:indexPath];
[self.cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
} log:@"heightForRow"];
[self measure:^{
[self configureCell:self.cell atIndexPath:indexPath];
[self.cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
} log:@"heightForRow"];
複製代碼
結果差距很大
第一遍耗時 231 ms,後面兩遍只有 98,87 毫秒
若是把第一遍展開的話,就會發現大部分時間都是在文字上:
後面兩遍由於和第一遍的數據同樣,不會觸發文字相關的操做。計算的時間只佔了 30%-40%
以咱們的微博 demo layout 作一個 benchmak
for status in self.statusViewModels{
measureTime(desc: "without text layout cache", action: {
self.statusNode.update(status)
self.statusNode.layoutIfNeeded()
status.layoutValues = self.statusNode.layoutValues
status.height = self.statusNode.frame.height
})
}
for status in self.statusViewModels{
measureTime(desc: "without text layout cache", action: {
self.statusNode.update(status)
self.statusNode.layoutIfNeeded()
status.layoutValues = self.statusNode.layoutValues
status.height = self.statusNode.frame.height
})
}
複製代碼
兩個 for 循環中,除了輸出的描述文案,代碼是同樣的,Panda 的實現中,會把已經建立的 TextKit 組成的 TextRender 對象緩存起來,而且是不可變。再次出現相同的文字會從緩存取。
第一次 for 循環中,不存在相應的 TextRender 對象,每次都須要建立新的 TextRender 對象並進行 layout
第二次 for 循環中,由於第一次計算過程當中已經緩存了 TextRender,基本上只是單純取值和 Cassowary 更新約束計算。
結果:(iPhone6 , iOS 12)
一樣更新數據,一樣的 update 約束,一樣的 Panda Layout 數據相差卻很是大。並且第二次數據更加平穩
對於 Panda Layou,相差的數據基本就是 text layout 的時間。第一次 Layout 平均數據 5.94,第二次平均數據 1.44. text layout 佔了總耗時的 70%-80%。
Autolayout 要比手算多一些 Text layout過程
text layout 耗時最多, 使用 autolayout 會比 手算 frame 多一部分 text layout 過程
其實上一個 NSContentSizeLayoutConstraint
的輸出結果中已經給出部分答案,只設置一次 text,卻輸出了兩次 intrinsicContentSize
,並且結果也不同。 檢查一下 UIView 的私有方法,會發現一個_needsDoubleUpdateConstraintsPass
的方法,返回值爲 true 的話,會調用兩次 intrinsicContentSize
方法。
size(withAttributes:)
進行一次 text layout 就能夠把文字大小算出來。textlayout 耗時佔比很大,這也是爲何蘋果推薦重寫 UIlable 的 intrinsicContentSize
方法,而後約束寬高的方式來避免 text layout。可是實際使用中能這樣優化的場景並很少。
主線程運行的影響
關於列表性能優化,你們比較喜歡說的就是 frame 比 autolayout 快,其實更重要的是 frame 相對 autolayout 能夠減小一些重複計算,以及把耗時操做丟到後臺線程。
heightForRowAtIndexPath
和 cellForRowAtIndexPath
都須要計算,這個多出來的的計算和 text layout 就更多了。Textlayout 在計算和渲染過程佔的比重很大,也是不少 app 即便 cell 高度用 frame 算,沒有作 text layout 相關緩存或者異步 Label 也會不流暢的緣由。單純作計算的優化,不作 text layout 緩存的佈局框架通常實際表現都不會太好。
上面的 benchmark 是針對 iPhone 6 的, 數據其實已經很不錯了,更好的設備豈不是要逆天?
看一組 iPhneX 的數據 (iPhoneX , iOS 12)
即便第一次 layout,Panda 和 YYKit 平均耗時只有 1.34 毫秒,只更新約束更是隻須要 0.287 毫秒。(這個數據遠好於 2016 MacBook Pro 的表現)。時間寬裕度很大,看起來即便 autolayout 的耗時多個一兩倍問題也不大。
Apple: 呵呵🙃
benchmark 出來的耗時其實通常和實際運行是不同。一樣 iOS 12 iPhoneX ,若是對列表進行快速滑動的話,是能夠到達 benchmark 的數據;若是滑動的不是很快的,上面 0.x,1.x ms 的耗時,不少就變成了 6 - 9 ms 左右。
CPU 達到最好性能是須要時間的,benchmark 過程計算比較集中, CPU 一直處於高性能狀態。可是滑的慢一點的話,可能 CPU 性能還沒起來計算就結束了。而後 CPU 開始偷懶。恰好性能下去之後另外一計算過程又開始了。並且 iOS 12 這個已經優化過了,iOS11 和 iOS 10 表現更差。作 benchmark 的有時候也會有一個有趣的現象,若是有幾組數據須要測試,在同一段代碼裏調用這些方法進行測試,方法的調用順序對 benchmark 出來的數據影響特別大。放在第一個的方法耗時會被大大增長。
總結一下,autolayout 性能很差並非之前常常看到的是由於 cassowary 算法差致使的
爲了解決上述問題,我用 swift 實現了一套異步繪製和 layout 組件 Panda。
Panda 包含第三個部分:
Cassowary 是單純的線性規劃求解器;Layoutable 是在 Cassowary 之上構建的 'autolayout' ,底層上實現了相似 NSLayoutConstraint ,NSLayoutAnchor 相似的 LayoutConstraint 和 Anchor,也封裝了更高級的 API 方便使用。Layoutable 提供 Layoutable 協議,任何實現了 Layoutable 的對象均可以使用 autolayout
,好比 UIView,CALayer,或者其餘自定義對象; Panda 則是實現了 Layoutable 協議的異步繪製組件,提供異步繪製,文本 layout 緩存,和通用的 FlowLayout,StackLayout 複合佈局控件。
Panda 基本上解決了上面提到的問題
intrincContentSize
不會從新建立約束,只會更新約束常量。重複利用 Cassowary 的優點。fixedWidth
優化屬性,大部分狀況下能夠避免一部分 text layoutcellForRowAIndexPath
中能夠禁止自動佈局,直接使用緩存數據,防止重複計算。Panda 使用也很簡單, ViewNode,TextNode,ImageNode 分別代替 UIView,UILabel 和 UIImage,而後就能夠像 autolayout 同樣佈局
let node = ViewNode()
let node1 = ViewNode()
let node2 = TextNode()
textNode.text = "hehe"
node.addSubnode(node1)
node.addSubnode(node2)
node1.size == (30,30)
node2.size == (40,40)
[node,node1].equal(.centerY,.left)
/// 等價於
/// node.left == node1.left
/// node.centerY == node2.centerY
/// 或者
/// node.left.equalTo(node1.left)
/// node.centerY.equalTo(node1.centerY)
[node2,node].equal(.top,.bottom,.centerY,.right)
[node1,node2].space(10, axis: .horizontal)
/// 支持約束優先級
node.width == 100 ~.strong
node.height == 200 ~ 760.0
update constant
/// 更新約束
let c = node.left == 10
c.constant = 100
複製代碼
在上面提到的微博 Feed demo 中,只用 500 行代碼就能夠實現很是流程的列表。開發效率和運行效率都遠超手算 frame。代碼更少,維護起來更方便。
對比 Texture(或者說 AsyncDisplayKit), Panda