AsyncDisplayKit教程: 节点层级结构

本文由René Cacheaux于15年发表于raywenderlich,原文可查看AsyncDisplayKit Tutorial: Node Hierarchies

AsyncDisplayKit是一个最初为Facebook的Paper项目而设计的iOS框架。它使获得比应用标准view更流畅响应更快速的UI成为可能。

你应该已经学习过一点AsyncDisplayKit的用法了,如果没有,请移步这里,这篇教程将带你达到下个级别。

这篇教程将通过探索AsyncDisplayKit中的节点层级结构来解释怎样充分使用这个库。借此,你将得到流畅的滚动视图,同时也能构建灵活且可复用的UI。

AsyncDisplayKit中一个关键概念就是节点(node),正如你将会学到的,AsyncDisplayKit中的节点是在线程不安全的UIView(层)上面抽象出来的一个线程安全的层。

好消息是如果你已经熟练使用UIKit,那你会发现你已经知道了AsyncDisplayKit的大部分方法和属性,因为它们的API几乎一模一样。

跟随本教程,你将学到:

  • 怎样构建你自己的ASDisplayNode子类
  • 为了更好的组织和重用怎样将节点层级结构放到一个单独的容器节点中
  • 怎样使用节点层级结构会比视图层级结构更占优势,从而自动减少拖慢主线程的机会,保持用户界面流畅且响应迅速

你要这样做:构建一个包含两个子节点-分别用于显示图片和标题-的容器节点,你会看到容器节点怎样计算它自己的尺寸和怎样布局它的子节点。最后,你会将UIView容器子类替换成ASDisplayNode子类。

这是你要最终达成的效果:

很炫酷有木有?!UI越流畅,效果就越好。

注意:这篇教程是为那些已经有了一些AsyncDisplayKit基础的工程师们而写的,如果你是第一次接触这个概念,还是先看看这个

开始

你将要构建的这个app通过呈现一张卡片来展示世界著名奇迹之一-泰姬陵。

首先下载初始工程,这是一个仅有一个controller的很基础的工程,简单看一下就行。

打开ViewController.swift文件,注意到controller中的常量属性被命名为card。它保存了一个泰姬陵相关信息的数据模型,你将使用这个模型来创建一个卡片节点展示给用户。

编译运行一下确保工程可以正常工作,正常来说你应该会看到一个空的黑屏幕-就是一个空的画布。

创建并展示一个容器节点

现在你将要构建你的第一个节点层级结构,和构建一个UIView层级结构相当类似,我相信你一定很熟悉:]。

打开Wonders-Bridging-Header.h文件,添加如下头文件:

#import <AsyncDisplayKit/ASDisplayNode+Subclasses.h>

ASDisplayNode+Subclasses.h文件暴露了一些ASDisplayNode内部的方法。你需要导入这个头文件才能重写ASDisplayNode子类的方法,但是要注意你也只能在你自定义的ASDisplayNode子类中调用这些方法。

打开CardNode.swift,增加如下代码:

class CardNode: ASDisplayNode {}

这样就声明了一个新的ASDisplayNode子类,你将会用它作为一个容器包含卡片的用户界面。

打开ViewController.swift,实现viewDidLoad()方法:

override func viewDidLoad() {
      super.viewDidLoad()
      // Create, configure, and lay out container node
      let cardNode = CardNode()
      cardNode.backgroundColor = UIColor(white: 1.0, alpha: 0.27)
      let origin = CGPointZero
      let size = CGSize(width: 100, height: 100)
      cardNode.frame = CGRect(origin: origin, size: size)
      // Create container node’s view and add to view hierarchy
      view.addSubview(cardNode.view)
}

这段代码用硬编码定义的尺寸创建了一个新的卡片节点,它会位于屏幕的左上角,宽高都是100。

我知道你会感觉布局很奇怪,别担心,稍后就会修改。编译运行一下:

恭喜你!你已经创建了一个自定义的节点子类并且显示到了屏幕上。下一步就是赋予它计算自身尺寸的能力。在那之前,你应该理解节点的布局引擎是如何工作的。

节点布局引擎

接下来的任务就是通过调用节点的measure(constrainedSize:)方法使得节点去计算自身的尺寸。

