AsyncDisplayKit近一年的使用體會(huì)及疑難點(diǎn)

歡迎關(guān)注我的微博以便交流:輕墨

一個(gè)第三方庫(kù)能做到像新產(chǎn)品一樣,值得大家去寫(xiě)寫(xiě)使用體會(huì)的,并不多見(jiàn),AsyncDisplayKit卻完全可以,因?yàn)?code>AsyncDisplayKit不僅僅是一個(gè)工具,它更像一個(gè)系統(tǒng)UI框架,改變整個(gè)編碼體驗(yàn)。也正是這種極強(qiáng)的侵入性,導(dǎo)致不少聽(tīng)過(guò)、star過(guò),甚至下過(guò)demo跑過(guò)AsyncDisplayKit的你我,望而卻步,駐足觀望。但列表界面稍微復(fù)雜時(shí),煩人的高度計(jì)算,因?yàn)樾阅懿坏貌环艞?code>Autolayout而選擇上古時(shí)代的frame layout,令人精疲力盡,這時(shí)AsyncDisplayKit總會(huì)不自然浮現(xiàn)眼前,讓你躍躍欲試。

去年10月份,我們?nèi)肟恿恕?/p>

當(dāng)時(shí)還只是拿簡(jiǎn)單的列表頁(yè)試水,基本上手后,去年底在稍微空閑的時(shí)候用AsyncDisplayKit重構(gòu)了帖子詳情,今年三月份,又借著公司聊天增加群聊的契機(jī),用AsyncDisplayKit重構(gòu)整個(gè)聊天。林林總總,從簡(jiǎn)單到復(fù)雜,踩過(guò)的坑大大小小,將近一年的時(shí)光轉(zhuǎn)眼飛逝,可以寫(xiě)寫(xiě)總結(jié)了。

學(xué)習(xí)曲線

先說(shuō)說(shuō)學(xué)習(xí)曲線,這是大家都比較關(guān)心的問(wèn)題。

跟大多人一樣,一開(kāi)始我以為AsyncDisplayKit會(huì)像RxswiftMVVM框架一樣,有著陡峭的學(xué)習(xí)曲線。但事實(shí)上,AsyncDisplayKit的學(xué)習(xí)曲線還算平滑。

主要是因?yàn)?code>AsyncDisplayKit只是對(duì)UIKit的再一次封裝,基本沿用了UIKitAPI設(shè)計(jì),大部分情況下,只是將view改成node,UI前綴改為AS,寫(xiě)著寫(xiě)著,恍惚間,你以為自己還是在寫(xiě)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()

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

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

flexbox布局被提出已久,且其本身直觀簡(jiǎn)單,較容易上手,學(xué)習(xí)曲線只是略陡峭。

這里有一個(gè)學(xué)習(xí)AsyncDisplayKit布局的小游戲,簡(jiǎn)單有趣,可以一玩。

整體上兩天即可上手,無(wú)須擔(dān)心學(xué)習(xí)曲線問(wèn)題。

體會(huì)

當(dāng)過(guò)了上手的艱難階段后,才是真正開(kāi)始體會(huì)AsyncDisplayKit的時(shí)候。用了將近一年,有幾點(diǎn)AsyncDisplayKit的優(yōu)勢(shì)相當(dāng)明顯:

1)cell中再也不用算高度和位置等frame信息了
這是非常非常非常非常誘人的,當(dāng)cell中有動(dòng)態(tài)文本時(shí),文本的高度計(jì)算很費(fèi)神,計(jì)算完,還得緩存,如果再加上其他動(dòng)態(tài)內(nèi)容,比如有時(shí)候沒(méi)圖片,那frame算起來(lái),簡(jiǎn)直讓人想哭,而如果用AsyncDisplayKit,所有的height、frame計(jì)算都煙消云散,甚至都不知道frame這個(gè)東西存在過(guò),很酸爽。

2)一幀不掉
平時(shí)界面稍微動(dòng)態(tài)點(diǎn),元素稍微多點(diǎn),Autolayout的性能就不堪重用,而上古時(shí)代的frame布局在高效緩存的基礎(chǔ)上確實(shí)可以做到高性能,但frame緩存的維護(hù)和計(jì)算都不是一般的復(fù)雜,而AsyncDisplayKit卻能在保持簡(jiǎn)介布局的同時(shí),做到一幀不掉,這是多么的讓人感動(dòng)!

