[貝聊科技]AsyncDisplayKit近一年的使用體會及疑難點

歡迎關注我的微博以便交流:輕墨

一個第三方庫能做到像新產品一樣,值得大家去寫寫使用體會的,並不多見,AsyncDisplayKit卻完全可以,因為AsyncDisplayKit不僅僅是一個工具,它更像一個系統UI框架,改變整個編碼體驗。也正是這種極強的侵入性,導致不少聽過、star過,甚至下過demo跑過AsyncDisplayKit的你我,望而卻步,駐足觀望。但列表界面稍微複雜時,煩人的高度計算,因為性能不得不放棄Autolayout而選擇上古時代的frame layout,令人精疲力盡,這時AsyncDisplayKit總會不自然浮現眼前,讓你躍躍欲試。

去年10月份,我們入坑了。

當時還只是拿簡單的列表頁試水,基本上手後,去年底在稍微空閑的時候用AsyncDisplayKit重構了帖子詳情,今年三月份,又借著公司聊天增加群聊的契機,用AsyncDisplayKit重構整個聊天。林林總總,從簡單到複雜,踩過的坑大大小小,將近一年的時光轉眼飛逝,可以寫寫總結了。

學習曲線

先說說學習曲線,這是大家都比較關心的問題。

跟大多人一樣,一開始我以為AsyncDisplayKit會像Rxswift等MVVM框架一樣,有著陡峭的學習曲線。但事實上,AsyncDisplayKit的學習曲線還算平滑。

主要是因為AsyncDisplayKit只是對UIKit的再一次封裝,基本沿用了UIKit的API設計,大部分情況下,只是將view改成node,UI前綴改為AS,寫著寫著,恍惚間,你以為自己還是在寫UIKit呢。

比如ASDisplayNode與UIView:

let nodeA = ASDisplayNode()let nodeB = ASDisplayNode()let nodeC = ASDisplayNode()nodeA.addSubnode(nodeB)nodeA.addSubnode(nodeC)nodeA.backgroundColor = .rednodeA.frame = CGRect(x: 0, y: 0, width: 100, height: 100)nodeC.removeFromSupernode()let viewA = UIView()let viewB = UIView()let viewC = UIView()viewA.addSubview(viewB)viewA.addSubview(viewC)viewA.backgroundColor = .redviewA.frame = CGRect(x: 0, y: 0, width: 100, height: 100)viewC.removeFromSuperview()

相信你看兩眼也就摸出門道了,大部分API一模一樣。

真正發生翻天覆地變化的是布局方式,AsyncDisplayKit用的是flexbox布局,UIView使用的是Autolayout。用AsyncDisplayKit的flexbox布局替代Autolayout布局,完全不亞於用Autolayout替換frame布局的蛻變,需要比較大的觀念轉變。

但flexbox布局被提出已久,且其本身直觀簡單,較容易上手,學習曲線只是略陡峭。

集中精力,整體上兩天即可上手,無須擔心學習曲線問題。

這裡有一個學習AsyncDisplayKit布局的小遊戲,簡單有趣,可以一玩。

體會

當過了上手的艱難階段後,才是真正開始體會AsyncDisplayKit的時候。用了將近一年,有幾點AsyncDisplayKit的優勢相當明顯:

1)cell中再也不用算高度和位置等frame信息了

這是非常非常非常非常誘人的,當cell中有動態文本時,文本的高度計算很費神,計算完,還得緩存,如果再加上其他動態內容,比如有時候沒圖片,那frame算起來,簡直讓人想哭,而如果用AsyncDisplayKit,所有的height、frame計算都煙消雲散,甚至都不知道frame這個東西存在過。

2)一幀不掉

平時界面稍微動態點,元素稍微多點,Autolayout的性能就不堪重用,而上古時代的frame布局在高效緩存的基礎上確實可以做到高性能,但frame緩存的維護和計算都不是一般的複雜,而AsyncDisplayKit卻能在保持簡介布局的同時,做到一幀不掉,這是多麼的讓人感動!

