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