AsyncDisplayKit教程: 达到60FPS的滚动帧率

天增岁月人增肉,今天是中秋。
本文由René Cacheaux于14年发表于raywenderlich,原文可查看AsyncDisplayKit Tutorial: Achieving 60 FPS scrolling
Facebook的Paper团队创造了另一个非常棒的库:AsyncDisplayKit。这个库允许你将图片解码,布局,渲染等操作放到后台线程,这样不会阻塞用户交互,从而使用户界面响应超级灵敏。本教程就是专门介绍它的,好好学学吧。

举个例子,对于非常复杂的界面你可以应用AsyncDisplayKit库来构建丝般顺滑,每秒60帧的滑动体验,而通过UIKit来优化不足以战胜这样的挑战。

在本篇教程中,你将应用AsyncDisplayKit到一个主要是有UICollectionView滑动问题的工程中来显著提高它的性能。你将学到怎样将AsyncDisplayKit应用到现存项目中。

注意:在正式开始本篇教程之前,你应该已经很熟悉Swift,Core Animation,Core Graphics,GCD,NSOperation,Block。
你可以在本网站上学习Swift 2 教程。如果你想复习一下或者是深入研究这些话题,建议查看官方文档:Core Animation Programming GuideQuartz 2D Programming Guide。WWDC2012中有一篇iOS App Performance: Graphics and Animations是另一个我强烈推荐的优秀的学习资源,你可以多看几遍相信每次都会有新收获的。

开始

在开始之前,先看一眼AsyncDisplayKit的官方介绍。这会让你有一个大概了解这个库是什么,解决了神马问题。

准备好了的话,首先下载初始化工程。(译者说明:作者是应用Xcode6.3和iOS8.3SDK编译的工程,如果你用更新的Xcode版本,可能会报错,对应修改一下Swift源码即可。)

注意:写这篇教程的时候AsyncDisplayKit还只是1.0版本,且已经加入到工程中。

这个app通过UICollectionView来展示不同的热带雨林动物。每一张信息卡片包含一个热带雨林动物的图片,名字和描述,卡片的背景是模糊化了的照片。还有一个渐变是为了保证文字更加清晰可见。

打开工程,按照以下指引你将会看到应用AsyncDisplayKit所带来的最激动人心的益处。

  • 最好在真机上运行这个app,因为在模拟器上很难看出来性能的提高。
  • 这个app虽然是通用各个设备上的,但是最好还是跑在iPad上。
  • 最后,为了更容易感受到这个库带来的变化,在一些比较老的设备上运行这个app,比如说第三代iPad。

滑动这个collection view,注意那可怜的帧率。在第三代iPad上,差不多也就是15-20FPS。很明显,collection view掉了很多帧,在本篇教程的最后,滑动时会极度接近60FPS。

注意:你看到的所有图片都是在本地的,而没有通过网络获取的。

测量反应灵敏度

在应用AsyncDisplayKit到一个已存的工程中之前,你应该通过Instruments来测量一下UI的表现。这样修改了之后才好有个对比。

最重要的是,你想要发现你的应用是CPU受限还是GPU受限。也就是说,到底是CPU还是GPU阻碍了你的app运行在一个高帧率上。这些信息能告诉你AsyncDisplayKit的哪些特性可以用来优化你的app。

经过测量你会发现滑动性能是CPU受限的。你能猜出到底是什么原因导致掉帧么?
就是因为对背景图片进行模糊化处理阻塞了主线程从而导致掉帧的。

准备应用AsyncDisplayKit

应用AsyncDisplayKit到一个工程中归根结底就是用node层级结构代替原有的view层级结构或layer树。展示node就是AsyncDisplayKit库的关键。它们位于view之上,而且是线程安全的,也就是说原来只能在主线程做的事情现在有一部分可以不在主线程做了。这样使得主线程有更多的精力能应对其他的操作比如说处理触摸事件或者处理collection view的滚动。

也就是所第一步应该是移除原有的view层级结构。

移除view层级结构

打开RainforestCardCell.swift文件,在awakeFromNib()方法中删除所有 addSubview(…),变成这样:

override func awakeFromNib() {
super.awakeFromNib()
contentView.layer.borderColor = UIColor(hue: 0, saturation: 0, brightness: 0.85, alpha: 0.2).CGColor
contentView.layer.borderWidth = 1
}

然后修改layoutSubviews()方法:

override func layoutSubviews() {
super.layoutSubviews()
}

修改configureCellDisplayWithCardInfo(cardInfo:)方法:

func configureCellDisplayWithCardInfo(cardInfo: RainforestCardInfo) {
//MARK: Image Size Section
let image = UIImage(named: cardInfo.imageName)!
featureImageSizeOptional = image.size
}

接着将cell其它属性删除掉,只留下一个:

class RainforestCardCell: UICollectionViewCell {
var featureImageSizeOptional: CGSize?

}

最后,编译运行一下,你会看到神马都没有了:

现在,cell都空了,再滚动真的是超级流畅。你的目标就是用node代替view来添加内容 而仍然保持这样的流畅度。

最好用Instruments的 Core Animation来检测每一步修改之后的app,这样能直观的感受到修改对帧率带来的影响。

增加一个占位内容

在RainforestCardCell中添加一个CALayer类型的变量命名为placeholerlayer。

class RainforestCardCell: UICollectionViewCell {
var featureImageSizeOptional: CGSize?
var placeholderLayer: CALayer!

}

之所以需要一个占位层是因为展示内容需要异步进行一些处理,而那是需要一点时间的,如果不加用户首先会看到一堆空的cell,显然不是那么令人愉悦。

在awakeFromNib()和layoutSubviews()方法中,分别修改成下面的样子:

override func awakeFromNib() {
super.awakeFromNib()
placeholderLayer = CALayer()
placeholderLayer.contents = UIImage(named: “cardPlaceholder”)!.CGImage
placeholderLayer.contentsGravity = kCAGravityCenter
placeholderLayer.contentsScale = UIScreen.mainScreen().scale
placeholderLayer.backgroundColor = UIColor(hue: 0, saturation: 0, brightness: 0.85, alpha: 1).CGColor
contentView.layer.addSublayer(placeholderLayer)
}