3)更優雅的架構設計

前兩點好處是用AsyncDisplayKit最直接最容易被感受到的,其實,當深入使用時,你會發現,AsyncDisplayKit還會給程序架構設計帶來一些改變,會使原本複雜的架構變得更簡單,更優雅,更靈活,更容易維護,更容易擴展,也會使整個代碼更容易理解,而這個影響是深遠的,畢竟代碼是寫給別人看的。

但AsyncDisplayKit有一個極其著名的問題,閃爍。

當我們開始試水使用AsyncDisplayKit時,只要簡單reload一下TableNode,那閃爍,眼睛都瞎了。後來查了官方的issue,才發現很多人都提了這個問題,但官方也沒給出什麼優雅的解決方案。要知道,閃爍是非常影響用戶體驗的。如果非要在不閃爍和帶閃爍的AsyncDisplayKit中選擇,我會毫不猶豫的選擇不閃爍,而放棄使用AsyncDisplayKit。但現在已經不存在這個選擇了,因為經過AsyncDisplayKit的多次迭代努力加上一些小技巧,AsyncDisplayKit的非同步閃爍已經被優雅的解決了。

但AsyncDisplayKit不宜廣泛使用,那些高度固定、UI簡單的用UIKit就好了,畢竟AsyncDisplayKit並不像UIKit,人人都會。但如果內容和高度複雜又很動態,強烈推薦AsyncDisplayKit,它會簡化太多東西。

疑難點

一年的AsyncDisplayKit使用經驗,踩過了不少坑,遇到了不少值得注意的問題,一併列在這裡,以供參考。

ASNetworkImageNode的緩存

ASNetworkImageNode是對UIImageView需要從網路載入圖片這一使用場景的封裝,省去了YYWebImage或者SDWebImage等第三方庫的引入,只需要設置URL即可實現網路圖片的自動載入。

import AsyncDisplayKitlet avatarImageNode = ASNetworkImageNode()avatarImageNode.url = URL(string: "http://shellhue.github.io/images/log.png")

這非常省事便捷,但ASNetworkImageNode默認用的緩存機制和圖片下載器是PinRemoteImage,為了使用我們自己的緩存機制和圖片下載器,需要實現ASImageCacheProtocol圖片緩存協議和 ASImageDownloaderProtocol圖片下載器協議兩個協議,然後初始化時,用ASNetworkImageNode的init(cache: ASImageCacheProtocol, downloader: ASImageDownloaderProtocol)初始化方法,傳入對應的類,方便其間,一般會自定義一個初始化靜態方法。我們公司緩存機制和圖片下載器都是用的YYWebImage,橋接代碼如下。

import YYWebImageimport AsyncDisplayKitextension ASNetworkImageNode { static func imageNode() -> ASNetworkImageNode { let manager = YYWebImageManager.shared() return ASNetworkImageNode(cache: manager, downloader: manager) }}extension YYWebImageManager: ASImageCacheProtocol, ASImageDownloaderProtocol { public func downloadImage(with URL: URL, callbackQueue: DispatchQueue, downloadProgress: AsyncDisplayKit.ASImageDownloaderProgress?, completion: @escaping AsyncDisplayKit.ASImageDownloaderCompletion) -> Any? { weak var operation: YYWebImageOperation? operation = requestImage(with: URL, options: .setImageWithFadeAnimation, progress: { (received, expected) -> Void in callbackQueue.async(execute: { let progress = expected == 0 ? 0 : received / expected downloadProgress?(CGFloat(progress)) }) }, transform: nil, completion: { (image, url, from, state, error) in completion(image, error, operation) }) return operation } public func cancelImageDownload(forIdentifier downloadIdentifier: Any) { guard let operation = downloadIdentifier as? YYWebImageOperation else { return } operation.cancel() } public func cachedImage(with URL: URL, callbackQueue: DispatchQueue, completion: @escaping AsyncDisplayKit.ASImageCacherCompletion) { cache?.getImageForKey(cacheKey(for: URL), with: .all, with: { (image, cacheType) in callbackQueue.async { completion(image) } }) }}