3)更優(yōu)雅的架構(gòu)設(shè)計(jì)
前兩點(diǎn)好處是用AsyncDisplayKit最直接最容易被感受到的,其實(shí),當(dāng)深入使用時(shí),你會(huì)發(fā)現(xiàn),AsyncDisplayKit還會(huì)給程序架構(gòu)設(shè)計(jì)帶來(lái)一些改變,會(huì)使原本復(fù)雜的架構(gòu)變得更簡(jiǎn)單,更優(yōu)雅,更靈活,更容易維護(hù),更容易擴(kuò)展,也會(huì)使整個(gè)代碼更容易理解,而這個(gè)影響是深遠(yuǎn)的,畢竟代碼是寫(xiě)給別人看的。

AsyncDisplayKit有一個(gè)極其著名的問(wèn)題,閃爍。

當(dāng)我們開(kāi)始試水使用AsyncDisplayKit時(shí),只要簡(jiǎn)單reload一下TableNode,那閃爍,眼睛都瞎了。后來(lái)查了官方的issue,才發(fā)現(xiàn)很多人都提了這個(gè)問(wèn)題,但官方也沒(méi)給出什么優(yōu)雅的解決方案。要知道,閃爍是非常影響用戶體驗(yàn)的。如果非要在不閃爍和帶閃爍的AsyncDisplayKit中選擇,我會(huì)毫不猶豫的選擇不閃爍,而放棄使用AsyncDisplayKit。但現(xiàn)在已經(jīng)不存在這個(gè)選擇了,因?yàn)榻?jīng)過(guò)AsyncDisplayKit的多次迭代努力加上一些小技巧,AsyncDisplayKit的異步閃爍已經(jīng)被優(yōu)雅的解決了。

AsyncDisplayKit不宜廣泛使用,那些高度固定、UI簡(jiǎn)單用UIKit更好一些,畢竟AsyncDisplayKit并不像UIKit,人人都會(huì),如果內(nèi)容和高度復(fù)雜又很動(dòng)態(tài),強(qiáng)烈推薦AsyncDisplayKit,它會(huì)簡(jiǎn)化太多東西。

疑難點(diǎn)

一年的AsyncDisplayKit使用經(jīng)驗(yàn),踩過(guò)了不少坑,遇到了不少值得注意的問(wèn)題,一并列在這里,以供參考。

ASNetworkImageNode的緩存

ASNetworkImageNode是對(duì)UIImageView需要從網(wǎng)絡(luò)加載圖片這一使用場(chǎng)景的封裝,省去了YYWebImage或者SDWebImage等第三方庫(kù)的引入,只需要設(shè)置URL即可實(shí)現(xiàn)網(wǎng)絡(luò)圖片的自動(dòng)加載。

import AsyncDisplayKit

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

這非常省事便捷,但ASNetworkImageNode默認(rèn)用的緩存機(jī)制和圖片下載器是PinRemoteImage,為了使用我們自己的緩存機(jī)制和圖片下載器,需要實(shí)現(xiàn)ASImageCacheProtocol圖片緩存協(xié)議和 ASImageDownloaderProtocol圖片下載器協(xié)議兩個(gè)協(xié)議,然后初始化時(shí),用ASNetworkImageNodeinit(cache: ASImageCacheProtocol, downloader: ASImageDownloaderProtocol)初始化方法,傳入對(duì)應(yīng)的類,方便其間,一般會(huì)自定義一個(gè)初始化靜態(tài)方法。我們公司緩存機(jī)制和圖片下載器都是用的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,當(dāng)享受其一幀不掉如絲般柔滑的手感時(shí),ASTableNodeASCollectionNode刷新時(shí)的閃爍一定讓你幾度崩潰,到AsyncDisplayKitgithub上搜索閃爍相關(guān)issue,會(huì)出來(lái)100多個(gè)問(wèn)題。閃爍是AsyncDisplayKit與生俱來(lái)的問(wèn)題,聞名遐邇,而閃爍的體驗(yàn)非常糟糕。幸運(yùn)的是,幾經(jīng)探索,AsyncDisplayKit的閃爍問(wèn)題已經(jīng)完美解決,這個(gè)完美指的是一幀不掉的同時(shí)沒(méi)有任何閃爍,同時(shí)也沒(méi)增加代碼的復(fù)雜度。