override func layoutSubviews() {
super.layoutSubviews()
placeholderLayer?.frame = bounds
}

再编译运行一下,看看是不是舒服多了

没有UIView做支持的CALayer当改变frame时默认是有隐式动画的。为了修复,可做出如下修改:
override func layoutSubviews() {
super.layoutSubviews()
CATransaction.begin()
CATransaction.setValue(kCFBooleanTrue, forKey: kCATransactionDisableActions)
placeholderLayer?.frame = bounds
CATransaction.commit()
}

编译运行一下看看是不是解决了这个问题。现在占位层不再有动画了。

你的第一个Node

重构app的第一步就是在每个cell中增加一个背景图片node。在这一小节中,你将学到:

  • 创建,布局,将一个图像node添加到cell上
  • 用node和他们的layer处理cell的重用
  • 模糊图像node

首先打开Layers-Bridging-Header.h导入头文件

#import

这样使得AsyncDisplayKit的类在所有swift文件中都可用了。

编译一下确保一切正常。

Collection View相关

现在来看一下collectionview相关组件。

  • View Controller:RainforestViewController类仅仅是得到内容数组然后实现UICollectionView必要的datasource方法,无需花太多时间观看。
  • Data Source:你将要花费大部分时间在cell类上面,也就是RainforestCardCell。controller通过调用configureCellDisplayWithCardInfo(cardInfo:)方法将人带雨林卡片信息传给cell。然后cell用这些信息配置自身。
  • Cell:在configureCellDisplayWithCardInfo(cardInfo:)方法中,cell创建,配置,布局,添加node到自身之上。也就是说每当controller从队列中取出一个cell,这个cell就会创建并添加到自身上一个新的node层级结构。

如果你使用view代替node,出于性能原因的考虑这就不是最好的策略。因为你可以异步的创建,配置,布局node,甚至可以异步绘制node,从而性能会提升很多。难点在于当cell准备重用时如何取消正在异步进行的活动然后移除旧的node。
注意:本教程中添加node到cell的这个策略还算OK,这对于精通AsyncDisplayKit来说是个好的开端。然而,在实际开发中,你最好使用ASRangeController来缓存你的node,这样你就不用每当cell重用时都重建它的node层级结构。ASRangeController超出了本教程的范围,可以看看ASRangeController.h文件中的注释了解更多。
还有一点:1.1版的AsyncDisplayKit包含有ASCollectionView类。使用 ASCollectionView会让本app中的整个collection view都由node来处理。而在本教程中,每个cell都会包含一个node层级结构。如上面所解释的,这样可以,但如果使用整个collection view都应用node将会更好。屌爆的ASCollectionView!
好了,不BB了,开始写代码了。
添加背景图像Node
现在你要经历用node配置cell了,一步一步来就好。
打开RainforestCardCell.swift文件,修改configureCellDisplayWithCardInfo(cardInfo:)方法为:
func configureCellDisplayWithCardInfo(cardInfo: RainforestCardInfo) {
//MARK: Image Size Section
let image = UIImage(named: cardInfo.imageName)!
featureImageSizeOptional = image.size

//MARK: Node Creation Section
let backgroundImageNode = ASImageNode()
backgroundImageNode.image = image
backgroundImageNode.contentMode = .ScaleAspectFill
}
这样就创建并配置了一个ASImageNode类型的常量,名字是backgroundImageNode。
注意:确保//MARK: 注释部分也包含了,这样更容易跟随本教程。

AsyncDisplayKit库有好几种node类型,比如说ASImageNode,当你需要显示图片时可以用它。它基本相当于 UIImageView,除了ASImageNode默认是异步地解码图片。
在 configureCellDisplayWithCardInfo(cardInfo:)方法的最后添加如下一行:

backgroundImageNode.layerBacked = true
这样保证backgroundImageNode是一个由layer做支持(layer-backed)的node。

Node由UIView或者是CALayer的实例做支持都可以。当需要能够处理事件(比如说触摸事件)时,你就需要一个UIView做支持的node。反之,如果仅仅是用于展示内容,用layer做支持的node就可以-它会更加轻量级,因此又能获得小小的性能提升。

因为本教程中的app不需要处理事件,所以你将所有的node都设置为由layer做支持的就好。在上面的代码中,由于backgroundImageNode是由layer做支持的,AsyncDisplayKit会为雨林动物图像的内容创建一个 CALayer。
在configureCellDisplayWithCardInfo(cardInfo:)方法的最下面添加如下代码:
//MARK: Node Layout Section
backgroundImageNode.frame = FrameCalculator.frameForContainer(featureImageSize: image.size)

这里使用FrameCalculator为backgroundImageNode布局。
FrameCalculator是一个帮助类,通过返回每个node的frame包含了cell的布局。注意所有内容都是手动布局的,而没有使用Auto Layout做约束。如果你需要构建自适应布局或者本地化驱动的布局,那就要小心了,因为不能给node添加约束。
接下来,添加代码到configureCellDisplayWithCardInfo(cardInfo:)方法底部:
//MARK: Node Layer and Wrap Up Section
self.contentView.layer.addSublayer(backgroundImageNode.layer)

这就将backgroundImageNode的layer添加到cell的contentView的layer上了。
注意,AsyncDisplayKit会为backgroundImageNode创建一个layer。然而,你必须要将这个node放到一个 layer tree中才能在屏幕上显示。这个node将会被异步绘制,所以直到绘制完成,它的内容都不会显示,即使它的layer已经在一个layer树中。
从技术角度来说,layer一直都存在。但渲染图像是异步进行的。layer开始时并没有内容(例如是透明的)。一旦渲染完成,layer的contents就会更新为包含图像内容。
此时此刻,cell的contentView的layer会包含两个子层:占位层和node的layer。在node完成绘制前,只有占位图会显示。
注意到configureCellDisplayWithCardInfo(cardInfo:)方法每当cell从队列中被取出时都会被调用。每次cell被回收,cell的contentView的layer都会增加一个新的子层。先不要担心,很快会解决这个问题。
回到RainforestCardCell.swift的开头,添加一个ASImageNode类型的变量:
class RainforestCardCell: UICollectionViewCell {
var featureImageSizeOptional: CGSize?
var placeholderLayer: CALayer!
var backgroundImageNode: ASImageNode? ///< ADD THIS LINE

}