閃爍

初次使用AsyncDisplayKit,當享受其一幀不掉如絲般柔滑的手感時,ASTableNode和ASCollectionNode刷新時的閃爍一定讓你幾度崩潰,到AsyncDisplayKit的github上搜索閃爍相關issue,會出來100多個問題。閃爍是AsyncDisplayKit與生俱來的問題,聞名遐邇,而閃爍的體驗非常糟糕。幸運的是,幾經探索,AsyncDisplayKit的閃爍問題已經完美解決,這個完美指的是一幀不掉的同時沒有任何閃爍,同時也沒增加代碼的複雜度。

閃爍可以分為四類,

1)ASNetworkImageNode reload時的閃爍

當ASCellNode中包含ASNetworkImageNode,則這個cell reload時,ASNetworkImageNode會非同步從本地緩存或者網路請求圖片,請求到圖片後再設置ASNetworkImageNode展示圖片,但在非同步過程中,ASNetworkImageNode會先展示PlaceholderImage,從PlaceholderImage--->fetched image的展示替換導致閃爍發生,即使整個cell的數據沒有任何變化,只是簡單的reload,ASNetworkImageNode的圖片載入邏輯依然不變,因此仍然會閃爍,這顯著區別於UIImageView,因為YYWebImage或者SDWebImage對UIImageView的image設置邏輯是,先同步檢查有無內存緩存,有的話直接顯示,沒有的話再先顯示PlaceholderImage,等待載入完成後再顯示載入的圖片,也即邏輯是memory cached image--->PlaceholderImage--->fetched image的邏輯,刷新當前cell時,如果數據沒有變化memory cached image一般都會有,因此不會閃爍。

AsyncDisplayKit官方給的修復思路是:

import AsyncDisplayKitlet node = ASNetworkImageNode()node.placeholderColor = UIColor.rednode.placeholderFadeDuration = 3

這樣修改後,確實沒有閃爍了,但這只是將PlaceholderImage--->fetched image圖片替換導致的閃爍拉長到3秒而已,自欺欺人,並沒有修復。

既然閃爍是reload時,沒有事先同步檢查有無緩存導致的,繼承一個ASNetworkImageNode的子類,複寫url設置邏輯:

import AsyncDisplayKitclass NetworkImageNode: ASNetworkImageNode { override var url: URL? { didSet { if let u = url, let image = UIImage.cachedImage(with: u) else { self.image = image placeholderEnabled = false } } }}

按道理不會閃爍了,但事實上仍然會,只要是個ASNetworkImageNode,無論怎麼設置,都會閃,這與官方的API說明嚴重不符,很無語。迫不得已之下,當有緩存時,直接用ASImageNode替換ASNetworkImageNode。

import AsyncDisplayKitclass NetworkImageNode: ASDisplayNode { private var networkImageNode = ASNetworkImageNode.imageNode() private var imageNode = ASImageNode() var placeholderColor: UIColor? { didSet { networkImageNode.placeholderColor = placeholderColor } } var image: UIImage? { didSet { networkImageNode.image = image } } override var placeholderFadeDuration: TimeInterval { didSet { networkImageNode.placeholderFadeDuration = placeholderFadeDuration } } var url: URL? { didSet { guard let u = url, let image = UIImage.cachedImage(with: u) else { networkImageNode.url = url return } imageNode.image = image } } override init() { super.init() addSubnode(networkImageNode) addSubnode(imageNode) } override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { return ASInsetLayoutSpec(insets: .zero, child: networkImageNode.url == nil ? imageNode : networkImageNode) } func addTarget(_ target: Any?, action: Selector, forControlEvents controlEvents: ASControlNodeEvent) { networkImageNode.addTarget(target, action: action, forControlEvents: controlEvents) imageNode.addTarget(target, action: action, forControlEvents: controlEvents) }}

使用時將NetworkImageNode當成ASNetworkImageNode使用即可。

2)reload 單個cell時的閃爍

