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

貝聊科技發表於2017-08-07

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

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

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

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

學習曲線

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

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

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

比如ASDisplayNodeUIView

let nodeA = ASDisplayNode()
let nodeB = ASDisplayNode()
let nodeC = ASDisplayNode()
nodeA.addSubnode(nodeB)
nodeA.addSubnode(nodeC)
nodeA.backgroundColor = .red
nodeA.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 = .red
viewA.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
viewC.removeFromSuperview()複製程式碼

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

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

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

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

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

體會

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

1)cell中再也不用算高度和位置等frame資訊了
這是非常非常非常非常誘人的,當cell中有動態文字時,文字的高度計算很費神,計算完,還得快取,如果再加上其他動態內容,比如有時候沒圖片,那frame算起來,簡直讓人想哭,而如果用AsyncDisplayKit,所有的heightframe計算都煙消雲散,甚至都不知道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 AsyncDisplayKit

let avatarImageNode = ASNetworkImageNode()
avatarImageNode.url = URL(string: "http://shellhue.github.io/images/log.png")複製程式碼

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

import YYWebImage
import AsyncDisplayKit

extension 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,當享受其一幀不掉如絲般柔滑的手感時,ASTableNodeASCollectionNode重新整理時的閃爍一定讓你幾度崩潰,到AsyncDisplayKitgithub上搜尋閃爍相關issue,會出來100多個問題。閃爍是AsyncDisplayKit與生俱來的問題,聞名遐邇,而閃爍的體驗非常糟糕。幸運的是,幾經探索,AsyncDisplayKit的閃爍問題已經完美解決,這個完美指的是一幀不掉的同時沒有任何閃爍,同時也沒增加程式碼的複雜度。

閃爍可以分為四類,

1)ASNetworkImageNode reload時的閃爍

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

AsyncDisplayKit官方給的修復思路是:

import AsyncDisplayKit

let node = ASNetworkImageNode()
node.placeholderColor = UIColor.red
node.placeholderFadeDuration = 3複製程式碼

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

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

import AsyncDisplayKit

class 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 AsyncDisplayKit

class 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的某個indexPathcell時,也會閃爍。原因和ASNetworkImageNode很像,都是非同步惹的禍。當非同步計算cell的佈局時,cell使用placeholder佔位(通常是白圖),佈局完成時,才用渲染好的內容填充cellplaceholder到渲染好的內容切換引起閃爍。UITableViewCell因為都是同步,不存在佔點陣圖的情況,因此也就不會閃。

先看官方的修改方案,

func tableNode(_ tableNode: ASTableNode, nodeForRowAt indexPath: IndexPath) -> ASCellNode {
  let cell = ASCellNode()
  ... // 其他程式碼

  cell.neverShowPlaceholders = true

  return cell
}複製程式碼

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

這時,可以設定ASTableNodeleadingScreensForBatching減緩卡頓

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或者ASCollectionNodereloadData方法,但會閃,而且很明顯。有了單個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成功後會先繪製contentOffsetCGPoint(x: 0, y: 0)時的一幀畫面,無動畫時這一幀畫面立即顯示,然後呼叫成功回撥,回撥中復原了collectionNode.view.contentOffset,下一幀就顯示覆原了位置的畫面,前後有變化因此閃爍。這是做訊息類APP一併會遇到的bug,google一下,主要有兩種解決方案,

第一種,通過仿射變換倒置ASCollectionNode,這樣下拉載入更多,就變成正常列表的上拉載入更多,也就無需移動contentOffsetASCollectionNode還特意設定了個屬性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並且保持位置時,將CollectionFlowLayoutisInsertingToTop設定為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的佈局思想,非常高效直觀簡潔,但畢竟迥異於AutoLayoutframe layout的佈局風格,咋一上手,很不習慣,有些小技巧還是需要慢慢積累,有些概念也需要逐漸熟悉深入,下面列舉幾個筆者覺得比較重要的概念

1)設定任意間距

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

AutoLayout是這樣的:

import Masonry
class 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 AsyncDisplayKit
class 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

flexGrowflexShrink是相當重要的概念,flexGrow是指當有多餘空間時,拉伸誰以及相應的拉伸比例(當有多個元素設定了flexGrow時),flexShrink相反,是指當空間不夠時,壓縮誰及相應的壓縮比例(當有多個元素設定了flexShrink時)。
靈活使用flexGrowspacer(佔位ASLayoutSpec)可以實現很多效果,比如等間距,

實現程式碼如下,

import AsyncDisplayKit
class 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])
  }
}複製程式碼

如果spacerflexGrow不同就可以實現指定比例的佈局,再結合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的大小取值範圍,有minSizemaxSize兩個屬性。比如下圖的佈局:

import AsyncDisplayKit
class 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自身大小的取值範圍。給定constrainedSizeAsyncDisplayKit會根據ContainerNodelayoutSpecThatFits(_:)中施加在nodeA、nodeB的佈局規則和nodeA、nodeB自身屬性計算nodeA、nodeBconstrainedSize

假如constrainedSizeminSizeCGSize(width: 0, height: 0)maxSizeCGSize(width: 375, height: Inf+)(Inf+為正無限大),則:

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

2)根據佈局規則和nodeB自身樣式屬性maxWidthminWidthwidthheightpreferredSize,可以計算出其constrainedSizeminSizeCGSize(width: 0, height: 0)maxSizeCGSize(width: 375 - 100 - b - e - d, height: Inf+),因為nodeBflexShrinkflexGrow均為1,也即當空間富餘或者空間不足時,nodeB添滿富餘空間或壓縮至空間夠為止。

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

理解constrainedSize的計算,才能熟練利用node的樣式maxWidthminWidthwidthheightpreferredSizeflexShrinkflexGrow進行佈局。如果發現佈局結果不對,而對應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:)建立圖片是不可避免的,用methodSwizzleUIImage(named:)置換成安全的即可。

其實在子執行緒初始化node並不多見,一般都在主執行緒。

總結

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

學習曲線也不算很陡峭。

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

個人部落格原文連結:qingmo.me/
歡迎關注我的微博以便交流:輕墨

相關文章