AsyncDisplayKit近一年的使用体会及疑难点
lzxys
7年前
<p>一个第三方库能做到像新产品一样,值得大家去写写使用体会的,并不多见, AsyncDisplayKit 却完全可以,因为 AsyncDisplayKit 不仅仅是一个工具,它更像一个系统UI框架,改变整个编码体验。也正是这种极强的侵入性,导致不少听过、star过,甚至下过demo跑过 AsyncDisplayKit 的你我,望而却步,驻足观望。但列表界面稍微复杂时,烦人的高度计算,因为性能不得不放弃 Autolayout 而选择上古时代的 frame layout ,令人精疲力尽,这时 AsyncDisplayKit 总会不自然浮现眼前,让你跃跃欲试。</p> <p>去年10月份,我们入坑了。</p> <p>当时还只是拿简单的列表页试水,基本上手后,去年底在稍微空闲的时候用 AsyncDisplayKit 重构了帖子详情,今年三月份,又借着公司聊天增加群聊的契机,用 AsyncDisplayKit 重构整个聊天。林林总总,从简单到复杂,踩过的坑大大小小,将近一年的时光转眼飞逝,可以写写总结了。</p> <h2>学习曲线</h2> <p>先说说学习曲线,这是大家都比较关心的问题。</p> <p>跟大多人一样,一开始我以为 AsyncDisplayKit 会像 Rxswift 等 MVVM 框架一样,有着陡峭的学习曲线。但事实上, AsyncDisplayKit 的学习曲线还算平滑。</p> <p>主要是因为 AsyncDisplayKit 只是对 UIKit 的再一次封装,基本沿用了 UIKit 的 API 设计,大部分情况下,只是将 view 改成 node , UI 前缀改为 AS ,写着写着,恍惚间,你以为自己还是在写 UIKit 呢。</p> <p>比如 ASDisplayNode 与 UIView :</p> <pre> <code class="language-objectivec">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() </code></pre> <p>相信你看两眼也就摸出门道了,大部分API一模一样。</p> <p>真正发生翻天覆地变化的是布局方式, AsyncDisplayKit 用的是 flexbox 布局, UIView 使用的是 Autolayout 。用 AsyncDisplayKit 的 flexbox 布局替代 Autolayout 布局,完全不亚于用 Autolayout 替换 frame 布局的蜕变,需要比较大的观念转变。</p> <p>但 flexbox 布局被提出已久,且其本身直观简单,较容易上手,学习曲线只是略陡峭。</p> <p>这里有一个学习 AsyncDisplayKit 布局的 <a href="/misc/goto?guid=4959751246687307237" rel="nofollow,noindex">小游戏</a> ,简单有趣,可以一玩。</p> <p>整体上两天即可上手,无须担心学习曲线问题。</p> <h2>体会</h2> <p>当过了上手的艰难阶段后,才是真正开始体会 AsyncDisplayKit 的时候。用了将近一年,有几点 AsyncDisplayKit 的优势相当明显:</p> <p>1) cell 中再也不用算高度和位置等 frame 信息了</p> <p>这是非常非常非常非常诱人的,当 cell 中有动态文本时,文本的高度计算很费神,计算完,还得缓存,如果再加上其他动态内容,比如有时候没图片,那 frame 算起来,简直让人想哭,而如果用 AsyncDisplayKit ,所有的 height 、 frame 计算都烟消云散,甚至都不知道 frame 这个东西存在过,很酸爽。</p> <p>2)一帧不掉</p> <p>平时界面稍微动态点,元素稍微多点, Autolayout 的性能就不堪重用,而上古时代的 frame 布局在高效缓存的基础上确实可以做到高性能,但 frame 缓存的维护和计算都不是一般的复杂,而 AsyncDisplayKit 却能在保持简介布局的同时,做到一帧不掉,这是多么的让人感动!</p> <p>3)更优雅的架构设计</p> <p>前两点好处是用 AsyncDisplayKit 最直接最容易被感受到的,其实,当深入使用时,你会发现, AsyncDisplayKit 还会给程序架构设计带来一些改变,会使原本复杂的架构变得更简单,更优雅,更灵活,更容易维护,更容易扩展,也会使整个代码更容易理解,而这个影响是深远的,毕竟代码是写给别人看的。</p> <p>但 AsyncDisplayKit 有一个极其著名的问题,闪烁。</p> <p>当我们开始试水使用 AsyncDisplayKit 时,只要简单 reload 一下 TableNode ,那闪烁,眼睛都瞎了。后来查了官方的 issue ,才发现很多人都提了这个问题,但官方也没给出什么优雅的解决方案。要知道,闪烁是非常影响用户体验的。如果非要在不闪烁和带闪烁的 AsyncDisplayKit 中选择,我会毫不犹豫的选择不闪烁,而放弃使用 AsyncDisplayKit 。但现在已经不存在这个选择了,因为经过 AsyncDisplayKit 的多次迭代努力加上一些小技巧, AsyncDisplayKit 的异步闪烁已经被优雅的解决了。</p> <p>但 AsyncDisplayKit 不宜广泛使用,那些高度固定、 UI 简单用 UIKit 更好一些,毕竟 AsyncDisplayKit 并不像 UIKit ,人人都会,如果内容和高度复杂又很动态,强烈推荐 AsyncDisplayKit ,它会简化太多东西。</p> <h2>疑难点</h2> <p>一年的 AsyncDisplayKit 使用经验,踩过了不少坑,遇到了不少值得注意的问题,一并列在这里,以供参考。</p> <h2>ASNetworkImageNode的缓存</h2> <p>ASNetworkImageNode 是对 UIImageView 需要从网络加载图片这一使用场景的封装,省去了 YYWebImage 或者 SDWebImage 等第三方库的引入,只需要设置 URL 即可实现网络图片的自动加载。</p> <pre> <code class="language-objectivec">import AsyncDisplayKit let avatarImageNode = ASNetworkImageNode() avatarImageNode.url = URL(string: "http://shellhue.github.io/images/log.png") </code></pre> <p>这非常省事便捷,但 ASNetworkImageNode 默认用的缓存机制和图片下载器是 PinRemoteImage ,为了使用我们自己的缓存机制和图片下载器,需要实现 ASImageCacheProtocol 图片缓存协议和 ASImageDownloaderProtocol 图片下载器协议两个协议,然后初始化时,用 ASNetworkImageNode 的 init(cache: ASImageCacheProtocol, downloader: ASImageDownloaderProtocol) 初始化方法,传入对应的类,方便其间,一般会自定义一个初始化静态方法。我们公司缓存机制和图片下载器都是用的 YYWebImage ,桥接代码如下。</p> <pre> <code class="language-objectivec">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) } }) } } </code></pre> <h2>闪烁</h2> <p>初次使用 AsyncDisplayKit ,当享受其一帧不掉如丝般柔滑的手感时, ASTableNode 和 ASCollectionNode 刷新时的闪烁一定让你几度崩溃,到 AsyncDisplayKit 的 github 上搜索闪烁相关issue,会出来100多个问题。闪烁是 AsyncDisplayKit 与生俱来的问题,闻名遐迩,而闪烁的体验非常糟糕。幸运的是,几经探索, AsyncDisplayKit 的闪烁问题已经完美解决,这个完美指的是一帧不掉的同时没有任何闪烁,同时也没增加代码的复杂度。</p> <p>闪烁可以分为四类,</p> <h3>1)ASNetworkImageNode reload时的闪烁</h3> <p>当 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 一般都会有,因此不会闪烁。</p> <p>AsyncDisplayKit 官方给的修复思路是:</p> <pre> <code class="language-objectivec">import AsyncDisplayKit let node = ASNetworkImageNode() node.placeholderColor = UIColor.red node.placeholderFadeDuration = 3 </code></pre> <p>这样修改后,确实没有闪烁了,但这只是将 PlaceholderImage —> fetched image 图片替换导致的闪烁拉长到3秒而已,自欺欺人,并没有修复。</p> <p>既然闪烁是 reload 时,没有事先同步检查有无缓存导致的,继承一个 ASNetworkImageNode 的子类,复写 url 设置逻辑:</p> <pre> <code class="language-objectivec">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 } } } } </code></pre> <p>按道理不会闪烁了,但事实上仍然会,只要是个 ASNetworkImageNode ,无论怎么设置,都会闪,这与官方的API说明严重不符,很无语。迫不得已之下,当有缓存时,直接用 ASImageNode 替换 ASNetworkImageNode 。</p> <pre> <code class="language-objectivec">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) } } </code></pre> <p>使用时将 NetworkImageNode 当成 ASNetworkImageNode 使用即可。</p> <h3>2)reload 单个cell时的闪烁</h3> <p>当 reload ASTableNode 或者 ASCollectionNode 的某个 indexPath 的 cell 时,也会闪烁。原因和 ASNetworkImageNode 很像,都是异步惹的祸。当异步计算 cell 的布局时, cell 使用 placeholder 占位(通常是白图),布局完成时,才用渲染好的内容填充 cell , placeholder 到渲染好的内容切换引起闪烁。 UITableViewCell 因为都是同步,不存在占位图的情况,因此也就不会闪。</p> <p>先看官方的修改方案,</p> <pre> <code class="language-objectivec">func tableNode(_ tableNode: ASTableNode, nodeForRowAt indexPath: IndexPath) -> ASCellNode { let cell = ASCellNode() ... // 其他代码 cell.neverShowPlaceholders = true return cell } </code></pre> <p>这个方案非常有效,因为设置 cell.neverShowPlaceholders = true ,会让 cell 从异步状态衰退回同步状态,若 reload 某个 indexPath 的 cell ,在渲染完成之前,主线程是卡死的,这与 UITableView 的机制一样,但速度会比 UITableView 快很多,因为 UITableView 的布局计算、资源解压、视图合成等都是在主线程进行,而 ASTableNode 则是多个线程并发进行,何况布局等还有缓存。所以,一般也没有问题,贝聊的聊天界面只是简单这样设置后,就不闪了,而且一帧不掉。但当页面布局较为复杂时,滑动时的卡顿掉帧就变的肉眼可见。</p> <p>这时,可以设置 ASTableNode 的 leadingScreensForBatching 减缓卡顿</p> <pre> <code class="language-objectivec">override func viewDidLoad() { super.viewDidLoad() ... // 其他代码 tableNode.leadingScreensForBatching = 4 } </code></pre> <p>一般设置 tableNode.leadingScreensForBatching = 4 即提前计算四个屏幕的内容时,掉帧就很不明显了,典型的空间换时间。但仍不完美,仍然会掉帧,而我们期望的是一帧不掉,如丝般顺滑。这不难,基于上面不闪的方案,刷点小聪明就能解决。</p> <pre> <code class="language-objectivec">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) } } </code></pre> <p>关键代码是,</p> <pre> <code class="language-objectivec">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) } }) } </code></pre> <p>即,检查当前的 indexPath 是否被标记,如果是,则先设置 cell.neverShowPlaceholders = true ,等待 reload 完成(一帧是1/60秒,这里等待0.5秒,足够渲染了),将 cell.neverShowPlaceholders = false 。这样 reload 时既不会闪烁,也不会影响滑动时的异步绘制,因此一帧不掉。</p> <p>这完全是耍小聪明的做法,但确实非常有效。</p> <h3>3)reloadData时的闪烁</h3> <p>在下拉刷新后,列表经常需要重新刷新,即调用 ASTableNode 或者 ASCollectionNode 的 reloadData 方法,但会闪,而且很明显。有了单个 cell reload 时闪烁的解决方案后,此类闪烁解决起来,就很简单了。</p> <pre> <code class="language-objectivec">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() ... // 其他代码 } </code></pre> <p>将肉眼可见的 cell 添加进 indexPathesToBeReloaded 中即可。</p> <h3>4)insertItems时更改ASCollectionNode的contentOffset引起的闪烁</h3> <p>我们公司的聊天界面是用 AsyncDisplayKit 写的,当下拉加载更多新消息时,为保持加载后当前消息的位置不变,需要在 collectionNode.insertItems(at: indexPaths) 完成后,复原 collectionNode.view.contentOffset ,代码如下:</p> <pre> <code class="language-objectivec">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) } } </code></pre> <p>遗憾的是,会闪烁。起初以为是 AsyncDisplayKit 异步绘制导致的闪烁,一度还想放弃 AsyncDisplayKit ,用 UITableView 重写一遍,幸运的是,当时项目工期太紧,没有时间重写,也没时间仔细排查,直接带问题上线了。</p> <p>最近闲暇,经仔细排查,方知不是 AsyncDisplayKit 的锅,但也比较难修,有一定的参考价值,因此一并列在这里。</p> <p>闪烁的原因是, collectionNode insertItems 成功后会先绘制 contentOffset 为 CGPoint(x: 0, y: 0) 时的一帧画面,无动画时这一帧画面立即显示,然后调用成功回调,回调中复原了 collectionNode.view.contentOffset ,下一帧就显示复原了位置的画面,前后有变化因此闪烁。这是做消息类APP一并会遇到的bug,google一下,主要有两种解决方案,</p> <p>第一种,通过仿射变换倒置 ASCollectionNode ,这样下拉加载更多,就变成正常列表的上拉加载更多,也就无需移动 contentOffset 。 ASCollectionNode 还特意设置了个属性 inverted ,方便大家开发。然而这种方案换汤不换药,当收到新消息,同时正在查看历史消息,依然需要插入新消息并复原 contentOffset ,闪烁依然在其他情形下发生。</p> <p>第二种,集成一个 UICollectionViewFlowLayout ,重写 prepare() 方法,做相应处理即可。这个方案完美,简介优雅。子类化的 CollectionFlowLayout 如下:</p> <pre> <code class="language-objectivec">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) } } </code></pre> <p>当需要 insertItems 并且保持位置时,将 CollectionFlowLayout 的 isInsertingToTop 设置为 true 即可,完成后再设置为 false 。如下,</p> <pre> <code class="language-objectivec">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 } } ... // 其他代码 } </code></pre> <h2>布局</h2> <p>AsyncDisplayKit 采用的是 flexbox 的布局思想,非常高效直观简洁,但毕竟迥异于 AutoLayout 和 frame layout 的布局风格,咋一上手,很不习惯,有些小技巧还是需要慢慢积累,有些概念也需要逐渐熟悉深入,下面列举几个笔者觉得比较重要的概念</p> <p>1)设置任意间距</p> <p>AutoLayout 实现任意间距,比较容易直观,因为 AutoLayout 的约束,本来就是我的边离你的边有多远的概念,而 AsyncDisplayKit 并没有, AsyncDisplayKit 里面的概念是,我自己的前面有多少空白距离,我自己的后面有多少空白距离,更强调自己。假如有三个元素,怎么约束它们之间的间距?</p> <p>AutoLayout 是这样的:</p> <pre> <code class="language-objectivec">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) } } } </code></pre> <p>而 AsyncDisplayKit 是这样的:</p> <pre> <code class="language-objectivec">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]) } } </code></pre> <p>如果是拿 ASStackLayoutSpec 布局,元素之间的任意间距一般是通过元素自己的 spaceBefore 或者 spaceBefore style 实现,这是自我包裹性,更容易理解,如果不是拿 ASStackLayoutSpec 布局,可以将某个元素包裹成 ASInsetsLayoutSpec ,再设置 UIEdgesInsets ,保持自己的四周任意边距。</p> <p>能任意设置间距是自由布局的基础。</p> <p>2)flexGrow和flexShrink</p> <p>flexGrow 和 flexShrink 是相当重要的概念, flexGrow 是指当有多余空间时,拉伸谁以及相应的拉伸比例(当有多个元素设置了 flexGrow 时), flexShrink 相反,是指当空间不够时,压缩谁及相应的压缩比例(当有多个元素设置了 flexShrink 时)。</p> <p>灵活使用 flexGrow 和 spacer (占位 ASLayoutSpec )可以实现很多效果,比如等间距,</p> <p><img src="https://simg.open-open.com/show/c731eecf9b628e6fbfe31ffd7f728504.png"></p> <p>实现代码如下,</p> <pre> <code class="language-objectivec">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]) } } </code></pre> <p>如果 spacer 的 flexGrow 不同就可以实现指定比例的布局,再结合 width 样式,轻松实现以下布局</p> <p><img src="https://simg.open-open.com/show/f7a690397e54adf874af09c74c6e7a5b.png"></p> <p>布局代码如下,</p> <pre> <code class="language-objectivec">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]) } </code></pre> <p>相同的布局如果用 Autolayout ,麻烦去了。</p> <p>3)constrainedSize的理解</p> <p>constrainedSize 是指某个 node 的大小取值范围,有 minSize 和 maxSize 两个属性。比如下图的布局:</p> <p><img src="https://simg.open-open.com/show/5833bc84010cd3844b9fc6c571e7feab.png"></p> <pre> <code class="language-objectivec">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) } } </code></pre> <p>其中方法 override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec 中的 constrainedSize 所指是 ContainerNode 自身大小的取值范围。给定 constrainedSize , AsyncDisplayKit 会根据 ContainerNode 在 layoutSpecThatFits(_:) 中施加在 nodeA、nodeB 的布局规则和 nodeA、nodeB 自身属性计算 nodeA、nodeB 的 constrainedSize 。</p> <p>假如 constrainedSize 的 minSize 是 CGSize(width: 0, height: 0) , maxSize 为 CGSize(width: 375, height: Inf+) ( Inf+ 为正无限大),则:</p> <p>1)根据布局规则和 nodeA 自身样式属性 maxWidth 、 minWidth 、 width 、 height 、 preferredSize ,可计算出 nodeA 的 constrainedSize 的 minSize 和 maxSize 均为其 preferredSize 即 CGSize(width: 100, height: 100) ,因为布局规则为水平向的 ASStackLayout ,当空间富余或者空间不足时, nodeA 即不压缩又不拉伸,所以会取其指定的 preferredSize 。</p> <p>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 添满富余空间或压缩至空间够为止。</p> <p>如果不指定 nodeB 的 flexShrink 和 flexGrow ,那么当空间富余或者空间不足时, AsyncDisplayKit 就不知道压缩和拉伸哪一个布局元素,则 nodeB 的 constrainedSize 的 maxSize 就变为 CGSize(width: Inf+, height: Inf+) ,即完全无大小限制,可想而知, nodeB 的子 node 的布局将完全不对。这也说明另外一个问题, node 的 constrainedSize 并不是一定大于其子 node 的 constrainedSize 。</p> <p>理解 constrainedSize 的计算,才能熟练利用 node 的样式 maxWidth 、 minWidth 、 width 、 height 、 preferredSize 、 flexShrink 和 flexGrow 进行布局。如果发现布局结果不对,而对应 node 的布局代码确是正确无误,一般极有可能是因为此 node 的父布局元素不正确。</p> <h2>动画</h2> <p>因为 AsyncDisplayKit 的布局方式有两种, frame 布局和 flexbox 式的布局,相应的动画方式也有两种</p> <p>1)frame布局</p> <p>如果采用的是 frame 布局,动画跟普通的 UIView 相同</p> <pre> <code class="language-objectivec">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 } } } </code></pre> <p>不要觉得用了 AsyncDisplayKit 就告别了 frame 布局, ViewController 中主要元素个数很少,布局简单,因此,一般也还是采用 frame layout ,如果只是做一些简单的动画,直接采用 UIView 的动画 API 即可</p> <p>2)flexbox式的布局</p> <p>这种布局方式,是在某个子 node 中常用的,如果 node 内部布局发生了变化,又需要做动画时,就需要复写 AsyncDisplayKit 的动画 API ,并基于提供的动画上下文类 context ,做动画:</p> <pre> <code class="language-objectivec">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 } } } </code></pre> <p>系统默认的动画是渐隐渐显,可以获取 animate 前后布局信息,比如某个子 node 两种布局中的 frame ,然后再自定义动画类型。如果想触发动画,主动调用 SomeNode 的触发方法 transitionLayout(withAnimation:shouldMeasureAsync:measurementCompletion:) 即可。</p> <h2>内存泄漏</h2> <p>为了方便将一个 UIView 或者 CALayer 转化为一个 ASDisplayNode ,系统提供了用 block 初始化 ASDisplayNode 的简便方法:</p> <pre> <code class="language-objectivec">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) </code></pre> <p>需要注意的是所传入的 block 会被要创建的 node 持有。如果 block 中反过来持有了这个 node 的持有者,则会产生循环引用,导致内存泄漏:</p> <pre> <code class="language-objectivec">class SomeNode { var nodeA: ASDisplayNode! let color = UIColor.red override init() { super.init() nodeA = ASDisplayNode { let view = UIView() view.backgroundColor = self.color // 内存泄漏 return view } } } </code></pre> <h2>子线程崩溃</h2> <p>AsyncDisplayKit 的性能优势来源于异步绘制,异步的意思是有时候 node 会在子线程创建,如果继承了一个 ASDisplayNode ,一不小心在初始化时调用了 UIKit 的相关方法,则会出现子线程崩溃。比如以下 node ,</p> <pre> <code class="language-objectivec">class SomeNode { let iconImageNode: ASDisplayNode let color = UIColor.red override init() { iconImageNode = ASImageNode() iconImageNode.image = UIImage(named: "iconName") // 需注意SomeNode有时会在子线程初始化,而UIImage(named:)并不是线程安全 super.init() } } </code></pre> <p>但在 node 初始化时调用 UIImage(named:) 创建图片是不可避免的,用 methodSwizzle 将 UIImage(named:) 置换成安全的即可。</p> <p>其实在子线程初始化 node 并不多见,一般都在主线程。</p> <h2>总结</h2> <p>一年的实践下来,闪烁是 AsyncDisplayKit 遇到的最大的问题,修复起来也颇为费神。其他bug,有时虽然很让人头疼,但由于 AsyncDisplayKit 是对UIKit的再封装,实在不行,仍然可以越过 AsyncDisplayKit 用 UIKit 的方法修复。</p> <p>学习曲线也不算很陡峭。</p> <p>考虑到 AsyncDisplayKit 的种种好处,非常推荐 AsyncDisplayKit ,当然还是仅限于用在比较复杂和动态的页面中。</p> <p> </p> <p>来自:http://qingmo.me/2017/07/21/asyndisplaykit/</p> <p> </p>