原文:AsyncDisplayKit 2.0 Tutorial: Getting Started
作者: Luke Parham
译者:kmyhy
“Art is anything you can do well. Anything you can do with Quality.”
—Robert M. Pirsig
AsyncDisplayKit 是一个 UI 框架,源自 Facebook 的 Paper App。它解决了 Paper 团队面临的一个核心问题:如何让你的主线程尽可能地保持简单干净?
现在,有许多 App 都会严重依赖连续手势和基于物理的动画来产生用户体验。最简单地,你的 UI 也会用到某种形式的 Scroll View。
这类 UI 完全依赖于主线程,对主线程的迟滞极度敏感。任何主线程上的阻塞都会拖累帧速率并导致不顺畅的用户体验。
在主线程中进行的一些大量操作包括:
在正确使用的情况下,AsynDisplayKit 默认允许你以异步方式操作所有的计算大小、布局和绘图操作。不需要进行任何其它的优化,App 就能够大量减少需要在主线程上进行的工作。
除了性能上的提升,先进的 AsyncDisplayKit 还能为开发者带来一系列明显的福利,允许你用最少的代码实现复杂的、微妙的界面。
在这个共分为两部分的 AsyncDisplayKit 2.0 教程中,你将学到如何用 ASDK 构建一个实用的、动态的 App。在第一部分,你会从大体上了解 App 构建时会用到的一些东西。在第二部分,你会学到如何创建自己的节点类,以及如何实用 ASDK 强大的布局引擎。为了完成这个教程,你必须使用 Xcode7.3 并熟悉 Objective-C。
说明:ASDK 和 IB 和自动布局不兼容,因此在本教程中你无法使用它们。尽管 ASDK 完全支持 Swift(和 ComponentKit 有一点区别),但许多用户仍然使用 Objective-C 。同时,在前一百个免费 App 中,大部分都不是用 Swift 些的(其中至少有 6 个使用了 ASDK)。基于这个原因,本教程使用了 Objective-C。但是,我们也包含了一个用 Swift 写的 Demo 项目给你选择。
请在此处下载开始项目。
这个项目使用了 CocoaPods 来安装 AsyncDisplayKit。因此,请打开 RainforestStarter.xcworkspace 而不是 RainforestStarter.xcodeproj。
注意:本教程中需要用到网络连接。
运行程序,App 中包含了一个 UITableView,显示了一个野生动物的列表。如果查看 AnimalTableController 的代码,你会看到它只是一个普通的 UITableViewController 类,和你之前曾经看过无数次的没有任何区别。
注意:请在真机上而不是模拟器上进行测试。
滚动表格,注意,帧率开始下降。你不需要打开 Instruments 就会感觉到这个 App 明显的需要进行性能上调优。
你可以用 AsyncDisplayKit 来解决这个问题。
ASDisplayNode 是 ASDK 的核心类,甚至可以说是“心脏”,就像 MVC 中的 view,可以看做是另一种 UIView 或 CALayer。理解一个“节点”对象的最好方法是参考 UIView 和 CALayer 的关系,你对此应该很熟悉了。
在一个 iOS App 中,屏幕上的每一个对象都表示了一个 CALayer 对象。UIView 私底下会创建和拥有一个 CALayer,通过这个 CALayer 来感知触摸或其他功能。UIView 自身并不是 CALayer 子类。相反,它包含了一个 CALayer 对象,并为它添加了一些功能。
这种概念也沿袭进了 ASDisplayNode:你可以认为它包含了一个 view,就好比 view 包含了一个 layer。
将节点通过一个普通的 View 放到表格上,最终使它们能够从后台队列中创建和配置,默认情况下,它们会被异步渲染。
幸运的是,这个 API 处理节点的方式和使用 UIView 或 CALayer 差不多。所有的 View 属性都可以在节点类上找到相同的属性。你还可访问底层的 view 或 layer——就像你可以通过 .layer 访问 UIView 的 layer 一样。
要让节点对象尽可能地提升性,必须将它和 4 个容器类协同工作。
这 4 个容器类分别是:
这也太简单了,但真正的秘密其实来自于 ASRangeController,这 4 个类都会通过它来影响所包含的节点的行为。现在,请听我说,暂且将那些内容保留到后面解释。
第一件事情是将目前的 Table View 转成一个 Table 节点。这个过程非常简单。
首先,找到 AnimalTableController.m。在import 语句后加入一句:
#import
这句导入了 ASDK 框架。
然后将 tableView 属性声明:
@property (strong, nonatomic) UITableView *tableView;
替换为 tableNode:
@property (strong, nonatomic) ASTableNode *tableNode;
这会导致许多代码出现错误,别担心!
真的无需担心。这些错误会引导你去完成整个转换,这正是我们想要的。
这些错误位于 viewDidLoad 方法,这是因为 tableView 已经不存在了。我不会教你一步步去将所有的 tableView 实例修改为 tableNode(对于你来收,查找替换工作真的没有任何难度),而应当注意这些地方:
因此将 viewDidLoad 方法修改为:
- (void)viewDidLoad {
[super viewDidLoad];
[self.view addSubnode:self.tableNode];
[self applyStyle];
}
注意,我们在 UIVIew 上 addSubnode: 方法,这个方法是通过 Category 的方式被添加到 UIView 上的。它实际上等于:
[self.view addSubview:self.tableNode.view];
然后,修改 -viewWillLayoutSubviews 方法:
- (void)viewWillLayoutSubviews {
[super viewWillLayoutSubviews];
self.tableNode.frame = self.view.bounds;
}
其实就是将 self.tableView 替换为 self.tableNode 以设置表格的 frame。
然后,找到 -applyStyle 方法,修改为:
- (void)applyStyle {
self.view.backgroundColor = [UIColor blackColor];
self.tableNode.view.separatorStyle = UITableViewCellSeparatorStyleNone;
}
改变的地方只有设置表格 separatorStyle 属性这一行。注意,为了设置表格的 separatorStyle 属性,我们必须访问 tableNode 的 view 属性。ASTableNode 并没有将 UITableView 的所有属性都暴露出来,因此为了访问 UITableView 的某些属性,你必须访问位于 table node 底下的 UITableView 对象。
然后,在 initWithAnimals 方法的最上面加入这句:
_tableNode = [[ASTableNode alloc] initWithStyle:UITableViewStylePlain];
然后将这句加到 return 之前:
[self wireDelegation];
在这个构造方法中,用一个 table node 进行构造,并调用 wireDelegate 方法设置Table Node 的委托。
和 UITableView 一样,ASTableNode 也使用了数据源和委托来获得相关信息。Table Node 的 ASTableDataSource 和 ASTableDelegate 协议和 UITableViewDataSource 和 UITableViewDelegate 协议非常类似。事实上,它们的方法定义都很像,比如 -tableNode:numberOfRowsInSection:。当然,这两套协议也不是完全没有区别,因为 ASTableNode 的行为和 UITableView 多少还是有一点不同。
找到 -wireDelegation 并将 tableView 修改为 tableNode:
- (void)wireDelegation {
self.tableNode.dataSource = self;
self.tableNode.delegate = self;
}
现在, Xcoe 会抱怨 AnimalTableController 没有遵循相应的协议。AnimalTableController 遵循的还是 UITableViewDataSource 和 UITableViewDelegate 协议。在下一节,你将实现两个正确的协议,以使 TableNode 能够正常工作。
在 AnimalTableController.m 的头部,找到 DataSource 这个 Category 的接口声明:
@interface AnimalTableController (DataSource)<UITableViewDataSource>
@end
将 UITableViewDataSource 替换成 ASTableDataSource:
@interface AnimalTableController (DataSource)<ASTableDataSource>
@end
现在 AnimalTableController 声明采用 ASTableDataSource 协议,接下来我们实现这个协议。
在 AnimalTableController.m 底部找到 DataSource category 的实现。
首先,将 UITableViewDataSource 方法 -tableView:numberOfRowsInSection: 修改为 ASTableDataSource 协议的版本。
- (NSInteger)tableNode:(ASTableNode *)tableNode numberOfRowsInSection:(NSInteger)section {
return self.animals.count;
}
然后,ASTableNodes 返回 cell 的方式和 UITableView 有所不同。将 -tableView:cellForRowAtIndexPath: 方法替换为:
//1
- (ASCellNodeBlock)tableNode:(ASTableView *)tableView nodeBlockForRowAtIndexPath:(NSIndexPath *)indexPath {
//2
RainforestCardInfo *animal = self.animals[indexPath.row];
//3
return ^{
//4
CardNode *cardNode = [[CardNode alloc] initWithAnimal:animal];
//You'll add something extra here later...
return cardNode;
};
}
代码解释如下:
这里有一点要注意。你可能也注意到了,使用 ASDK 的时候 cell 不会进行重用。虽然这一点我已经重复过两次了,但在你脑海中一定要有这个意识。你可以回到类的头部将这句删除:
static NSString *kCellReuseIdentifier = @"CellReuseIdentifier";
我们不再需要它了。
等等,这是不是说你永远不用担心 -prepareForReuse 了呢?
回到 AnimalTableController.m 头部,找到下列 Category 定义:
@interface AnimalTableController (Delegate)
@end
将 UITableViewDelegate 替换为 ASTableDelegate:
@interface AnimalTableController (Delegate)
@end
现在 AnimalTableController 声明遵循了 ASTableDelegate 协议,接下来我们实现这个协议。找到 AnimalTableController.m 底部的 Delegate 类别的实现。
相信你也知道了,在使用 UITableView 的时候通常都需要实现一个 -tableView:heightForRowAtIndexPath: 方法。这是因为 UIKit 是通过这个委托方法来计算每个 cell 的高度的。
ASTableDelegate 中没有 -tableView:heightForRowAtIndexPath: 方法。如果使用 ASDK, 所有的 ASCellNodes 都自己负责计算它们的大小。不需要提供一个固定的高度,你可以为每个 cell 指定一个最大尺寸和最小尺寸。这个例子中,你需要让每个 cell 至少占据屏幕 2/3 的高度。
现在我们暂时不讨论这个,细节会在第二部分进行讨论。
现在,将 -tableView:heightForRowAtIndexPath: 方法替换为:
- (ASSizeRange)tableView:(ASTableView *)tableNode
constrainedSizeForRowAtIndexPath:(NSIndexPath *)indexPath {
CGFloat width = [UIScreen mainScreen].bounds.size.width;
CGSize min = CGSizeMake(width, ([UIScreen mainScreen].bounds.size.height/3) * 2);
CGSize max = CGSizeMake(width, INFINITY);
return ASSizeRangeMake(min, max);
}
大部分工作都完成了,运行一下程序看看效果。
表格滚动非常流利!稍微冷静一下,准备进一步的改进!
在大部分 App 中,服务器上拥有的数据远远多于在表格中一次能显示下的 cell 的行数。也就是说,每个 App 都应采用某些机制,来保证用户浏览到当前数据集结尾时,随时从服务器上拉取新的数据。
过去,这只能通过 Scroll View 的委托方法 -scrollViewDidScroll: 来进行手动处理。在 ASDK 中,有一种更明确的解决方式。
你可以预先指定多少页,才需要加载新的数据。
首先,取消被注释的助手方法。找到 AnimalTableController.m 最后,取消 Helpers 类别中的两个方法注释。-retrieveNextPageWithCompletion: 方法可以看出是网络调用,而 -insertNewRowsInTableNode: 方法只是一个一般方法,将新的数据添加到表格中。
首先,在 -viewDidLoad: 方法中加入一句:
self.tableNode.view.leadingScreensForBatching = 1.0; // 默认值是 2.0
将 leadingScreensForBatching 设置为 1.0 表示你当用户滚动还剩 1 个全屏就到达数据末尾时,就开始抓取新的一批数据。
然后,在 Delegate 类别中加入这个方法:
- (BOOL)shouldBatchFetchForTableNode:(ASTableNode *)tableNode {
return YES;
}
这个方法告诉表格,在这次批抓取之后是否还可以进行新的批抓取。如果你知道 API 上的数据什么时候结束,返回 NO,表示不用进行新的批抓取了。
因为你想让表格无限滚动,所以返回 YES,表示永远能够进行新的批抓取。
然后,继续加入:
- (void)tableNode:(ASTableNode *)tableNode willBeginBatchFetchWithContext:(ASBatchContext *)context {
//1
[self retrieveNextPageWithCompletion:^(NSArray *animals) {
//2
[self insertNewRowsInTableNode:animals];
//3
[context completeBatchFetching:YES];
}];
}
这个方法在用户即将滚动到表格末尾并且 -shouldBatchFetchForTableNode: 方法返回 YES 时调用。
代码解释如下:
运行程序,开始滚动。不停滚动,直到你再也不想看到新的鸟类为止。它们是无穷无尽的。
你曾经写过这样的 App 吗:根据 ScrollView /PageView 的滚动来提前加载数据?可能你写过一个全屏的图片浏览器,总是提前加载下几张图片,这样你的用户基本不会看到空白图片。
如果你想写这样一个程序,你会发现需要考虑的东西太多了。
如果内容的大小不固定,你要考虑的问题甚至更加复杂。你有一个 page ViewController,在每个 View Controller 中又有一个 Collection View ?现在你需要考虑如何在两个方向上同时动态加载内容……同时,需要为了支持每一种设备进行调整。
前面我曾经说:“请在脑子中牢记 ASRangeController 是幕后英雄”。现在该让它从幕后转到前台了。
在每个容器类中,每个节点都会有一个“interface 状态”的概念。在任何既定时刻,一个节点都会处于下列状态之一:
这些位置都能以“整屏”的方式进行计算,还可以轻易地通过 ASRangeTuningParameters 属性进行修改。
例如,如果你用一个 ASNetworkImageNode 来显示照片库中的一页图片。当每一页进入 Preload Range 位置时,都要从网络请求数据,而当它进入 Display Range 位置时又会对已经下载的图片进行解码。
通常,如果你不愿意,你可以不考虑这些 Range。它们已经内置了,比如 SNetworkImageNode 和 ASTextNode, 使用它们,就意味着你可以获得这些好处。
注意:有一点没有说清楚,就是这个 Range 是不可重叠的。它们会在 Visible Range 位置汇集起来。如果你将显示范围和预加载范围设置在一屏内,它们会同时发生。数据通常会被尽可能地呈现,因此预加载范围应当会设置得大一点,以便节点能够在到达可见范围时得到显示。
通常,前置范围会设置得比后置范围大。当用户改变滚动方向,范围的大小发生反转,以便符合用户实际移动的方向。
你会问这些 Range 的工作机制到底是怎样的?非常高兴你能这样问。
系统中的每个节点都有一个 interfaceState 属性,它是一个 “bitfield” (NS_OPTION) 类型 ASInterfaceState。因为 ASCellNode 在 Scroll View 中滚动时,Scroll View 是受 ASRangeController 管理的,每个子节点都会适时改变它的 interfaceState 属性。这样,哪怕层次最深的那个节点也可以根据 interfaceState 的改变做出反应。
幸运的是,几乎不需要直接操纵节点的 interfaceState 属性。通常,你只需要对节点的状态改变进行响应即可。也就是所谓的 Interface 状态回调。
为了明白节点的状态是怎样变化的,我们需要给它命名。这样,你就可以看到节点是什么时候加载数据,显示内容,进入屏幕,以及它离开时的逆过程。
找到 -tableNode:nodeBlockForRowAtIndexPath: 方法中的这个注释:
//You'll add something extra here later...
在注释的下面,加入下句,给每个 cell 一个 debugName 值。
cardNode.debugName = [NSString stringWithFormat:@"cell %zd", indexPath.row];
现在可以跟踪到每个 cell 进入不同范围的变化过程。
打开 CardNode_InterfaceCallbacks.m。这里你会看到 6 个方法,这些方法用来打印节点进入各种范围的过程。将它们取消注释,运行程序。确认 Xcode 中的控制台是可见的,然后慢慢滚动表格。同时,观察 cell 状态的改变。
注意:大部分情况下,你只需要用到两个关于 ASInterfaceState 的改变方法:-didEnterVisibleState 和 -didExitVisibleState。也就是说,大量底层的工作已经为你完成了。要想知道什么时候需要用到 Preload 和 Display 状态,请看一下 ASNetworkImageNode 的源代码。所有的 Network Image Node 都会自动抓取和解码,以及释放内存,根本不需要你动一根手指头。
在 2.0 版中,增加了智能化预加载的多方向支持。也就是你可以在垂直方向上滚动表格,当 cell 来到屏幕上的某个位置时,cell 会包含一个水平滚动的 Collection View。
虽然 Collection View 明显已经位于可见区域,但你并不想将整个 Collection View 都加载。为此,你可以让每个 Scroll View 分别拥有各自的 ASRangeController,每个 ASRangeController 的 Range Tuning Parameters 是分开配置的。
现在你已经完成了 AnimalTableController,你可以把它用在一个 ASPageNode 中作为一页来使用。
用于包含这个页的 ViewController 已经在项目中了,因此第一件事情就是打开 AppDelegate.m。
找到 -installRootViewController 方法将这句:
AnimalTableController *vc = [[AnimalTableController alloc]
initWithAnimals:[RainforestCardInfo allAnimals]];
替换为:
AnimalPagerController *vc = [[AnimalPagerController alloc] init];
然后打开 AnimalPagerController.m 在构造函数中 return 语句之前加入代码。你所需要做的仅仅是创建一个新的 pager,并将它作为 View Controller 的数据源:
_pagerNode = [[ASPagerNode alloc] init];
_pagerNode.dataSource = self;
Pager Node 实际上是一个配置好的 ASCollectionNode 子类,这和你曾经在 UIPageViewController 的用法是一样的。好消息是,这个 API 比起 UIPageViewController 来说还要简单一些。
接下来你必须实现 Pager 的数据源方法。找到 ASPagerDataSource 这个类别的实现,就在文件的底部。
首先,告诉 Pager 它的页数等于动物数组的元素个数,这里,-numberOfPagesInPagerNode: 方法中原来的 3 被替换为:
- (NSInteger)numberOfPagesInPagerNode:(ASPagerNode *)pagerNode {
return self.animals.count;
}
然后,实现 -pagerNode:nodeAtIndex:方法,这和早先你实现过的 ASTableNode 方法差不多:
- (ASCellNode *)pagerNode:(ASPagerNode *)pagerNode nodeAtIndex:(NSInteger)index {
//1
CGSize pagerNodeSize = pagerNode.bounds.size;
NSArray *animals = self.animals[index];
//2
ASCellNode *node = [[ASCellNode alloc] initWithViewControllerBlock:^{
return [[AnimalTableController alloc] initWithAnimals:animals];
} didLoadBlock:nil];
return node;
}
代码解释如下:
加完这个方法后,你就拥有一个完整的 Pager 了,它的 cell 来自于你前面完成的那个 Table Node Controller。它拥有二维的预加载给你,用户可以从水平、垂直两个方向滚动。
完整的 AsyncDisplayKit 2.0 教程项目,从这里下载。如果你想看 Swift 版的,也可以从这里下载。
接下来,你可以进入第二部分学习,如何利用 ASDK 2.0 强大的布局系统。
如果在进入第二部分之前想学习更多的内容,你可以浏览 AsyncDisplayKit 的官方主页,并查看它的文档。Scott Goodson (AsyncDisplayKit 的原作者) 也会发表一些你感兴趣的文字,它们按时间顺序罗列在这个地方:AsyncDisplayKit.org Resources。
你可能会对Building Paper 大事记 感兴趣。尽管它还没有被开源,并且还有许多东西会变化,但在它才开始的时候就关注它仍然是一件很有意思的事情。
最后,ASDK 社区有一个欢迎新人的活动,它有一个公共的 Slack 频道,任何人都可以加入并进行提问。
希望你喜欢这篇教程,如果有任何问题和建议,请在下面留言。