之所以需要这个属性是因为必须要将backgroundImageNode的引用保留住,否则ARC会自动将其释放,也就不会有东西显示了。node虽然保留了它们自己的layer的引用,但是反过来layer并没有保留它们从属的node的引用——因此即使node的layer已经在一个layer tree中,你依然需要保留node。
在configureCellDisplayWithCardInfo(cardInfo:)方法底部的Node Layer and Wrap Up Section的最后,修改如下:
self.backgroundImageNode = backgroundImageNode
以下是configureCellDisplayWithCardInfo(cardInfo:)方法的完整版:
func configureCellDisplayWithCardInfo(cardInfo: RainforestCardInfo) {
//MARK: Image Size Section
let image = UIImage(named: cardInfo.imageName)!
featureImageSizeOptional = image.size

//MARK: Node Creation Section
let backgroundImageNode = ASImageNode()
backgroundImageNode.image = image
backgroundImageNode.contentMode = .ScaleAspectFill
backgroundImageNode.layerBacked = true

//MARK: Node Layout Section
backgroundImageNode.frame = FrameCalculator.frameForContainer(featureImageSize: image.size)

//MARK: Node Layer and Wrap Up Section
self.contentView.layer.addSublayer(backgroundImageNode.layer)
self.backgroundImageNode = backgroundImageNode
}
编译并运行,观察AsyncDisplayKit是如何异步地使用图像设置layer的contents的。这使得你能够当CPU在后台绘制layer的内容的同时流畅的滑动界面。

如果你是运行在旧设备上,注意图像是如何出现的——这是爆米花特效,但不总是受欢迎!本教程的最后一节会去掉这个不令人愉快的弹出效果,替换成让图像非常友好地淡入,非常酷。
如之前所讨论的,每当cell被重用时新的node就会被创建。这并不很理想因为这意味着每当cell被重用时就会增加一个layer。
如果你想看看子层堆积太多的结果,那就多上下滑动几次,然后加个断点打印cell的contentView的layer的sublayers属性。你应该会看到很多layer,这并不好。
处理cell的重用
还是在cell类中,添加一个叫做contentLayer的CALayer类型的变量。注意是可选类型:
class RainforestCardCell: UICollectionViewCell {
var featureImageSizeOptional: CGSize?
var placeholderLayer: CALayer!
var backgroundImageNode: ASImageNode?
var contentLayer: CALayer? ///< ADD THIS LINE

}
你将使用这个属性移除cell的contentView的layer tree中旧的node layer。虽然你也可以简单的通过访问node的layer属性来获得,但上面的写法更清晰。
在configureCellDisplayWithCardInfo(cardInfo:)方法最后添加代码:
self.contentLayer = backgroundImageNode.layer
然后,修改prepareForReuse()方法:
override func prepareForReuse() {
super.prepareForReuse()
backgroundImageNode?.preventOrCancelDisplay = true
}
因为AsyncDisplayKit能够异步地绘制node,所以你能够阻止未开始的绘制也能够取消任何在进行中的绘制。无论是你需要阻止还是取消绘制,将preventOrCancelDisplay属性设置为true即可。在本例中,你要在cell被重用前取消任何正在进行的绘制活动。
接下来,添加如下代码到prepareForReuse()尾部:
contentLayer?.removeFromSuperlayer()

这就将contentLayer从其父layer(也就是contentView的layer)中移除了。

每当一个cell被回收,上面代码就移除了旧的node的layer,因而解决了堆积问题。因此在任何时间,最多只有两个子layer:占位层和 node的layer。

继续添加如下代码:

contentLayer = nil
backgroundImageNode = nil

这确保cell释放它们的引用,这样ARC才好做清理工作。
编译并运行。这次,不会再有layer堆积的问题,且所有不必要的绘制都会被取消。

终于该加点儿模糊效果了,宝贝,是模糊呀。

模糊图像

要模糊图像,需要添加一个额外的步骤到图像node显示过程中。
还是在cell的configureCellDisplayWithCardInfo(cardInfo:)方法中,在设置backgroundImageNode.layerBacked的后面,添加如下代码:

backgroundImageNode.imageModificationBlock = { input in
if input == nil {
return input
}
if let blurredImage = input.applyBlurWithRadius(
30,
tintColor: UIColor(white: 0.5, alpha: 0.3),
saturationDeltaFactor: 1.8,
maskImage: nil,
didCancel:{ return false }) {
return blurredImage
} else {
return image
}
}

ASImageNode的imageModificationBlock给了你一个在显示之前去处理底层的图像的机会。这是非常实用的功能。
在上面的代码里,你使用imageModificationBlock来为cell的背景图像应用模糊效果。关键点在于图像node将会在后台绘制它的内容并执行这个闭包,而主线程依然顺滑流畅。这个闭包接受原始的UIImage并返回一个经过处理的UIImage。
上面的代码使用了UIImage的模糊category,它由Apple在WWDC2013 提供,使用了Accelerate framework在CPU上模糊图像。因为模糊会消耗很多时间和内存,这个版本的category被修改为包含了取消机制。这个模糊方法会定期调用didCancel闭包来决定是否应该要停止模糊。
暂时,上面的代码给didCancel简单地返回false,稍后会修改。

注意:还记得第一次运行app时collection view那可怜的滑动效果吗?就是应为模糊方法阻塞了主线程。通过使用AsyncDisplayKit将模糊放入后台,能显著提高collection view的滑动性能。简直天壤之别。

编译并运行,观察模糊效果:

感受下此时滑动collection view有多流畅。
当collection view从队列中取出一个cell时,一个模糊操作将在后台线程开始。当用户快速滑动时,collection view会重用每个cell很多次,同时会开始许多模糊操作。我们的目标是在cell准备被重用时取消正在进行中的模糊操作。
你已经在prepareForReuse()里取消了node的绘制,但一旦控制权被移交给处理图像修改的闭包,你还需要响应node的preventOrCancelDisplay信号。

取消模糊

为了取消进行中的模糊操作,你需要实现模糊方法的didCancel闭包。
添加一个捕捉列表到imageModificationBlock以捕捉一个backgroundImageNode的弱引用:
backgroundImageNode.imageModificationBlock = { [weak backgroundImageNode] input in

}

用弱引用是为了避免循环引用。修改imageModificationBlock如下:

backgroundImageNode.imageModificationBlock = { [weak backgroundImageNode] input in
if input == nil {
return input
}
// ADD FROM HERE…
let didCancelBlur: () -> Bool = {
var isCancelled = true
// 1
if let strongBackgroundImageNode = backgroundImageNode {
// 2
let isCancelledClosure = {
isCancelled = strongBackgroundImageNode.preventOrCancelDisplay
}
// 3
if NSThread.isMainThread() {
isCancelledClosure()
} else {
dispatch_sync(dispatch_get_main_queue(), isCancelledClosure)
}
}
return isCancelled
}
// …TO HERE

}

稍微解释一下:

  1. 获得一个对backgroundImageNode的强引用。如果此处代码执行时backgroundImageNode已经为空,那么isCancelled将保持为true,然后模糊操作将会被取消。如果没有node需要显示时,自然没有必要继续进行模糊操作。
  2. 之所以会将取消检查包在闭包里是因为一旦node创建了它的layer或view,以后就只能在主线程访问node的属性。由于你需要访问preventOrCancelDisplay,所以必须在主线程中检查。
  3. 最后,确保isCancelledClosure是在主线程被调用,在didCancelBlur闭包返回之前设置好isCancelled。

在调用applyBlurWithRadius(…)方法处,修改传递给didCancel的参数:

if let blurredImage = input.applyBlurWithRadius(
30,
tintColor: UIColor(white: 0.5, alpha: 0.3),
saturationDeltaFactor: 1.8,
maskImage: nil,
didCancel: didCancelBlur) {

}

编译并运行,你可能没有注意到有多大差别,但现在任何在cell离开屏幕时还未完成的模糊都会被取消了。这就意味着设备比之前做得更少了。你可能观察到轻微的性能提升,特别是在较慢的设备如第三代iPad上运行时。

当然了,若没有东西在前面,也就谈不上什么背景。你的卡片需要内容。通过下面四个小节,你将学会:

  • 创建一个会将所有的子node绘制到一个单独的CALayer里的容器node;

  • 构建一个node层级结构;

  • 创建一个自定义的ASDisplayNode子类;

  • 在后台构建并布局node层级结构。

所有这些做完后,你就会得到一个看起来和应用AsyncDisplayKit之前一样的app,然而却有着黄油般顺滑的滑动体验。

Rasterized容器Node

截止目前,你一直在操作cell内的一个单独的node。接下来,你将创建一个容器node,它会包含所有的卡片内容。

增加容器Node

在configureCellDisplayWithCardInfo(cardInfo:)方法中,在Node Layout Section之前backgroundImageNode.imageModificationBlock之后,增加如下代码:

//MARK: Container Node Creation Section
let containerNode = ASDisplayNode()
containerNode.layerBacked = true
containerNode.shouldRasterizeDescendants = true
containerNode.borderColor = UIColor(hue: 0, saturation: 0, brightness: 0.85, alpha: 0.2).CGColor
containerNode.borderWidth = 1

这就创建并配置了一个叫做containerNode的ASDisplayNode常量。注意这个容器的shouldRasterizeDescendants属性,这是一个关于node如何工作的暗示以及一个使它们工作得更好地机会。
正如单词 “descendants(子孙)” 所暗示的,你可以创建AsyncDisplayKit Node的层级结构或树,就如你可以创建Core Animation Layer的层级结构一样。举个例子,如果你有一个都是由Layer支持的node的层级结构,那么AsyncDisplayKit将会为每个node创建一个单独的CALayer,layer层级结构会映射node层次结构。
这听起来很熟悉:它类似于当你使用普通的UIKit时,layer层级结构会映射view层次结构。然而,这个layer的栈有一些不同的结果:

  • 首先,因为是异步渲染,你会看到layer逐个显示。当AsyncDisplayKit绘制完成一个layer,它会立刻制作layer的显示内容。所以如果你有一个layer的绘制比其他 layer耗时更长,那么它将会在它们之后显示。用户会看到零碎的layer组件,这个过程通常是不可见的,因为Core Animation会在显示任何东西之前的runloop中重绘所有必须的 layer。
  • 第二,layer太多了以后可能会引起性能问题。每个CALayer都需要一块内存来存储它的像素位图和内容。同样,Core Animation必须通过XPC将每个layer发给渲染服务器。最后,渲染服务器可能需要重绘一些layer以复合它们,例如当混合layer时。通常来说,更多的layer意味着Core Animation更多的工作。所以限制layer的使用数量有许多好处。

为了解决这个问题,AsyncDisplayKit有一个方便的特性:它允许你绘制一个node层级结构到一个单独的layer容器中。这就是shouldRasterizeDescendants所做的。当你设置了这个,那么在完成所有的Subnode的绘制之前,ASDisplayNode将不会设置layer的contents。
所以在之前的步骤里,设置容器node的shouldRasterizeDescendants为true有两个好处:

  • 它确保卡片一次显示所有的node,就如同旧的同步绘制;
  • 而且它通过光栅化layer栈到一个单独的layer并最小化未来的合成消耗而提高了效率。

唯一的缺点是,由于你将所有的layer放入一个位图,你就不能在之后单独给某一个node加动画了。

要了解更多,请看shouldRasterizeDescendants在头文件 ASDisplayNode.h里的注释。

接下来,在Container Node Creation Section之后,修改代码:
//MARK: Node Hierarchy Section
containerNode.addSubnode(backgroundImageNode)

