AsyncDisplayKit近一年的使用體會及疑難點

lzxys 7年前發布 | 23K 次閱讀 UIKit iOS開發 移動開發 flexbox

一個第三方庫能做到像新產品一樣,值得大家去寫寫使用體會的,并不多見, 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 = .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 。用 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 AsyncDisplayKit

let 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 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 ,當享受其一幀不掉如絲般柔滑的手感時, 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 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 的某個 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 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

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

靈活使用 flexGrow 和 spacer (占位 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])
  }
}

如果 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 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 自身大小的取值范圍。給定 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 ,當然還是僅限于用在比較復雜和動態的頁面中。

 

來自:http://qingmo.me/2017/07/21/asyndisplaykit/

 

 本文由用戶 lzxys 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!