往方法中传入constrainedSize参数,这样节点就可以根据这个约束尺寸计算出一个适合的尺寸。

通俗的说,这意味着计算得到的尺寸不能大于提供的约束尺寸。

举个例子,看一下下面的示意图:

这显示了一个有确定宽高的约束尺寸,计算得到的尺寸和约束尺寸相比宽度是相等的,高度要小。也有可能宽高都相等,或者宽高都小于。但是绝不会允许宽或高超过。

这其实就类似于UIView中的sizeThatFits(size:)方法,区别在于measure(constrainedSize:)会保存计算得到的尺寸结果,允许你通过节点的calculatedSize属性获得缓存值。

举个例子神马时候计算得到的尺寸宽和高都会小于约束尺寸,请看下图:

之所以AsyncDisplayKit将sizing纳入API是因为计算尺寸经常会花费可感知到的大量时间。比如说从磁盘读取一张图片然后计算尺寸就非常慢。通过纳入到API,并且是线程安全的,也就意味着计算可以放到后台线程了!酷吧。这是一个使得UI如黄油般顺滑的贴心的小特性,减少了用户遇到卡的一b的情况。

一个节点如果之前没计算过没有缓存值或者约束尺寸改变了就会去进行尺寸计算。

用程序员的术语来说,它会像这样工作:

  • measure(constrainedSize:)或者返回一个缓存值或者通过调用calculateSizeThatFits(constrainedSize:)去计算尺寸

  • 在你自定义的ASDisplayNode子类中你将所有的尺寸计算逻辑放到calculateSizeThatFits(constrainedSize:)中

注意:calculateSizeThatFits(constrainedSize:)是ASDisplayNode内部的方法,你不应该在你的子类外调用它。

计算一个节点的尺寸

现在你已经理解了这个方法,该去实际应用了。

打开CardNode.swift,修改成如下:

class CardNode: ASDisplayNode {

      override func calculateSizeThatFits(constrainedSize: CGSize) -> CGSize {
        return CGSize(width: constrainedSize.width * 0.2, height: constrainedSize.height * 0.2)
      }

}

现在这个方法返回了一个尺寸宽高都是提供的约束尺寸的20%,因此,它占可用空间的4%。

打开ViewController.swift,删除viewDidLoad()方法实现,实现createCardNode(containerRect:)方法:

/* Delete this method

override func viewDidLoad() {
      super.viewDidLoad()
      // 1
      let cardNode = CardNode()
      cardNode.backgroundColor = UIColor(white: 1.0, alpha: 0.27)
      let origin = CGPointZero
      let size = CGSize(width: 100, height: 100)
      cardNode.frame = CGRect(origin: origin, size: size)

      // 2
      view.addSubview(cardNode.view)
}    
*/

func createCardNode(containerRect containerRect: CGRect) -> CardNode {
      // 3
      let cardNode = CardNode()
      cardNode.backgroundColor = UIColor(white: 1.0, alpha: 0.27)
      cardNode.measure(containerRect.size)

      // 4
      let size = cardNode.calculatedSize
      let origin = containerRect.originForCenteredRectWithSize(size)
      cardNode.frame = CGRect(origin: origin, size: size)
      return cardNode
}

还是稍微解释一下:

  1. 删除旧的创建,配置,布局容器节点的方法
  2. 删除旧的创建容器节点的视图和将其添加到视图层级结构的方法
  3. createCardNode(containerRect:)方法创建了一个和旧的容器节点拥有同样背景色的新的卡片节点,并且使用一个外界提供的容器矩形来约束卡片节点的尺寸,因此卡片节点不会大于containerRect的size
  4. 使用originForCenteredRectWithSize(size:)方法将卡片居中显示

在这个方法下面,重新实现viewDidLoad()方法:

override func viewDidLoad() {
      super.viewDidLoad()
      let cardNode = createCardNode(containerRect: UIScreen.mainScreen().bounds)
      view.addSubview(cardNode.view)
}

当controller的view加载时,createCardNode(containerRect:)方法创建并设置了一个新的CardNode,这个卡片节点的尺寸不能大于主屏幕的大小。