閃爍可以分為四類,

1)ASNetworkImageNode reload時(shí)的閃爍

當(dāng)ASCellNode中包含ASNetworkImageNode,則這個(gè)cell reload時(shí),ASNetworkImageNode會(huì)異步從本地緩存或者網(wǎng)絡(luò)請(qǐng)求圖片,請(qǐng)求到圖片后再設(shè)置ASNetworkImageNode展示圖片,但在異步過(guò)程中,ASNetworkImageNode會(huì)先展示PlaceholderImage,從PlaceholderImage--->fetched image的展示替換導(dǎo)致閃爍發(fā)生,即使整個(gè)cell的數(shù)據(jù)沒(méi)有任何變化,只是簡(jiǎn)單的reload,ASNetworkImageNode的圖片加載邏輯依然不變,因此仍然會(huì)閃爍,這顯著區(qū)別于UIImageView,因?yàn)?code>YYWebImage或者SDWebImage對(duì)UIImageViewimage設(shè)置邏輯是,先同步檢查有無(wú)內(nèi)存緩存,有的話直接顯示,沒(méi)有的話再先顯示PlaceholderImage,等待加載完成后再顯示加載的圖片,也即邏輯是memory cached image--->PlaceholderImage--->fetched image的邏輯,刷新當(dāng)前cell時(shí),如果數(shù)據(jù)沒(méi)有變化memory cached image一般都會(huì)有,因此不會(huì)閃爍。

AsyncDisplayKit官方給的修復(fù)思路是:

import AsyncDisplayKit

let node = ASNetworkImageNode()
node.placeholderColor = UIColor.red
node.placeholderFadeDuration = 3

這樣修改后,確實(shí)沒(méi)有閃爍了,但這只是將PlaceholderImage--->fetched image圖片替換導(dǎo)致的閃爍拉長(zhǎng)到3秒而已,自欺欺人,并沒(méi)有修復(fù)。

既然閃爍是reload時(shí),沒(méi)有事先同步檢查有無(wú)緩存導(dǎo)致的,繼承一個(gè)ASNetworkImageNode的子類,復(fù)寫(xiě)url設(shè)置邏輯:

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
      }
    }
  }
}

按道理不會(huì)閃爍了,但事實(shí)上仍然會(huì),只要是個(gè)ASNetworkImageNode,無(wú)論怎么設(shè)置,都會(huì)閃,這與官方的API說(shuō)明嚴(yán)重不符,很無(wú)語(yǔ)。迫不得已之下,當(dāng)有緩存時(shí),直接用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)
  }
}

使用時(shí)將NetworkImageNode當(dāng)成ASNetworkImageNode使用即可。

2)reload 單個(gè)cell時(shí)的閃爍

當(dāng)reload ASTableNode或者ASCollectionNode的某個(gè)indexPathcell時(shí),也會(huì)閃爍。原因和ASNetworkImageNode很像,都是異步惹的禍。當(dāng)異步計(jì)算cell的布局時(shí),cell使用placeholder占位(通常是白圖),布局完成時(shí),才用渲染好的內(nèi)容填充cell,placeholder到渲染好的內(nèi)容切換引起閃爍。UITableViewCell因?yàn)槎际峭剑淮嬖谡嘉粓D的情況,因此也就不會(huì)閃。

先看官方的修改方案,

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

這個(gè)方案非常有效,因?yàn)樵O(shè)置cell.neverShowPlaceholders = true,會(huì)讓cell從異步狀態(tài)衰退回同步狀態(tài),若reload某個(gè)indexPathcell,在渲染完成之前,主線程是卡死的,這與UITableView的機(jī)制一樣,但速度會(huì)比UITableView快很多,因?yàn)?code>UITableView的布局計(jì)算、資源解壓、視圖合成等都是在主線程進(jìn)行,而ASTableNode則是多個(gè)線程并發(fā)進(jìn)行,何況布局等還有緩存。所以,一般也沒(méi)有問(wèn)題,貝聊的聊天界面只是簡(jiǎn)單這樣設(shè)置后,就不閃了,而且一幀不掉。但當(dāng)頁(yè)面布局較為復(fù)雜時(shí),滑動(dòng)時(shí)的卡頓掉幀就變的肉眼可見(jiàn)。