注意:添加node的顺序很重要,就如同subview和sublayer。最先添加的node会在之后添加的后面显示。

修改Node Layout Section的第一行:

//MARK: Node Layout Section
containerNode.frame = FrameCalculator.frameForContainer(featureImageSize: image.size)

最后,用FrameCalculator布局backgroundImageNode:

backgroundImageNode.frame = FrameCalculator.frameForBackgroundImage(containerBounds: containerNode.bounds)

这样就设置backgroundImageNode充满整个containerNode。

你几乎完成了新的node层级结构,但因为现在容器node是容器了所以首先你需要正确地设置layer的层级结构。

管理容器Node的层

在Node Layer and Wrap Up Section部分,修改如下:

// Replace the following line…
// self.contentView.layer.addSublayer(backgroundImageNode.layer)
// …with this line:
self.contentView.layer.addSublayer(containerNode.layer)

删除下面backgroundImageNode属性的引用:

self.backgroundImageNode = backgroundImageNode

因为现在cell仅仅需要保留一个容器node的引用,所以修改如下:

// Replace the following line…
// self.contentLayer = backgroundImageNode.layer
// …with this line:
self.contentLayer = containerNode.layer

增加一个可选的ASDisplayNode类型变量命名为containerNode:

class RainforestCardCell: UICollectionViewCell {
var featureImageSizeOptional: CGSize?
var placeholderLayer: CALayer!
var backgroundImageNode: ASImageNode?
var contentLayer: CALayer?
var containerNode: ASDisplayNode? ///< ADD THIS LINE

}

记住你需要保留你自己的node,否则它们会立即被释放。

回到configureCellDisplayWithCardInfo(cardInfo:)方法,在Node Layer and Wrap Up Section的最后,做一个赋值:

self.containerNode = containerNode

编译并运行,模糊的图像将会再此显示!但还有最后一件事要去改变,因为现在有了新的node层级结构。回忆之前cell重用时你控制图像node停止显示。现在你需要让整个 node层级结构都停止显示。

用新的Node层级结构处理cell重用

在prepareForReuse()方法中,修改如下:

override func prepareForReuse() {
super.prepareForReuse()

// Replace this line…
// backgroundImageNode?.preventOrCancelDisplay = true
// …with this line:
containerNode?.recursiveSetPreventOrCancelDisplay(true)

contentLayer?.removeFromSuperlayer()

}

当你需要取消一整个node层次结构的绘制时用recursiveSetPreventOrCancelDisplay(),这个方法将会给当前node以及它所有的子node设置preventOrCancelDisplay值。

接下来,继续修改:

override func prepareForReuse() {

contentLayer = nil

// Replace this line…
// backgroundImageNode = nil
// …with this line:
containerNode = nil
}

删除backgroundImageNode属性:

class RainforestCardCell: UICollectionViewCell {
var featureImageSizeOptional: CGSize?
var placeholderLayer: CALayer!
// var backgroundImageNode: ASImageNode? ///< REMOVE THIS LINE
var contentLayer: CALayer?
var containerNode: ASDisplayNode?

}
编译并运行,app呈现如之前一样,但现在你的图像node在容器node中,而重用依然和它应有的方式一样。

cell的内容

目前为止你有了一个node层级结构,但容器内还只有一个node——图像node。现在是时候设置node层级结构去复制在添加AsyncDisplayKit之前时应用的视图层级结构了。这意味着添加text和一个未模糊的特征图像。

增加特征图像
要添加特征图像了,它是一个未模糊的图像,显示在卡片的顶部。
打开cell文件并找到configureCellDisplayWithCardInfo(cardInfo:)方法,在Node Creation Section的底部,添加如下代码:

let featureImageNode = ASImageNode()
featureImageNode.layerBacked = true
featureImageNode.contentMode = .ScaleAspectFit
featureImageNode.image = image

这就创建并配置了一个叫做featureImageNode的ASImageNode的常量。它被设置为由layer支持,自适应缩放,并设置显示图像,这次不需要模糊。

在Node Hierarchy Section的最后,将featureImageNode设为containerNode的一个子node。
containerNode.addSubnode(featureImageNode)

在Node Layout Section,布局featureImageNode
featureImageNode.frame = FrameCalculator.frameForFeatureImage(featureImageSize: image.size, containerFrameWidth: containerNode.frame.size.width)

编译并运行,你会看到特征图像显示在卡片的顶部,位于模糊图像的上方。注意特征图像和模糊图像是在同一时间跳出的。这是你之前添加的shouldRasterizeDescendants在起作用。

增加标题文字
接下来添加文字label,用来显示动物的名字和描述。首先来动物名字吧。
继续在configureCellDisplayWithCardInfo(cardInfo:)中,找到Node Creation Section。添加下列代码到这节尾部,就在创建featureImageNode之后:

let titleTextNode = ASTextNode()
titleTextNode.layerBacked = true
titleTextNode.backgroundColor = UIColor.clearColor()
titleTextNode.attributedString = NSAttributedString.attributedStringForTitleText(cardInfo.name)

这就创建了一个叫做titleTextNode的ASTextNode类型常量。
ASTextNode是另一个AsyncDisplayKit提供的node子类,用于显示文本。它是一个基于UILabel效果的node。它接受一个attributed string,由TextKit支持,拥有如文本链接等许多特性。要学到更多关于这个Node的功能,去看看ASTextNode.h吧。
初始项目包含有一个NSAttributedString的扩展,它提供了一个工厂方法去生成一个属性字符串用于将title和description文本显示在雨林卡片上。上面的代码使用了这个扩展的attributedStringForTitleText(…)方法。
在Node Hierarchy Section的最后,添加:
containerNode.addSubnode(titleTextNode)

这样就将titleTextNode增加到了node层级结构中,因为它是最后一个添加的所以它会在最上面。

在Node Layout Section的最后,添加:
titleTextNode.frame = FrameCalculator.frameForTitleText(containerBounds: containerNode.bounds, featureImageFrame: featureImageNode.frame)