注意在此时,controller的view还没有被布局。因此如果使用controller的view的bounds size是不安全的,因此使用主屏幕的bounds size来约束卡片节点的尺寸。

当然,这种方法不够优雅,别急,稍后会修改。先这么用着。

编译运行一下,你会看到节点居中显示了。

异步设置与布局节点

有时布局复杂的层级结构会占用大量的时间,如果发生在主线程,这就会阻塞用户交互。要是你期望有良好的用户体验,就不能有任何可感知到的等待时间。

基于这个原因,你要把创建,设置,布局都放入后台线程,这样就避免了阻塞主的UI线程。

在createCardNode(containerRect:)和viewDidLoad():之间实现addCardViewAsynchronously(containerRect:)方法:

func addCardViewAsynchronously(containerRect containerRect: CGRect) {
      dispatch_async(dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0)) {
        let cardNode = self.createCardNode(containerRect: containerRect)
        dispatch_async(dispatch_get_main_queue()) {
             self.view.addSubview(cardNode.view)
        }
      }
}

addCardViewAsynchronously(containerRect:)方法在后台线程创建了CardNode-因为node是线程安全的所以没有问题!在创建,配置,布局之后,回到主线程好将node的view增加到controller的view层级结构中-毕竟,UIKit不是线程安全的嘛。:]

注意:一旦你创建了节点的视图,之后再使用节点就只能在主线程了。

重新实现viewDidLoad()方法:

override func viewDidLoad() {
      super.viewDidLoad()
      addCardViewAsynchronously(containerRect: UIScreen.mainScreen().bounds)
}

不再阻塞主线程,保证了用户界面反应灵敏。

编译运行一下,显示还和之前一样,但是所有的计算工作都放到后台线程了!酷!:]

用controller的view尺寸去约束节点的尺寸

还记得刚才我说相比依赖于屏幕尺寸会用一种更优雅的方式去计算节点的尺寸吧?现在来兑现承诺!

打开ViewController.swift,增加一个属性:

var cardViewSetupStarted = false

然后用viewWillLayoutSubviews():方法代替viewDidLoad()方法:

/* Delete this method
override func viewDidLoad() {
      super.viewDidLoad()
      addCardViewAsynchronously(containerRect: UIScreen.mainScreen().bounds)
}
*/

override func viewWillLayoutSubviews() {
      super.viewWillLayoutSubviews()
      if !cardViewSetupStarted {
        addCardViewAsynchronously(containerRect: view.bounds)
        cardViewSetupStarted = true
      }
}

替换掉使用主屏幕的bounds size,上面的代码使用controller的view的bounds size来约束卡片节点的尺寸。

现在之所有可以安全的使用controller的view的bounds size是因为在viewWillLayoutSubviews()方法发生的时候,controller的view已经被设置了尺寸大小。

这样做之所以更优秀是因为一个controller的view可以是任何尺寸,你不能基于controller正好铺满整个屏幕这样一个事实去做事情。

view可能被布局多次,因此viewWillLayoutSubviews()方法可能会被调用多次。但是你只是希望创建卡片节点一次,所以要加一个cardViewSetupStarted变量来标识一下。

再次编译运行一下,好吧,界面还是没变化:

节点层级结构

当前在屏幕上有了一个空的容器卡片节点。现在你要展示一些内容,方法就是增加一些子节点,接下来的部分描述了你将要构建的简单的节点层级结构。

增加子节点的过程会相当熟悉因为其实和添加子视图异曲同工。

第一步是增加一个图像节点,但首先,你应该知道容器节点是怎样布局它的子节点的。

子节点布局

你现在知道怎样测量容器节点的尺寸以及怎样使用计算得到的尺寸(calculated size)来布局容器节点的视图。但是容器节点怎样布局子节点呢?

可以分两步走:

  1. 首先,在calculateSizeThatFits(constrainedSize:)方法中计算每一个子节点的尺寸,这保证了每个子节点缓存了一个计算后的尺寸。
  2. 在UIKit的布局传递到主线程期间,AsyncDisplayKit会在你自定义的ASDisplayNode子类中调用layout()方法,layout()类似于UIView中的layoutSubviews(),区别在于layout()不是必须去计算它的所有孩子的尺寸,仅仅是获取每个子节点的缓存计算好的尺寸(calculated size)。