這時(shí),可以設(shè)置ASTableNodeleadingScreensForBatching減緩卡頓

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

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

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)
  }
}

關(guān)鍵代碼是,

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)
    }
  })
}

即,檢查當(dāng)前的indexPath是否被標(biāo)記,如果是,則先設(shè)置cell.neverShowPlaceholders = true,等待reload完成(一幀是1/60秒,這里等待0.5秒,足夠渲染了),將cell.neverShowPlaceholders = false。這樣reload時(shí)既不會(huì)閃爍,也不會(huì)影響滑動(dòng)時(shí)的異步繪制,因此一幀不掉。

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

3)reloadData時(shí)的閃爍

在下拉刷新后,列表經(jīng)常需要重新刷新,即調(diào)用ASTableNode或者ASCollectionNodereloadData方法,但會(huì)閃,而且很明顯。有了單個(gè)cell reload時(shí)閃爍的解決方案后,此類閃爍解決起來(lái),就很簡(jiǎn)單了。

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

將肉眼可見(jiàn)的cell添加進(jìn)indexPathesToBeReloaded中即可。

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

我們公司的聊天界面是用AsyncDisplayKit寫(xiě)的,當(dāng)下拉加載更多新消息時(shí),為保持加載后當(dāng)前消息的位置不變,需要在collectionNode.insertItems(at: indexPaths)完成后,復(fù)原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)
  }
}

遺憾的是,會(huì)閃爍。起初以為是AsyncDisplayKit異步繪制導(dǎo)致的閃爍,一度還想放棄AsyncDisplayKit,用UITableView重寫(xiě)一遍,幸運(yùn)的是,當(dāng)時(shí)項(xiàng)目工期太緊,沒(méi)有時(shí)間重寫(xiě),也沒(méi)時(shí)間仔細(xì)排查,直接帶問(wèn)題上線了。

最近閑暇,經(jīng)仔細(xì)排查,方知不是AsyncDisplayKit的鍋,但也比較難修,有一定的參考價(jià)值,因此一并列在這里。

閃爍的原因是,collectionNode insertItems成功后會(huì)先繪制contentOffsetCGPoint(x: 0, y: 0)時(shí)的一幀畫(huà)面,無(wú)動(dòng)畫(huà)時(shí)這一幀畫(huà)面立即顯示,然后調(diào)用成功回調(diào),回調(diào)中復(fù)原了collectionNode.view.contentOffset,下一幀就顯示復(fù)原了位置的畫(huà)面,前后有變化因此閃爍。這是做消息類APP一并會(huì)遇到的bug,google一下,主要有兩種解決方案,

第一種,通過(guò)仿射變換倒置ASCollectionNode,這樣下拉加載更多,就變成正常列表的上拉加載更多,也就無(wú)需移動(dòng)contentOffsetASCollectionNode還特意設(shè)置了個(gè)屬性inverted,方便大家開(kāi)發(fā)。然而這種方案換湯不換藥,當(dāng)收到新消息,同時(shí)正在查看歷史消息,依然需要插入新消息并復(fù)原contentOffset,閃爍依然在其他情形下發(fā)生。

第二種,集成一個(gè)UICollectionViewFlowLayout,重寫(xiě)prepare()方法,做相應(yīng)處理即可。這個(gè)方案完美,簡(jiǎn)介優(yōu)雅。子類化的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)
  }
}

當(dāng)需要insertItems并且保持位置時(shí),將CollectionFlowLayoutisInsertingToTop設(shè)置為true即可,完成后再設(shè)置為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的布局思想,非常高效直觀簡(jiǎn)潔,但畢竟迥異于AutoLayoutframe layout的布局風(fēng)格,咋一上手,很不習(xí)慣,有些小技巧還是需要慢慢積累,有些概念也需要逐漸熟悉深入,下面列舉幾個(gè)筆者覺(jué)得比較重要的概念