这样就完成了titleTextNode的布局。
编译并运行,你就有了一个显示在特征图像顶部的标题。再次说明,label只会在整个cell准备好渲染时才渲染。

增加描述文字

和增加标题文字差不多。
在configureCellDisplayWithCardInfo(cardInfo:)方法中创建node:

let descriptionTextNode = ASTextNode()
descriptionTextNode.layerBacked = true
descriptionTextNode.backgroundColor = UIColor.clearColor()
descriptionTextNode.attributedString = NSAttributedString.attributedStringForDescriptionText(cardInfo.description)

添加到node层级结构中:

containerNode.addSubnode(descriptionTextNode)

布局:

descriptionTextNode.frame = FrameCalculator.frameForDescriptionText(containerBounds: containerNode.bounds, featureImageFrame: featureImageNode.frame)

编译并运行,你就也能看到描述文字了。

自定义Node子类

现在你已经使用了ASImageNode和ASTextNode。现在来学习下如何创建node子类吧。

创建一个渐变的Node类

接下来,你将用GradientView.swift中的Core Graphics代码来构建一个自定义的渐变的node。渐变会显示在特征图像的底部以便让标题看起来更加明显。

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

#import

这一步是必需的因为这个类没有包含在库的主头文件里。你在子类化任何ASDisplayNode或_ASDisplayLayer时都需要访问这个类。

创建一个继承自ASDisplayNode的子类GradientNode,增加如下方法:

class func drawRect(bounds: CGRect, withParameters parameters: NSObjectProtocol!,
isCancelled isCancelledBlock: asdisplaynode_iscancelled_block_t!, isRasterizing: Bool) {

}

如同UIView或CALayer,你可以子类化ASDisplayNode去做自定义绘制。你可以绘制到UIView的layer或是单独的CALayer,这取决于实际情况。查看ASDisplayNode+Subclasses.h以获取更多关于子类化ASDisplayNode的信息。
另外,ASDisplayNode的绘制方法比在UIView和CALayer里的接受更多参数,给你提供方法少做工作,并更有效率。
要为你自定义Display Node填充内容,你需要实现来自_ASDisplayLayerDelegate协议的drawRect(…)或displayWithParameters(…)。在继续之前,看看_ASDisplayLayer.h中这些方法和它们的参数。搜索 _ASDisplayLayerDelegate,重点看看头文件注释里关于drawRect(…)的描述。
因为渐变图位于特征图的上方,使用Core Graphics绘制,所以你需要使用drawRect(…)。
打开GradientView.swift并拷贝drawRect(…)的内容到GradientNode.swift的drawRect(…)中,如下:

class func drawRect(bounds: CGRect, withParameters parameters: NSObjectProtocol!,
isCancelled isCancelledBlock: asdisplaynode_iscancelled_block_t!, isRasterizing: Bool) {
let myContext = UIGraphicsGetCurrentContext()
CGContextSaveGState(myContext)
CGContextClipToRect(myContext, bounds)

let componentCount: Int = 2
let locations: [CGFloat] = [0.0, 1.0]
let components: [CGFloat] = [0.0, 0.0, 0.0, 1.0,
0.0, 0.0, 0.0, 0.0]
let myColorSpace = CGColorSpaceCreateDeviceRGB()
let myGradient = CGGradientCreateWithColorComponents(myColorSpace, components,
locations, componentCount)

let myStartPoint = CGPoint(x: bounds.midX, y: bounds.maxY)
let myEndPoint = CGPoint(x: bounds.midX, y: bounds.midY)
CGContextDrawLinearGradient(myContext, myGradient, myStartPoint,
myEndPoint, UInt32(kCGGradientDrawsAfterEndLocation))

CGContextRestoreGState(myContext)
}

现在可以删除GradientView.swift了。

增加渐变Node

打开RainforestCardCell.swift文件,找到configureCellDisplayWithCardInfo(cardInfo:)方法,在Node Creation Section的底下,增加代码:

let gradientNode = GradientNode()
gradientNode.opaque = false
gradientNode.layerBacked = true

在Node Hierarchy Section,修改如下:

//MARK: Node Hierarchy Section
containerNode.addSubnode(backgroundImageNode)
containerNode.addSubnode(featureImageNode)
containerNode.addSubnode(gradientNode) ///< ADD THIS LINE
containerNode.addSubnode(titleTextNode)
containerNode.addSubnode(descriptionTextNode)

在Node Layout Section底下:

gradientNode.frame = FrameCalculator.frameForGradient(featureImageFrame: featureImageNode.frame)

编译运行一下,看看效果!

爆米花效果
如之前提到的,cell的node内容会在完成绘制时“弹出”。这不是很理想。所以现在来以修复这个问题。但首先,更加深入学习AsyncDisplayKit以看看它是如何工作的。
在configureCellDisplayWithCardInfo(cardInfo:)的Container Node Creation Section,关闭容器node的shouldRasterizeDescendants:
containerNode.shouldRasterizeDescendants = false

编译并运行,你会注意到现在容器层级结构里不同的node一个接一个的弹出。首先是文字,然后是特征图,再然后是模糊背景图。
当shouldRasterizeDescendants被关闭后,AsyncDisplayKit就不是绘制一个容器layer了,它会创建一个能够反映卡片node层级结构的layer tree。记得爆米花特效的出现是因为每个layer都在它绘制完成后立即出现,而某些layer比其他的花费更多时间在绘制上。
这不是我们想要的,但它说明了AsyncDisplayKit的工作方式。我们不想要这个行为,所以还是将shouldRasterizeDescendants打开:
containerNode.shouldRasterizeDescendants = true

现在来想想怎么样去掉爆米花特效。首先,看看node如何在后台被构建。

在后台构建Node

除了异步地绘制,使用AsyncDisplayKit,你同样可以异步地创建、配置以及布局。深呼吸一下,因为这就是你接下来要做的事情。
创建一个Node构建operation
你要将node层级结构的构造包装到一个NSOperation中。这样做很棒,因为操作能很容易的在不同的操作队列上执行,包括后台队列。
打开 RainforestCardCell.swift 。然后添加如下方法:
func nodeConstructionOperationWithCardInfo(cardInfo: RainforestCardInfo, image: UIImage) -> NSOperation {
let nodeConstructionOperation = NSBlockOperation()
nodeConstructionOperation.addExecutionBlock {
// TODO: Add node hierarchy construction
}
return nodeConstructionOperation
}