當reload ASTableNode或者ASCollectionNode的某個indexPath的cell時,也會閃爍。原因和ASNetworkImageNode很像,都是非同步惹的禍。當非同步計算cell的布局時,cell使用placeholder佔位(通常是白圖),布局完成時,才用渲染好的內容填充cell,placeholder到渲染好的內容切換引起閃爍。UITableViewCell因為都是同步,不存在占點陣圖的情況,因此也就不會閃。

先看官方的修改方案,

func tableNode(_ tableNode: ASTableNode, nodeForRowAt indexPath: IndexPath) -> ASCellNode { let cell = ASCellNode() ... // 其他代碼 cell.neverShowPlaceholders = true return cell}

這個方案非常有效,因為設置cell.neverShowPlaceholders = true,會讓cell從非同步狀態衰退回同步狀態,若reload某個indexPath的cell,在渲染完成之前,主線程是卡死的,這與UITableView的機制一樣,但速度會比UITableView快很多,因為UITableView的布局計算、資源解壓、視圖合成等都是在主線程進行,而ASTableNode則是多個線程並發進行,何況布局等還有緩存。所以,一般也沒有問題,貝聊的聊天界面只是簡單這樣設置後,就不閃了,而且一幀不掉。但當頁面布局較為複雜時,滑動時的卡頓掉幀就變的肉眼可見。

這時,可以設置ASTableNode的leadingScreensForBatching減緩卡頓

override func viewDidLoad() { super.viewDidLoad() ... // 其他代碼 tableNode.leadingScreensForBatching = 4}

一般設置tableNode.leadingScreensForBatching = 4即提前計算四個屏幕的內容時,掉幀就很不明顯了,典型的空間換時間。但仍不完美,仍然會掉幀,而我們期望的是一幀不掉,如絲般順滑。這不難,基於上面不閃的方案,刷點小聰明就能解決。

class ViewController: ASViewController { ... // 其他代碼 private var indexPathesToBeReloaded: [IndexPath] = [] func tableNode(_ tableNode: ASTableNode, nodeForRowAt indexPath: IndexPath) -> ASCellNode { let cell = ASCellNode() ... // 其他代碼 cell.neverShowPlaceholders = false if indexPathesToBeReloaded.contains(indexPath) { let oldCellNode = tableNode.nodeForRow(at: indexPath) cell.neverShowPlaceholders = true oldCellNode?.neverShowPlaceholders = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { cell.neverShowPlaceholders = false if let indexP = self.indexPathesToBeReloaded.index(of: indexPath) { self.indexPathesToBeReloaded.remove(at: indexP) } }) } return cell } func reloadActionHappensHere() { ... // 其他代碼 let indexPath = ... // 需要roload的indexPath indexPathesToBeReloaded.append(indexPath) tableNode.reloadRows(at: [indexPath], with: .none) }}

關鍵代碼是,

if indexPathesToBeReloaded.contains(indexPath) { let oldCellNode = tableNode.nodeForRow(at: indexPath) cell.neverShowPlaceholders = true oldCellNode?.neverShowPlaceholders = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { cell.neverShowPlaceholders = false if let indexP = self.indexPathesToBeReloaded.index(of: indexPath) { self.indexPathesToBeReloaded.remove(at: indexP) } })}

即,檢查當前的indexPath是否被標記,如果是,則先設置cell.neverShowPlaceholders = true,等待reload完成(一幀是1/60秒,這裡等待0.5秒,足夠渲染了),將cell.neverShowPlaceholders = false。這樣reload時既不會閃爍,也不會影響滑動時的非同步繪製,因此一幀不掉。

這完全是耍小聰明的做法,但確實非常有效。

3)reloadData時的閃爍

在下拉刷新後,列表經常需要重新刷新,即調用ASTableNode或者ASCollectionNode的reloadData方法,但會閃,而且很明顯。有了單個cell reload時閃爍的解決方案後,此類閃爍解決起來,就很簡單了。

func reloadDataActionHappensHere() { ... // 其他代碼 let count = tableNode.dataSource?.tableNode?(tableNode, numberOfRowsInSection: 0) ?? 0 if count > 2 { // 將肉眼可見的cell添加進indexPathesToBeReloaded中 indexPathesToBeReloaded.append(IndexPath(row: 0, section: 0)) indexPathesToBeReloaded.append(IndexPath(row: 1, section: 0)) indexPathesToBeReloaded.append(IndexPath(row: 2, section: 0)) } tableNode.reloadData() ... // 其他代碼}

將肉眼可見的cell添加進indexPathesToBeReloaded中即可。

4)insertItems時更改ASCollectionNode的contentOffset引起的閃爍

我們公司的聊天界面是用AsyncDisplayKit寫的,當下拉載入更多新消息時,為保持載入後當前消息的位置不變,需要在collectionNode.insertItems(at: indexPaths)完成後,復原collectionNode.view.contentOffset,代碼如下:

func insertMessagesToTop(indexPathes: [IndexPath]) { let originalContentSizeHeight = collectionNode.view.contentSize.height let originalContentOffsetY = collectionNode.view.contentOffset.y let heightFromOriginToContentBottom = originalContentSizeHeight - originalContentOffsetY let heightFromOriginToContentTop = originalContentOffsetY collectionNode.performBatch(animated: false, updates: { self.collectionNode.insertItems(at: indexPaths) }) { (finished) in let contentSizeHeight = self.collectionNode.view.contentSize.height self.collectionNode.view.contentOffset = CGPointMake(0, isLoadingMore ? (contentSizeHeight - heightFromOriginToContentBottom) : heightFromOriginToContentTop) }}

遺憾的是,會閃爍。起初以為是AsyncDisplayKit非同步繪製導致的閃爍,一度還想放棄AsyncDisplayKit,用UITableView重寫一遍,幸運的是,當時項目工期太緊,沒有時間重寫,也沒時間仔細排查,直接帶問題上線了。

最近閑暇,經仔細排查,方知不是AsyncDisplayKit的鍋,但也比較難修,有一定的參考價值,因此一併列在這裡。

閃爍的原因是,collectionNode insertItems成功後會先繪製contentOffset為CGPoint(x: 0, y: 0)時的一幀畫面,無動畫時這一幀畫面立即顯示,然後調用成功回調,回調中復原了collectionNode.view.contentOffset,下一幀就顯示復原了位置的畫面,前後有變化因此閃爍。這是做消息類APP一併會遇到的bug,google一下,主要有兩種解決方案,

第一種,通過仿射變換倒置ASCollectionNode,這樣下拉載入更多,就變成正常列表的上拉載入更多,也就無需移動contentOffset。ASCollectionNode還特意設置了個屬性inverted,方便大家開發。然而這種方案換湯不換藥,當收到新消息,同時正在查看歷史消息,依然需要插入新消息並復原contentOffset,閃爍依然在其他情形下發生。

第二種,集成一個UICollectionViewFlowLayout,重寫prepare()方法,做相應處理即可。這個方案完美,簡介優雅。子類化的CollectionFlowLayout如下:

class CollectionFlowLayout: UICollectionViewFlowLayout { var isInsertingToTop = false override func prepare() { super.prepare() guard let collectionView = collectionView else { return } if !isInsertingToTop { return } let oldSize = collectionView.contentSize let newSize = collectionViewContentSize let contentOffsetY = collectionView.contentOffset.y + newSize.height - oldSize.height collectionView.setContentOffset(CGPoint(x: collectionView.contentOffset.x, y: contentOffsetY), animated: false) }}

當需要insertItems並且保持位置時,將CollectionFlowLayout的isInsertingToTop設置為true即可,完成後再設置為false。如下,

class MessagesViewController: ASViewController { ... // 其他代碼 var collectionNode: ASCollectionNode! var flowLayout: CollectionFlowLayout! override func viewDidLoad() { super.viewDidLoad() flowLayout = CollectionFlowLayout() collectionNode = ASCollectionNode(collectionViewLayout: flowLayout) ... // 其他代碼 } ... // 其他代碼 func insertMessagesToTop(indexPathes: [IndexPath]) { flowLayout.isInsertingToTop = true collectionNode.performBatch(animated: false, updates: { self.collectionNode.insertItems(at: indexPaths) }) { (finished) in self.flowLayout.isInsertingToTop = false } } ... // 其他代碼}

布局

AsyncDisplayKit採用的是flexbox的布局思想,非常高效直觀簡潔,但畢竟迥異於AutoLayout和frame layout的布局風格,咋一上手,很不習慣,有些小技巧還是需要慢慢積累,有些概念也需要逐漸熟悉深入,下面列舉幾個筆者覺得比較重要的概念

1)設置任意間距

AutoLayout實現任意間距,比較容易直觀,因為AutoLayout的約束,本來就是我的邊離你的邊有多遠的概念,而AsyncDisplayKit並沒有,AsyncDisplayKit裡面的概念是,我自己的前面有多少空白距離,我自己的後面有多少空白距離,更強調自己。假如有三個元素,怎麼約束它們之間的間距?

AutoLayout是這樣的:

import Masonryclass SomeView: UIView { override init() { super.init() let viewA = UIView() let viewB = UIView() let viewC = UIView() addSubview(viewA) addSubview(viewB) addSubview(viewC) viewB.snp.makeConstraints { (make) in make.left.equalTo(viewA.snp.right).offset(15) } viewC.snp.makeConstraints { (make) in make.left.equalTo(viewB.snp.right).offset(5) } }}

而AsyncDisplayKit是這樣的:

import AsyncDisplayKitclass SomeNode: ASDisplayNode { let nodeA = ASDisplayNode() let nodeB = ASDisplayNode() let nodeC = ASDisplayNode() override init() { super.init() addSubnode(nodeA) addSubnode(nodeB) addSubnode(nodeC) } override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { nodeB.style.spaceBefore = 15 nodeC.stlye.spaceBefore = 5 return ASStackLayoutSpec(direction: .horizontal, spacing: 0, justifyContent: .start, alignItems: .start, children: [nodeA, nodeB, nodeC]) }}

如果是拿ASStackLayoutSpec布局,元素之間的任意間距一般是通過元素自己的spaceBefore或者spaceBefore style實現,這是自我包裹性,更容易理解,如果不是拿ASStackLayoutSpec布局,可以將某個元素包裹成ASInsetsLayoutSpec,再設置UIEdgesInsets,保持自己的四周任意邊距。

能任意設置間距是自由布局的基礎。

2)flexGrow和flexShrink

