UITableView/UICollectionView的優化一直是iOS應用效能優化重要的一塊。即使是iOS10+iPhone7這樣的最新軟硬體配置,在系統的資訊app中滾動,仔細觀察的話仍然能感到一定的掉幀現象。對於UI要求苛刻的蘋果竟然在如此簡單的tableView上無法達到60fps的幀率,可見優化滾動效能的背後並不簡單。
為什麼?
理想狀態下,iOS的幀率應該保持在60fps。然而很多情況下使用者操作時會感覺到掉幀或者『不跟手』。原因可能有很多,這裡只簡單列舉幾個,網上可以找到許多相應分析:
- CPU(主要是主執行緒)/GPU負擔過重或者不均衡(諸如mask/cornerRadius/drawRect/opaque帶來offscreen
rendering/blending等等)。由於所有的UIView都是由CALayer來負責顯示,因此對Core
Animation的瞭解就變得尤為重要。這裡推薦Nick Lockwood的Core Animation: Advanced
Techniques一書,其中有對Core Animation的效能有著非常詳盡的梳理和剖析。 - Autolayout佈局效能瓶頸,約束計算時間會隨著數量呈指數級增長,並且必須在主執行緒執行。具體分析可以參考這篇文章:floriankugler.com/2013/04/22/…。這也是為何ASDK拋棄了Autolayout而設計了自己的佈局系統的重要原因之一(github.com/facebook/As…)。Autolayout在單個View開發時能帶來很多便利,而在一些需要高效能的場景下需要謹慎使用。
- 儘管從iPhone4S(A5)開始CPU已經採用多核,然而對於大多數app來說,多執行緒協作並沒有被充分利用。換句話說,在app卡頓(主執行緒所佔用的核心滿負荷)時,往往CPU的其他核心幾乎無事可做。一般情況下,由於主執行緒承擔了絕大部分的工作,如果能把主執行緒的任務轉移一部給其他執行緒進行非同步處理,就可以馬上享受到併發帶來的效能提升。這應該也是AsyncDisplayKit得名的原因之一。
UIKit的單執行緒設計也有一定的歷史原因。早在十年前iOS SDK剛問世的時候,mobile
SDK還是一個非常新的概念,更沒有移動多核CPU的存在,因此當時的重點是簡單可靠,大多數API都沒有支援相對複雜的非同步操作。時至今日,如果要完全重構UIKit使之支援非同步繪製和佈局,對於相容已有海量的app,難度可想而知。在iOS10中雖然對UICollectionView/UITableView做了一定的預載入優化(WWDC2016
Session219),然而並沒有從根本上解決主執行緒佈局和渲染的問題。
優化思路
我們知道,當使用者開始滾動或點選一個View,所有的事件都會被送到主執行緒等待處理。此時主執行緒能否抽出足夠充裕的時間來處理變得極為重要,尤其是在連續操作(如UIGestureRecognizer)時,每次touchMoved事件處理都會佔用主執行緒一定的時間(如新的UIImageView進入檢視,主執行緒開始處理佈局或者圖片解碼,而這些需要連續佔用大量CPU時間)。如果一個操作耗時超過16ms(1000ms/60fps),那就意味著下一幀無法及時得到處理,引起丟幀。
圖片擷取自wwdc2016 session219
如何能將主執行緒的壓力盡可能減輕成為優化的首要目標。
對列表滾動卡頓的常用解決方案有(推薦Yaoyuan的部落格,有比較深入的介紹:
blog.ibireme.com/2015/11/12/…):
- 針對Autolayout效能優化:提前計算並快取cell的layout:
github.com/forkingdog/… - 省去中間滑動過程中的計算,直接計算目標區域cell:
github.com/johnil/VVeb… - 棄用Autolayout,採用手動佈局計算。這樣雖然可以換來最高的效能,但是代價是編寫和維護的不便,對於經常改動或者效能要求不高的場景並不一定值得。
- 自行非同步渲染Layer,如:github.com/ibireme/YYA…
- iOS10列表的prefetch API,只是並沒有解決Autolayout的效能,同時也受到系統版本限制。
ASDK的基本思路:非同步
對於一般的開發者,自己重新實現一整套非同步佈局和渲染機制是非常困難的。幸運的是,ASDK做到了。
AsyncDisplayKit(ASDK)是2012年由Facebook開始著手開發,並於2014年出品的高效能顯示類庫,主要作者是Scott
Goodson。Scott(github:
appleguy)曾經參與了多個iOS版本系統的開發,包括UIKit以及一些系統原生app,後來加入Facebook並參與了ASDK的開發並應用到Paper,因此該庫有機會從相對底層的角度來進行一系列的優化。
現在最新的版本是2.0,除了擁有1.0系列版本核心的非同步佈局渲染功能,還增加了類似ComponentKit的基於flexbox的佈局功能。原始檔一共近300個,3萬多行程式碼,是一個非常龐大而精密的顯示和佈局系統。使用上如果不考慮工程成本,完全可以在一定程度上代替UIKit的大部分功能。同時由於和Instagram同處於FB家族,因此也迅速在最近的更新中加入了IGListKit的支援。
在Scott介紹ASDK的視訊中,總結了一下三點佔用大量CPU時間的『元凶』(雖然仍然可能有以上提到的其他原因,但ASDK最主要集中於這三點進行優化):
- 渲染,對於大量圖片,或者大量文字(尤其是CJK字元)混合在一起時。而文字區域的大小和佈局,恰恰依賴著渲染的結果。ASDK儘可能後臺執行緒進行渲染,完成後再同步回主執行緒相應的UIView。
- 佈局。ASDK完全棄用了Autolayout,另闢蹊徑實現了自己的佈局和快取機制。關於佈局的問題會在下一篇講到。
- 系統objects的建立與銷燬。由於UIKit封裝了CALayer以支援觸控等顯示以外的操作,耗時也相應增加。而這些同樣也需要在主執行緒上操作。ASDK基於Node的設計,突破了UIKit執行緒的限制。
既然同步就意味著阻塞,那就非同步放到其他執行緒去做,在需要主執行緒時再同步回來。
我們知道對於一般UIView和CALayer來說,因為不是執行緒安全的,任何相關操作都需要在主執行緒進行。正如UIView可以彌補CALayer無法處理使用者事件的不足一樣,ASDK引入了Node的概念來解決UIView/CALayer只能在主執行緒上操作的限制(不由讓人想起『Abstract layer can solve many problems, except problem of having too many abstract layers.』)。
主要特點如下:
- 每個Node對應相應的UIView或者CALayer,從開發者的角度而言,只需要將初始化UIView的程式碼稍作修改,替換為建立ASDisplayNode即可。在不需要接受使用者操作的Node上可以開啟isLayerBacked,直接使用CALayer進一步降低開銷。根據Scott的研究UIView的開銷大約是CALayer的5倍。
- Node預設是非同步佈局/渲染,只有在需要將frame/contents等同步到UIView上才會回到主執行緒,使其空出更多的時間處理其他事件。
- ASDK只有在認為需要的時候才會非同步地為Node載入相應的View,因此建立Node的開銷變得非常低。同時Node是執行緒安全的,可以在任意queue上建立和設定屬性。
- ASDK不僅有與UIView對應的大部分控制元件(如ASButtonNode、ASTextNode、ASImageNode、ASTableNode等等),同時也bridge了大多數UIView的方法和屬性,可以非常方便的操作frame/backgroundColor/addSubnode等,因此一般情況下只要對Node進行操作,ASDK就會在適當的時候同步到其View。如果需要的話,當相應的View載入之後(或訪問node.view手動觸發載入),也可以通過node.view的方式直接訪問,回到我們熟悉的UIKit。
- 當實現自定義View的時候,ASDisplayNode提供了一個初始化方法initWithViewBlock/initWithLayerBlock,就可以將任意UIView/CALayer用Node包裹起來(被包裹的view可以使用autolayout),從而與ASDK的其他元件相結合。雖然這樣建立的Node與一般view在佈局和渲染上的差異不大,但是由於Node管理著何時何地載入view,我們仍然能得到一定的效能提升。
舉例來說,當使用UIKit建立一個UIImageView:
_imageView = [[UIImageView alloc] init];
_imageView.image = [UIImage imageNamed:@"hello"];
_imageView.frame = CGRectMake(10.0f, 10.0f, 40.0f, 40.0f);
[self.view addSubview:_imageView];複製程式碼
使用ASDK後只要稍加改動:
_imageNode = [[ASImageNode alloc] init];
_imageNode.image = [UIImage imageNamed:@"hello"];
_imageNode.frame = CGRectMake(10.0f, 10.0f, 40.0f, 40.0f);
[self.view addSubview:_imageNode.view];複製程式碼
雖然只是簡單的把View替換成了Node,然而和UIImageView不同的是,此時ASDK已經在悄悄使用另一個執行緒進行圖片解碼,從而大大降低新的使用者操作到來時主執行緒被阻塞的概率,使每一個回撥都能得到及時的處理。實踐中將會有更加複雜的情況,有興趣的話可以參考專案中的Example目錄,有20多個不同場景下的示例專案。
一些細節
- 在ASDisplayNode.h中有相當多的註釋,其中displaysAsynchronously屬性大致描述了非同步渲染的步驟:
Asynchronous rendering proceeds as follows:
When the view is initially added to the hierarchy, it has -needsDisplay true.
After layout, Core Animation will call -display on the _ASDisplayLayer
-display enqueues a rendering operation on the displayQueue
When the render block executes, it calls the delegate display method
(-drawRect:… or -display)The delegate provides contents via this method and an operation is added to
the asyncdisplaykit_async_transactionOnce all rendering is complete for the current
asyncdisplaykit_async_transaction,the completion for the block sets the contents on all of the layers in the
same frame
從中我們可以看到,所有非同步渲染操作是先被同一加入asyncdisplaykit_async_transaction,然後一起提交的。在_ASAsyncTransactionGroup.m原始檔中,可以看到ASDK是在主執行緒的runloop(關於runloop可以參考Yaoyuan的文章和sunny的視訊)中註冊了observer,在kCFRunLoopBeforeWaiting和kCFRunLoopExit兩個activity的回撥中將之前非同步完成的工作同步到主執行緒中去。
- ASDisplayNode還有一個屬性shouldRasterizeDescendants。
/**
@abstract Whether to draw all descendant nodes’ layers/views into this node’s
layer/view’s backing store.@discussion
When set to YES, causes all descendant nodes’ layers/views to be drawn
directly into this node’s layer/view’s backingstore. Defaults to NO.
If a node’s descendants are static (never animated or never change attributes
after creation) then that node is agood candidate for rasterization. Rasterizing descendants has two main
benefits:1) Backing stores for descendant layers are not created. Instead the layers
are drawn directly into the rasterizedcontainer. This can save a great deal of memory.
2) Since the entire subtree is drawn into one backing store, compositing and
blending are eliminated in that subtreewhich can help improve animation/scrolling/etc performance.
Rasterization does not currently support descendants with transform,
sublayerTransform, or alpha. Those propertieswill be ignored when rasterizing descendants.
Note: this has nothing to do with -[CALayer shouldRasterize], which doesn’t
work with ASDisplayNode’s asynchronousrendering model.
*/
當我們不需要分別關注單個CALayer,也不需要對他們進行操作時,就可以將所有的子node都合併到父node的backing
store一併繪製,從而達到節省記憶體和提高效能的目的。
注意事項
- ASDK不支援Storyboard和Autolayout,但是可以與使用Autolayout的view相容共存。同樣React native和Component
Kit等其他Facebook出品的iOS庫也不支援Storyboard。 - 由於Node的非同步渲染,很有可能在其View到達螢幕之後,內容仍然在渲染過程中。此時需要額外考慮每個Node的placeholder狀態,使使用者不至於看到一片空白。
- 在使用ASDisplayNode初始化initWithViewBlock時,由於Node需要在適當的時候呼叫該block來建立view,因此並不會立即呼叫block(block可能capture其他變數,例如self),而是存在一個ivar當中。如果該view始終沒被建立,而此時擁有該node的父元素被銷燬,容易造成retain
cycle導致memory leak。
Best Practice
由於ASDK的基本理念是在需要建立UIView時替換成對應的Node來獲取效能提升,因此對於現有程式碼改動較大,侵入性較高,同時由於大量原本熟悉的操作變成了非同步的,對於一個團隊來說學習曲線也較為陡峭。
從我們在實際專案中的經驗,結合Scott的建議來看,不需要也不可能將所有UIView都替換成其Node版本。將注意力集中在可能造成主執行緒阻塞的地方,如tableView/collectionView、複雜佈局的View、使用連續手勢的操作等等。找到合適的切入點將一部分效能需求較高的程式碼替換成ASDK,會是一個較好的選擇。
對於優化以後的效果,可以嘗試我們的應用:即刻,其中訊息盒子部分應用了ASDK進行了大量調優工作,從而在擁有大量圖片和gif/不定長度文字共存的情況下,仍然能達到60fps的流暢體驗。在我們將近兩年的實踐中,儘管也碰到過一些坑,但是帶來的提升也是非常明顯的,使在構建更復雜的介面同時保持高效能成為可能。
推薦閱讀
AsyncDisplayKit Getting Started
AsyncDisplayKit Tutorial: Node
Hierarchies
NSLondon — Scott Goodson — Behind
AsyncDisplayKit
MCE 2015 — Scott Goodson — Effortless Responsiveness with
AsyncDisplayKit
AsyncDisplayKit 2.0: Intelligent User Interfaces — NSSpain
2015
PS: 即刻正在招聘,如果你是一個追求極致又富有探索精神的iOS工程師,同時也熱愛我們的產品,那麼歡迎加入我們,一起參加WWDC(公司cover全部費用),持續打造更棒的即刻!聯絡我們:hr@ruguoapp.com