1)設(shè)置任意間距

AutoLayout實(shí)現(xiàn)任意間距,比較容易直觀,因?yàn)?code>AutoLayout的約束,本來(lái)就是我的邊離你的邊有多遠(yuǎn)的概念,而AsyncDisplayKit并沒(méi)有,AsyncDisplayKit里面的概念是,我自己的前面有多少空白距離,我自己的后面有多少空白距離,更強(qiáng)調(diào)自己。假如有三個(gè)元素,怎么約束它們之間的間距?

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布局,元素之間的任意間距一般是通過(guò)元素自己的spaceBefore或者spaceBefore style實(shí)現(xiàn),這是自我包裹性,更容易理解,如果不是拿ASStackLayoutSpec布局,可以將某個(gè)元素包裹成ASInsetsLayoutSpec,再設(shè)置UIEdgesInsets,保持自己的四周任意邊距。

能任意設(shè)置間距是自由布局的基礎(chǔ)。

2)flexGrow和flexShrink

flexGrowflexShrink是相當(dāng)重要的概念,flexGrow是指當(dāng)有多余空間時(shí),拉伸誰(shuí)以及相應(yīng)的拉伸比例(當(dāng)有多個(gè)元素設(shè)置了flexGrow時(shí)),flexShrink相反,是指當(dāng)空間不夠時(shí),壓縮誰(shuí)及相應(yīng)的壓縮比例(當(dāng)有多個(gè)元素設(shè)置了flexShrink時(shí))。
靈活使用flexGrowspacer(占位ASLayoutSpec)可以實(shí)現(xiàn)很多效果,比如等間距,

equalspace.png

實(shí)現(xiàn)代碼如下,

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不同就可以實(shí)現(xiàn)指定比例的布局,再結(jié)合width樣式,輕松實(shí)現(xiàn)以下布局

complexspace.png

布局代碼如下,

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是指某個(gè)node的大小取值范圍,有minSizemaxSize兩個(gè)屬性。比如下圖的布局:

complexlayout.png
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會(huì)根據(jù)ContainerNodelayoutSpecThatFits(_:)中施加在nodeA、nodeB的布局規(guī)則和nodeA、nodeB自身屬性計(jì)算nodeA、nodeBconstrainedSize。

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

1)根據(jù)布局規(guī)則和nodeA自身樣式屬性maxWidth、minWidth、width、heightpreferredSize,可計(jì)算出nodeAconstrainedSizeminSizemaxSize均為其preferredSizeCGSize(width: 100, height: 100),因?yàn)椴季忠?guī)則為水平向的ASStackLayout,當(dāng)空間富余或者空間不足時(shí),nodeA即不壓縮又不拉伸,所以會(huì)取其指定的preferredSize。

2)根據(jù)布局規(guī)則和nodeB自身樣式屬性maxWidth、minWidth、widthheight、preferredSize,可以計(jì)算出其constrainedSizeminSizeCGSize(width: 0, height: 0),maxSizeCGSize(width: 375 - 100 - b - e - d, height: Inf+),因?yàn)?code>nodeB的flexShrinkflexGrow均為1,也即當(dāng)空間富余或者空間不足時(shí),nodeB添滿富余空間或壓縮至空間夠?yàn)橹埂?/p>

如果不指定nodeBflexShrinkflexGrow,那么當(dāng)空間富余或者空間不足時(shí),AsyncDisplayKit就不知道壓縮和拉伸哪一個(gè)布局元素,則nodeBconstrainedSizemaxSize就變?yōu)?code>CGSize(width: Inf+, height: Inf+),即完全無(wú)大小限制,可想而知,nodeB的子node的布局將完全不對(duì)。這也說(shuō)明另外一個(gè)問(wèn)題,nodeconstrainedSize并不是一定大于其子nodeconstrainedSize。

理解constrainedSize的計(jì)算,才能熟練利用node的樣式maxWidth、minWidth、width、heightpreferredSize、flexShrinkflexGrow進(jìn)行布局。如果發(fā)現(xiàn)布局結(jié)果不對(duì),而對(duì)應(yīng)node的布局代碼確是正確無(wú)誤,一般極有可能是因?yàn)榇?code>node的父布局元素不正確。