绘制并不是唯一会拖慢主线程的操作。对于复杂的屏幕来说,布局计算也有可能变的昂贵。目前为止,本工程的当前状态,缓慢的node布局会引起collection view丢帧。
60 FPS意味着你有大约17ms的时间让你的cell准备好显示,否则一个或多个帧就会被丢掉。这在有很多很复杂的cell的table view和collection view中是非常常见的,滑动时丢帧就是这个原因。
AsyncDisplayKit前来救援!
你将使用上面的nodeConstructionOperation将所有node层级结构的构建以及布局从主线程分离并放入后台NSOperationQueue,进一步确保collection view能尽量以接近60 FPS的帧率滑动。
警告:你可以在后台访问并设置node的属性,但只能在node的layer或view未被创建之前,也就是当你第一次访问node的layer或view属性时。
一旦node的layer或view被创建,你就必须在主线程才能访问和设置node的属性了,因为node将会转发这些调用到它的layer或view上。如果你得到一个崩溃log显示“Incorrect display node thread affinity”,那就意味着node在创建了layer或view之后,你依然尝试在后台访问或设置node的属性。
修改nodeConstructionOperation操作block如下:
nodeConstructionOperation.addExecutionBlock {
[weak self, unowned nodeConstructionOperation] in
if nodeConstructionOperation.cancelled {
return
}
if let strongSelf = self {
// TODO: Add node hierarchy construction
}
}
当这个操作运行时,cell可能已经被释放了。那样的话,就不需要做任何工作了。类似的,如果操作被取消了,那一样也没有工作要做了。
之所以对nodeConstructionOperation使用unowned引用也是为了避免在操作和执行闭包之间产生保留环。
现在找到configureCellDisplayWithCardInfo(cardInfo:)。将任何在Image Size Section之后的代码移动到nodeConstructionOperation的执行闭包里。将代码放在strongSelf的条件语句里,即TODO的位置。之后 configureCellDisplayWithCardInfo(cardInfo:)方法将看起来如下:

func configureCellDisplayWithCardInfo(cardInfo: RainforestCardInfo) {
//MARK: Image Size Section
let image = UIImage(named: cardInfo.imageName)!
featureImageSizeOptional = image.size
}
你将会有一些编译错误。这是因为操作block中的self是一个弱引用,因此是可选的。但你有一个对self的强引用,因为代码在可选绑定语句内。所以替换错误的几行成下面的样子:
strongSelf.contentView.layer.addSublayer(containerNode.layer)
strongSelf.contentLayer = containerNode.layer
strongSelf.containerNode = containerNode

最后,添加如下代码到你刚改动的三行之下:

containerNode.setNeedsDisplay()

编译确保没有错误。如果你现在运行,那么只有占位图会显示,因为node的创建操作还没有实际被使用。现在来添加它。
使用Node创建操作

打开RainforestCardCell.swift文件,添加新属性:
class RainforestCardCell: UICollectionViewCell {
var featureImageSizeOptional: CGSize?
var placeholderLayer: CALayer!
var contentLayer: CALayer?
var containerNode: ASDisplayNode?
var nodeConstructionOperation: NSOperation? ///< ADD THIS LINE

}

这样增加了一个NSOperation类型可选变量。
当cell准备回收时,你将使用这个属性去取消node的构建。这在用户非常快速地滑动collection view时发生,特别是如果布局还需要一些计算时间的话。
在prepareForReuse()中添加如下指示的代码:
override func prepareForReuse() {
super.prepareForReuse()

// ADD FROM HERE…
if let operation = nodeConstructionOperation {
operation.cancel()
}
// …TO HERE

containerNode?.recursiveSetPreventOrCancelDisplay(true)
contentLayer?.removeFromSuperlayer()
contentLayer = nil
containerNode = nil
}
这就在cell被重用时取消了操作,所以如果node创建还没完成,它就不会完成了。
现在找到configureCellDisplayWithCardInfo(cardInfo:)方法,添加代码:
func configureCellDisplayWithCardInfo(cardInfo: RainforestCardInfo) {
// ADD FROM HERE…
if let oldNodeConstructionOperation = nodeConstructionOperation {
oldNodeConstructionOperation.cancel()
}
// …TO HERE

//MARK: Image Size Section
let image = UIImage(named: cardInfo.imageName)!
featureImageSizeOptional = image.size
}
这个cell现在会在它准备重用并开始配置时,取消任何进行中的node构造操作。这确保了操作被取消,即使cell在准备好重用前就被重新配置。
编译一下以确保没有错误。
运行在主线程
AsyncDisplayKit允许你在非主线程做许多工作。但当它要面对UIKit和CoreAnimation时,你还是需要在主线程做。目前为止,你从主线程移走了所有的node创建。但还有一件事需要被放在主线程——即设置CoreAnimation的layer层级结构。
在RainforestCardCell.swift中,找到nodeConstructionOperationWithCardInfo(cardInfo:image:)并替换Node Layer and Wrap Up Section为如下代码:
// 1
dispatch_async(dispatch_get_main_queue()) { [weak nodeConstructionOperation] in
if let strongNodeConstructionOperation = nodeConstructionOperation {
// 2
if strongNodeConstructionOperation.cancelled {
return
}

// 3
if strongSelf.nodeConstructionOperation !== strongNodeConstructionOperation {
return
}

// 4
if containerNode.preventOrCancelDisplay {
return
}

// 5
//MARK: Node Layer and Wrap Up Section
strongSelf.contentView.layer.addSublayer(containerNode.layer)
containerNode.setNeedsDisplay()
strongSelf.contentLayer = containerNode.layer
strongSelf.containerNode = containerNode
}
}
稍微解释一下:

  1. 回忆当node的layer属性第一次被访问时,所有的layer都会被创建。这就是为何你必须运行在主线程。
  2. 操作被检查以确定是否在添加到layer层级结构之前就已经取消了。在操作完成前,cell被重用或者重新配置,就很可能会出现这样的情况,那你就不应该添加layer了。
  3. 作为一个保护措施,确保node当前的nodeConstructionOperation和被调度到此闭包中的操作是同一个NSOperation。
  4. 如果containerNode的preventOrCancel是true就立即返回。如果构造操作完成,但node的绘制被取消了,你依然不想将node的layer显示到cell中。
  5. 最后,添加node的layer到层级结构中,如果必要这将创建layer。