flexGrow和flexShrink是相當重要的概念,flexGrow是指當有多餘空間時,拉伸誰以及相應的拉伸比例(當有多個元素設置了flexGrow時),flexShrink相反,是指當空間不夠時,壓縮誰及相應的壓縮比例(當有多個元素設置了flexShrink時)。

靈活使用flexGrow和spacer(佔位ASLayoutSpec)可以實現很多效果,比如等間距,

實現代碼如下,

import AsyncDisplayKitclass ContainerNode: ASDisplayNode { let nodeA = ASDisplayNode() let nodeB = ASDisplayNode() override init() { super.init() addSubnode(nodeA) addSubnode(nodeB) } override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { let spacer1 = ASLayoutSpec() let spacer2 = ASLayoutSpec() let spacer3 = ASLayoutSpec() spacer1.stlye.flexGrow = 1 spacer2.stlye.flexGrow = 1 spacer3.stlye.flexGrow = 1 return ASStackLayoutSpec(direction: .horizontal, spacing: 0, justifyContent: .start, alignItems: .start, children: [spacer1, nodeA,spacer2, nodeB, spacer3]) }}

如果spacer的flexGrow不同就可以實現指定比例的布局,再結合width樣式,輕鬆實現以下布局

布局代碼如下,

override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { let spacer1 = ASLayoutSpec() let spacer2 = ASLayoutSpec() let spacer3 = ASLayoutSpec() spacer1.stlye.flexGrow = 2 spacer2.stlye.width = ASDimensionMake(100) spacer3.stlye.flexGrow = 1 return ASStackLayoutSpec(direction: .horizontal, spacing: 0, justifyContent: .start, alignItems: .start, children: [spacer1, nodeA,spacer2, nodeB, spacer3])}