还回到UI,泰姬陵卡片的尺寸应该等于图像的尺寸,标题文字应该适应在这个尺寸中。最简单的做法是计算图像节点的尺寸然后用结果去约束文本节点的尺寸。

这就是布局卡片的子节点所需要的逻辑,现在用代码实现。:]

增加一个子节点

打开CardNode.swift在calculateSizeThatFits(constrainedSize:)方法之上增加如下代码:

// 1
let imageNode: ASImageNode

// 2
init(card: Card) {
      imageNode = ASImageNode()
      super.init()
      setUpSubnodesWithCard(card)
      buildSubnodeHierarchy()
}

// 3
func setUpSubnodesWithCard(card: Card) {
      // Set up image node
      imageNode.image = card.image
}

// 4
func buildSubnodeHierarchy() {
      addSubnode(imageNode)
}

照例还是稍微解释下:

  1. 图像节点属性:这一行增加了一个保存卡片的图像子节点的属性。
  2. 自定义的初始化方法:需要一个卡片模型对象来保存图像和标题。
  3. 子节点设置:用工程一开始就存在的卡片模型对象来设置子节点。
  4. 容器的层级结构:就像组织视图层级那样组织节点层级,把所有子节点加到自身上。

接下来,重新实现calculateSizeThatFits(constrainedSize:)方法:

override func calculateSizeThatFits(constrainedSize: CGSize) -> CGSize {
      // 1 
      imageNode.measure(constrainedSize)

      // 2 
      let cardSize = imageNode.calculatedSize

      // 3 
      return cardSize
}

接下来,重写layout()方法:

override func layout() {
    imageNode.frame = CGRect(origin: CGPointZero, size: imageNode.calculatedSize).integral
}

这个逻辑将图像放到卡片节点的左上角,并且确定图像节点的frame都是整数,避免了边界展示问题。

注意一下这个方法怎样在布局时使用图像节点缓存的计算好的尺寸(calculated size)。

因为图像节点的尺寸决定了卡片节点的尺寸,图像会铺满整个卡片。

回到ViewController.swift,在createCardNode(containerRect:)方法中,初始化CardNode方法修改成:

let cardNode = CardNode(card: card)

编译运行,感受下神马叫美丽壮观!:]

棒棒的,你已经成功的创建了一个存在一个节点层级结构的容器节点。虽然很简单,但它也是一个节点层级结构!

增加更多子节点

嘿!接下来我们增加标题告诉用户这是个啥。

至少需要另一个子节点来保存标题。

打开CardNode.swift增加一个新属性:

let titleTextNode: ASTextNode

在初始化方法中赋初值,在super.init()之上添加:

titleTextNode = ASTextNode()

在setUpSubnodesWithCard(card:)方法中添加:

titleTextNode.attributedString = NSAttributedString.attributedStringForTitleText(card.name) 

这一行代码给文本节点一个带有属性的字符串保存卡片的标题。attributedStringForTitleText(text:)是一个帮助方法,很简单你可以自己去看看。

接下来,在buildSubnodeHierarchy()方法的最后添加:

addSubnode(titleTextNode)

然后在calculateSizeThatFits(constrainedSize:)方法内,在返回之前添加:

titleTextNode.measure(cardSize)

在layout()方法中添加:

titleTextNode.frame =
FrameCalculator.titleFrameForSize(titleTextNode.calculatedSize, containerFrame: imageNode.frame)

FrameCalculator又是一个帮助工具类。

编译运行,现在显示泰姬陵没有任何问题了。

回顾一下你是怎样实现的!这是一个充实的节点层级结构,一个容器节点有两个子节点。

何去何从

如果你愿意看看最后的工程,可以在这里下载

要想学习更多可以到AsyncDisplayKit的Github仓库看一些🌰工程

这个库完全开源,所以如果你好奇怎样实现的话可以看看源码。

总结一下:通过使用节点将一部分工作转移到后台线程能确保获得更流畅反应更灵敏的UI界面,而这通过UIKit是不可能实现的。