专栏名称: 贝聊科技
移动开发部@贝聊科技
目录
相关文章推荐
51好读  ›  专栏  ›  贝聊科技

[贝聊科技]AsyncDisplayKit近一年的使用体会及疑难点

贝聊科技  · 简书  ·  · 2017-10-27 11:10

正文

请到「今天看啥」查看全文


欢迎关注我的微博以便交流: 轻墨

一个第三方库能做到像新产品一样,值得大家去写写使用体会的,并不多见, 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/
欢迎关注我的微博以便交流: 轻墨







请到「今天看啥」查看全文