相同的布局如果用Autolayout,麻煩去了。

3)constrainedSize的理解

constrainedSize是指某個node的大小取值範圍,有minSize和maxSize兩個屬性。比如下圖的布局:

import AsyncDisplayKitclass ContainerNode: ASDisplayNode { let nodeA = ASDisplayNode() let nodeB = ASDisplayNode() override init() { super.init() addSubnode(nodeA) addSubnode(nodeB) nodeA.style.preferredSize = CGSize(width: 100, height: 100) } override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { nodeB.style.flexShrink = 1 nodeB.style.flexGrow = 1 let stack = ASStackLayoutSpec(direction: .horizontal, spacing: e, justifyContent: .start, alignItems: .start, children: [nodeA, nodeB]) return ASInsetLayoutSpec(insets: UIEdgeInsetsMake(a, b, c, d), child: stack) }}

其中方法override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec中的constrainedSize所指是ContainerNode自身大小的取值範圍。給定constrainedSize,AsyncDisplayKit會根據ContainerNode在layoutSpecThatFits(_:)中施加在nodeA、nodeB的布局規則和nodeA、nodeB自身屬性計算nodeA、nodeB的constrainedSize。

假如constrainedSize的minSize是CGSize(width: 0, height: 0),maxSize為CGSize(width: 375, height: Inf+)(Inf+為正無限大),則:

1)根據布局規則和nodeA自身樣式屬性maxWidth、minWidth、width、height、preferredSize,可計算出nodeA的constrainedSize的minSize和maxSize均為其preferredSize即CGSize(width: 100, height: 100),因為布局規則為水平向的ASStackLayout,當空間富餘或者空間不足時,nodeA即不壓縮又不拉伸,所以會取其指定的preferredSize。

2)根據布局規則和nodeB自身樣式屬性maxWidth、minWidth、width、height、preferredSize,可以計算出其constrainedSize的minSize是CGSize(width: 0, height: 0),maxSize為CGSize(width: 375 - 100 - b - e - d, height: Inf+),因為nodeB的flexShrink和flexGrow均為1,也即當空間富餘或者空間不足時,nodeB添滿富餘空間或壓縮至空間夠為止。

如果不指定nodeB的flexShrink和flexGrow,那麼當空間富餘或者空間不足時,AsyncDisplayKit就不知道壓縮和拉伸哪一個布局元素,則nodeB的constrainedSize的maxSize就變為CGSize(width: Inf+, height: Inf+),即完全無大小限制,可想而知,nodeB的子node的布局將完全不對。這也說明另外一個問題,node的constrainedSize並不是一定大於其子node的constrainedSize。

理解constrainedSize的計算,才能熟練利用node的樣式maxWidth、minWidth、width、height、preferredSize、flexShrink和flexGrow進行布局。如果發現布局結果不對,而對應node的布局代碼確是正確無誤,一般極有可能是因為此node的父布局元素不正確。

動畫

因為AsyncDisplayKit的布局方式有兩種,frame布局和flexbox式的布局,相應的動畫方式也有兩種

1)frame布局

如果採用的是frame布局,動畫跟普通的UIView相同

