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