動(dòng)畫(huà)

因?yàn)?code>AsyncDisplayKit的布局方式有兩種,frame布局和flexbox式的布局,相應(yīng)的動(dòng)畫(huà)方式也有兩種

1)frame布局

如果采用的是frame布局,動(dòng)畫(huà)跟普通的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
    }
  }
}

不要覺(jué)得用了AsyncDisplayKit就告別了frame布局,ViewController中主要元素個(gè)數(shù)很少,布局簡(jiǎn)單,因此,一般也還是采用frame layout,如果只是做一些簡(jiǎn)單的動(dòng)畫(huà),直接采用UIView的動(dòng)畫(huà)API即可

2)flexbox式的布局

這種布局方式,是在某個(gè)子node中常用的,如果node內(nèi)部布局發(fā)生了變化,又需要做動(dòng)畫(huà)時(shí),就需要復(fù)寫(xiě)AsyncDisplayKit的動(dòng)畫(huà)API,并基于提供的動(dòng)畫(huà)上下文類context,做動(dòng)畫(huà):

class SomeNode: ASDisplayNode {
  let nodeA = ASDisplayNode()

  override func animateLayoutTransition(_ context: ASContextTransitioning) {
    // 利用context可以獲取animate前后布局信息

    UIView.animate(withDuration: 0.5) { 
      // 不使用系統(tǒng)默認(rèn)的fade動(dòng)畫(huà),采用自定義動(dòng)畫(huà)
      let newFrame = ... // 新的frame
      nodeA.frame = newFrame
    }
  }
}

系統(tǒng)默認(rèn)的動(dòng)畫(huà)是漸隱漸顯,可以獲取animate前后布局信息,比如某個(gè)子node兩種布局中的frame,然后再自定義動(dòng)畫(huà)類型。如果想觸發(fā)動(dòng)畫(huà),主動(dòng)調(diào)用SomeNode的觸發(fā)方法transitionLayout(withAnimation:shouldMeasureAsync:measurementCompletion:)即可。

內(nèi)存泄漏

為了方便將一個(gè)UIView或者CALayer轉(zhuǎn)化為一個(gè)ASDisplayNode,系統(tǒng)提供了用block初始化ASDisplayNode的簡(jiǎn)便方法:

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會(huì)被要?jiǎng)?chuàng)建的node持有。如果block中反過(guò)來(lái)持有了這個(gè)node的持有者,則會(huì)產(chǎn)生循環(huán)引用,導(dǎo)致內(nèi)存泄漏:

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

子線程崩潰

AsyncDisplayKit的性能優(yōu)勢(shì)來(lái)源于異步繪制,異步的意思是有時(shí)候node會(huì)在子線程創(chuàng)建,如果繼承了一個(gè)ASDisplayNode,一不小心在初始化時(shí)調(diào)用了UIKit的相關(guān)方法,則會(huì)出現(xiàn)子線程崩潰。比如以下node,

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

    super.init()

  }
}

但在node初始化時(shí)調(diào)用UIImage(named:)創(chuàng)建圖片是不可避免的,用methodSwizzleUIImage(named:)置換成安全的即可。

其實(shí)在子線程初始化node并不多見(jiàn),一般都在主線程。

總結(jié)

一年的實(shí)踐下來(lái),閃爍是AsyncDisplayKit遇到的最大的問(wèn)題,修復(fù)起來(lái)也頗為費(fèi)神。其他bug,有時(shí)雖然很讓人頭疼,但由于AsyncDisplayKit是對(duì)UIKit的再封裝,實(shí)在不行,仍然可以越過(guò)AsyncDisplayKitUIKit的方法修復(fù)。

學(xué)習(xí)曲線也不算很陡峭。

考慮到AsyncDisplayKit的種種好處,非常推薦AsyncDisplayKit,當(dāng)然還是僅限于用在比較復(fù)雜和動(dòng)態(tài)的頁(yè)面中。

個(gè)人博客原文鏈接:http://qingmo.me/
歡迎關(guān)注我的微博以便交流:輕墨

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容