class ViewController: ASViewController { let nodeA = ASDisplayNode() override func viewDidLoad() { super.viewDidLoad() nodeA.frame = CGRect(x: 0, y: 0, width: 100, height: 100) ... // 其他代碼 } ... // 其他代碼 func animateNodeA() { UIView.animate(withDuration: 0.5) { let newFrame = ... // 新的frame nodeA.frame = newFrame } }}

不要覺得用了AsyncDisplayKit就告別了frame布局,ViewController中主要元素個數很少,布局簡單,因此,一般也還是採用frame layout,如果只是做一些簡單的動畫,直接採用UIView的動畫API即可

2)flexbox式的布局

這種布局方式,是在某個子node中常用的,如果node內部布局發生了變化,又需要做動畫時,就需要複寫AsyncDisplayKit的動畫API,並基於提供的動畫上下文類context,做動畫:

class SomeNode: ASDisplayNode { let nodeA = ASDisplayNode() override func animateLayoutTransition(_ context: ASContextTransitioning) { // 利用context可以獲取animate前後布局信息 UIView.animate(withDuration: 0.5) { // 不使用系統默認的fade動畫,採用自定義動畫 let newFrame = ... // 新的frame nodeA.frame = newFrame } }}

系統默認的動畫是漸隱漸顯,可以獲取animate前後布局信息,比如某個子node兩種布局中的frame,然後再自定義動畫類型。如果想觸發動畫,主動調用SomeNode的觸發方法transitionLayout(withAnimation:shouldMeasureAsync:measurementCompletion:)即可。

內存泄漏

為了方便將一個UIView或者CALayer轉化為一個ASDisplayNode,系統提供了用block初始化ASDisplayNode的簡便方法:

public convenience init(viewBlock: @escaping AsyncDisplayKit.ASDisplayNodeViewBlock)public convenience init(viewBlock: @escaping AsyncDisplayKit.ASDisplayNodeViewBlock, didLoad didLoadBlock: AsyncDisplayKit.ASDisplayNodeDidLoadBlock? = nil)public convenience init(layerBlock: @escaping AsyncDisplayKit.ASDisplayNodeLayerBlock)public convenience init(layerBlock: @escaping AsyncDisplayKit.ASDisplayNodeLayerBlock, didLoad didLoadBlock: AsyncDisplayKit.ASDisplayNodeDidLoadBlock? = nil)

需要注意的是所傳入的block會被要創建的node持有。如果block中反過來持有了這個node的持有者,則會產生循環引用,導致內存泄漏:

class SomeNode { var nodeA: ASDisplayNode! let color = UIColor.red override init() { super.init() nodeA = ASDisplayNode { let view = UIView() view.backgroundColor = self.color // 內存泄漏 return view } }}

子線程崩潰

AsyncDisplayKit的性能優勢來源於非同步繪製,非同步的意思是有時候node會在子線程創建,如果繼承了一個ASDisplayNode,一不小心在初始化時調用了UIKit的相關方法,則會出現子線程崩潰。比如以下node,

class SomeNode { let iconImageNode: ASDisplayNode let color = UIColor.red override init() { iconImageNode = ASImageNode() iconImageNode.image = UIImage(named: "iconName") // 需注意SomeNode有時會在子線程初始化,而UIImage(named:)並不是線程安全 super.init() }}

但在node初始化時調用UIImage(named:)創建圖片是不可避免的,用methodSwizzle將UIImage(named:)置換成安全的即可。

其實在子線程初始化node並不多見,一般都在主線程。

總結

一年的實踐下來,閃爍是AsyncDisplayKit遇到的最大的問題,修復起來也頗為費神。其他bug,有時雖然很讓人頭疼,但由於AsyncDisplayKit是對UIKit的再封裝,實在不行,仍然可以越過AsyncDisplayKit用UIKit的方法修復。

學習曲線也不算很陡峭。

考慮到AsyncDisplayKit的種種好處,非常推薦AsyncDisplayKit,當然還是僅限於用在比較複雜和動態的頁面中。

個人博客原文鏈接:qingmo.me/

歡迎關注我的微博以便交流:輕墨


推薦閱讀:

破一下 Apple 與 PRC 領土有關的兩條謠言
你對 OS X 10.11 / iOS 9 的預設中文介面顯示字體有哪些期待?
iOS 10.2.1正式發布 修複數十個安全漏洞
聊聊Apple對使用JSPatch的警告

TAG:iOS | iOS开发 | iOS应用 |