编译一下以确保一切OK。

开始Node创建操作

你依然没有实际创建和开始操作,现在来做。
继续在RainforestCardCell.swift里,改变configureCellDisplayWithCardInfo(cardInfo:)的方法签名为:
func configureCellDisplayWithCardInfo(cardInfo: RainforestCardInfo, nodeConstructionQueue: NSOperationQueue)

这里添加了一个新的参数nodeConstructionQueue。它就是一个用于将node创建操作入队的NSOperationQueue。

在configureCellDisplayWithCardInfo(cardInfo:nodeConstructionQueue:)底部,添加如下代码:
let newNodeConstructionOperation = nodeConstructionOperationWithCardInfo(cardInfo, image: image)
nodeConstructionOperation = newNodeConstructionOperation
nodeConstructionQueue.addOperation(newNodeConstructionOperation)
这就创建了一个node构造操作,将其保留在nodeConstructionOperation属性中,并将其添加到传入的队列中。
最后,打开RainforestViewController.swift,给RainforestViewController添加一个叫做nodeConstructionQueue的初始化为常量的属性,如下:
class RainforestViewController: UICollectionViewController {
let rainforestCardsInfo = getAllCardInfo()
let nodeConstructionQueue = NSOperationQueue() ///< ADD THIS LINE

}
在collectionView(collectionView:cellForItemAtIndexPath indexPath:)方法中:
cell.configureCellDisplayWithCardInfo(cardInfo, nodeConstructionQueue: nodeConstructionQueue)
cell将会创建一个新的node构造操作并将其添加到Controller的操作队列里并发运行。记住在cell出队时就会创建一个新的node层级结构。这并不理想,但也还行。如果你要缓存node并重用,看看ASRangeController吧。
呜呼,OK,现在编译并运行!你将看到和之前一样的效果,但现在布局和渲染都没在主线程执行了。酷!我打赌你从来没有想过有一天能做到这样的事情。这就是AsyncDisplayKit的威力。你可以将越来越多不必需在主线程的操作从主线程移除,这将给主线程更多资源去处理用户交互,让你的app如黄油般顺滑。

cell中的淡入效果

现在是有趣的部分。在这个简短的小节,你将学到:

  • 用自定义的layer子类来支持node;
  • 将隐式动画应用到node layer上。
    这将会确保你移除爆米花特效并最终带来良好的淡入动画。
    创建一个新的Layer子类
    创建一个_ASDisplayLayer的子类命名为AnimatedContentsDisplayLayer,增加如下方法:
    override func actionForKey(event: String!) -> CAAction! {
    if let action = super.actionForKey(event) {
    return action
    }

if event == “contents” && contents == nil {
let transition = CATransition()
transition.duration = 0.6
transition.type = kCATransitionFade
return transition
}

return nil
}
Layer有一个contents属性告诉系统为这个layer绘制什么。AsyncDisplayKit在最终在主线程设置contents之前在后台渲染contents。
这段代码将会添加一个过渡动画,这样contents会淡入到View中。你可以在Apple的Core Animation Programming Guide中找到更多关于隐式Layer动画以及CAAction的信息.。
编译以确保没有错误。
在容器Node中淡入
你已经设置好一个当其contents被设置时淡入的layer,现在来使用。
打开RainforestCardCell.swift,在nodeConstructionOperationWithCardInfo(cardInfo:image:)方法中,在Container Node Creation Section的开头,改动如下:
// REPLACE THIS LINE…
// let containerNode = ASDisplayNode()
// …WITH THIS LINE:
let containerNode = ASDisplayNode(layerClass: AnimatedContentsDisplayLayer.self)
这会告诉容器node使用 AnimatedContentsDisplayLayer实例作为其支持的layer,因此自动带来淡入的效果。
注意:只有 _ASDisplayLayer 的子类才能被异步地绘制。
编译并运行,你将看到容器node会在其绘制好之后淡入出现。

何去何从

恭喜你!当你需要构建高性能的可以滑动的用户界面的时候,你有了另外一个工具。
在本教程里,你通过替换视图层级结构为一个栅格化的AsyncDisplayKit Node层级结构,显著改善了一个性能很差的collection view的滑动性能。多么令人激动!
这只是一个例子而已。AsyncDisplayKit承诺能提高UI性能到一定水平,而通过普通的UIKit优化往往难以达到。
实际说来,要充分利用 AsyncDisplayKit,你需要对标准的UIKit真正的性能瓶颈所在有足够的了解。AsyncDisplayKit很棒的一点是它引发我们探讨这些问题并思考我们的app能如何在物理的极限上更快以及更具响应性。
AsyncDisplayKit是探讨此性能前沿的一个非常强大的工具。明智地使用它。
这仅仅是AsyncDisplayKit的一个开始!它的作者和贡献者每天都在构建新的特性。请关注1.1 版中的ASCollectionView以及ASMultiplexImageNode。从头文件中可看到“ASMultiplexImageNode是一个图像 Node,它能加载并显示一个图像的多个版本。例如,它可以在高分辨率的图像还在渲染时先显示一个低分辨率的图像。” 非常酷,对吧 :]
你可以在此下载最终的Xcode工程
AsyncDisplayKit的指导在这里,AsyncDisplayKit的Github仓库在这里
这个库的作者在收集API设计的反馈。你可以在Facebook上的Paper Engineering Community group中分享你的想法,或者直接参与到AsyncDisplayKit的开发中。