AsyncDisplayKit介紹(一)原理和思路

即刻技術團隊發表於2017-03-07

UITableView/UICollectionView的優化一直是iOS應用效能優化重要的一塊。即使是iOS10+iPhone7這樣的最新軟硬體配置,在系統的資訊app中滾動,仔細觀察的話仍然能感到一定的掉幀現象。對於UI要求苛刻的蘋果竟然在如此簡單的tableView上無法達到60fps的幀率,可見優化滾動效能的背後並不簡單。

為什麼?

理想狀態下,iOS的幀率應該保持在60fps。然而很多情況下使用者操作時會感覺到掉幀或者『不跟手』。原因可能有很多,這裡只簡單列舉幾個,網上可以找到許多相應分析:

  1. CPU(主要是主執行緒)/GPU負擔過重或者不均衡(諸如mask/cornerRadius/drawRect/opaque帶來offscreen
    rendering/blending等等)。由於所有的UIView都是由CALayer來負責顯示,因此對Core
    Animation的瞭解就變得尤為重要。這裡推薦Nick Lockwood的Core Animation: Advanced
    Techniques
    一書,其中有對Core Animation的效能有著非常詳盡的梳理和剖析。
  2. Autolayout佈局效能瓶頸,約束計算時間會隨著數量呈指數級增長,並且必須在主執行緒執行。具體分析可以參考這篇文章:floriankugler.com/2013/04/22/…。這也是為何ASDK拋棄了Autolayout而設計了自己的佈局系統的重要原因之一(github.com/facebook/As…)。Autolayout在單個View開發時能帶來很多便利,而在一些需要高效能的場景下需要謹慎使用。
  3. 儘管從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),那就意味著下一幀無法及時得到處理,引起丟幀。

AsyncDisplayKit介紹(一)原理和思路

圖片擷取自wwdc2016 session219

如何能將主執行緒的壓力盡可能減輕成為優化的首要目標。

對列表滾動卡頓的常用解決方案有(推薦Yaoyuan的部落格,有比較深入的介紹:
blog.ibireme.com/2015/11/12/…):

  1. 針對Autolayout效能優化:提前計算並快取cell的layout:
    github.com/forkingdog/…
  2. 省去中間滑動過程中的計算,直接計算目標區域cell:
    github.com/johnil/VVeb…
  3. 棄用Autolayout,採用手動佈局計算。這樣雖然可以換來最高的效能,但是代價是編寫和維護的不便,對於經常改動或者效能要求不高的場景並不一定值得。
  4. 自行非同步渲染Layer,如:github.com/ibireme/YYA…
  5. 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最主要集中於這三點進行優化):

  1. 渲染,對於大量圖片,或者大量文字(尤其是CJK字元)混合在一起時。而文字區域的大小和佈局,恰恰依賴著渲染的結果。ASDK儘可能後臺執行緒進行渲染,完成後再同步回主執行緒相應的UIView。
  2. 佈局。ASDK完全棄用了Autolayout,另闢蹊徑實現了自己的佈局和快取機制。關於佈局的問題會在下一篇講到。
  3. 系統objects的建立與銷燬。由於UIKit封裝了CALayer以支援觸控等顯示以外的操作,耗時也相應增加。而這些同樣也需要在主執行緒上操作。ASDK基於Node的設計,突破了UIKit執行緒的限制。

既然同步就意味著阻塞,那就非同步放到其他執行緒去做,在需要主執行緒時再同步回來。

AsyncDisplayKit介紹(一)原理和思路

我們知道對於一般UIView和CALayer來說,因為不是執行緒安全的,任何相關操作都需要在主執行緒進行。正如UIView可以彌補CALayer無法處理使用者事件的不足一樣,ASDK引入了Node的概念來解決UIView/CALayer只能在主執行緒上操作的限制(不由讓人想起『Abstract layer can solve many problems, except problem of having too many abstract layers.』)。

主要特點如下:

  1. 每個Node對應相應的UIView或者CALayer,從開發者的角度而言,只需要將初始化UIView的程式碼稍作修改,替換為建立ASDisplayNode即可。在不需要接受使用者操作的Node上可以開啟isLayerBacked,直接使用CALayer進一步降低開銷。根據Scott的研究UIView的開銷大約是CALayer的5倍。
  2. Node預設是非同步佈局/渲染,只有在需要將frame/contents等同步到UIView上才會回到主執行緒,使其空出更多的時間處理其他事件。
  3. ASDK只有在認為需要的時候才會非同步地為Node載入相應的View,因此建立Node的開銷變得非常低。同時Node是執行緒安全的,可以在任意queue上建立和設定屬性。
  4. ASDK不僅有與UIView對應的大部分控制元件(如ASButtonNode、ASTextNode、ASImageNode、ASTableNode等等),同時也bridge了大多數UIView的方法和屬性,可以非常方便的操作frame/backgroundColor/addSubnode等,因此一般情況下只要對Node進行操作,ASDK就會在適當的時候同步到其View。如果需要的話,當相應的View載入之後(或訪問node.view手動觸發載入),也可以通過node.view的方式直接訪問,回到我們熟悉的UIKit。
  5. 當實現自定義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多個不同場景下的示例專案。

一些細節

  1. 在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_transaction

  • Once 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的回撥中將之前非同步完成的工作同步到主執行緒中去。

  1. 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 backing

  • store. Defaults to NO.

  • If a node’s descendants are static (never animated or never change attributes
    after creation) then that node is a

  • good 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 rasterized

  • container. 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 subtree

  • which can help improve animation/scrolling/etc performance.

  • Rasterization does not currently support descendants with transform,
    sublayerTransform, or alpha. Those properties

  • will be ignored when rasterizing descendants.

  • Note: this has nothing to do with -[CALayer shouldRasterize], which doesn’t
    work with ASDisplayNode’s asynchronous

  • rendering model.

*/

當我們不需要分別關注單個CALayer,也不需要對他們進行操作時,就可以將所有的子node都合併到父node的backing
store一併繪製,從而達到節省記憶體和提高效能的目的。

注意事項

  1. ASDK不支援Storyboard和Autolayout,但是可以與使用Autolayout的view相容共存。同樣React native和Component
    Kit等其他Facebook出品的iOS庫也不支援Storyboard。
  2. 由於Node的非同步渲染,很有可能在其View到達螢幕之後,內容仍然在渲染過程中。此時需要額外考慮每個Node的placeholder狀態,使使用者不至於看到一片空白。
  3. 在使用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

相關文章