AsyncDisplayKit2.0教程(上)

bluepeacock 发布于2年前
0 条问题

原文: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 完全依赖于主线程,对主线程的迟滞极度敏感。任何主线程上的阻塞都会拖累帧速率并导致不顺畅的用户体验。

在主线程中进行的一些大量操作包括:

  • 计算大小和布局:包括 -heightForRowAtIndexPath: 或对 UILabel 上进行 -sizeThatFits 调用,以及大量的自动布局约束的解析。
  • 图片解码:在一个 Image View 上使用 UIImage 就意味着要进行图片数据的解码。
  • 绘图:复杂文本以及手动绘制渐变色和阴影。
  • 对象生命周期:创建、操作和销毁系统对象(比如创建一个 UIView)。

在正确使用的情况下,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 简介

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 个容器类分别是:

  • ASViewController: 一个 UIViewController 子类,允许你创建节点并进行管理。
  • ASCollectionNode 和 ASTableNode: 对应于 UICollectionView 和 UITableView 的 Node 子类,封装了其底层细节。
  • ASPagerNode: 一个 ASCollectionNode 子类,封装了扫动手势,对应于 UIKit 的 UIPageViewController。

这也太简单了,但真正的秘密其实来自于 ASRangeController,这 4 个类都会通过它来影响所包含的节点的行为。现在,请听我说,暂且将那些内容保留到后面解释。

转换 TableView

第一件事情是将目前的 Table View 转成一个 Table 节点。这个过程非常简单。

用 TableNode 替换 Table View

首先,找到 AnimalTableController.m。在import 语句后加入一句:

#import <AsyncDisplayKit/AsyncDisplayKit.h>

这句导入了 ASDK 框架。
然后将 tableView 属性声明:

@property (strong, nonatomic) UITableView *tableView;

替换为 tableNode:

@property (strong, nonatomic) ASTableNode *tableNode;

这会导致许多代码出现错误,别担心!

真的无需担心。这些错误会引导你去完成整个转换,这正是我们想要的。

这些错误位于 viewDidLoad 方法,这是因为 tableView 已经不存在了。我不会教你一步步去将所有的 tableView 实例修改为 tableNode(对于你来收,查找替换工作真的没有任何难度),而应当注意这些地方:

  1. 将一个 ASTableNode 赋给 tableNode 属性。
  2. TableNode 对象没有 registerCalss: forCellReuseIdentifier 方法。
  3. 不需要将一个 Node 添加为一个 subview 。

因此将 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 的委托。

设置 TableNode 的数据也和委托

和 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 能够正常工作。

实现 ASTableDataSource 协议

在 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;
  };
}

代码解释如下:

  1. 一个 ASCellNode 等同于 UITableViewCell 或 UICollectionViewCell。尤其要注意这个方法返回的是一个 ASCellNodeBlock。因为一个 ASTableNode 在内部维护了所有的 cell,为每个 cell 的 Index Path 指定了一个块,这样当需要的时候就能够异步地初始化所有 cell。
  2. 首先,你需要获得一个数据模型对象,以便渲染这个 cell。这已经是固定的套路了。获取数据,然后将它传给后面的闭包。IndexPath 在闭包中不需要,这个例子中在闭包被调用之前数据就会先发生改变。
  3. 然后返回一个闭包,闭包中返回的类型必须是 ASCellNode。
  4. 不需要关心 cell 重用的问题,因此只需要实例化一个 cell。注意,你返回的是一个 CardNode 而不是 CardCell。

这里有一点要注意。你可能也注意到了,使用 ASDK 的时候 cell 不会进行重用。虽然这一点我已经重复过两次了,但在你脑海中一定要有这个意识。你可以回到类的头部将这句删除:

static NSString *kCellReuseIdentifier = @"CellReuseIdentifier";

我们不再需要它了。
等等,这是不是说你永远不用担心 -prepareForReuse 了呢?

实现 ASTableDelegate 协议

回到 AnimalTableController.m 头部,找到下列 Category 定义:

@interface AnimalTableController (Delegate)<UITableViewDelegate>
@end

将 UITableViewDelegate 替换为 ASTableDelegate:

@interface AnimalTableController (Delegate)<ASTableDelegate>
@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 时调用。
代码解释如下:

  • 首先,请求一次新批抓取,以备显示。通常这会从 API 返回一个对象数组。
  • 在完成块中,用新数据刷新表格。
  • 最后,调用 -completeBatchFetching: ,传递一个 YES 表示你已经完成。在本次批抓取完成之前,不会进行新的批抓取。

运行程序,开始滚动。不停滚动,直到你再也不想看到新的鸟类为止。它们是无穷无尽的。

智能化预加载

你曾经写过这样的 App 吗:根据 ScrollView /PageView 的滚动来提前加载数据?可能你写过一个全屏的图片浏览器,总是提前加载下几张图片,这样你的用户基本不会看到空白图片。
如果你想写这样一个程序,你会发现需要考虑的东西太多了。

  • 你使用了多少内存?
  • 你应当提前加载多少数据?
  • 为了响应用户的交互,什么时候该清空数据?

如果内容的大小不固定,你要考虑的问题甚至更加复杂。你有一个 page ViewController,在每个 View Controller 中又有一个 Collection View ?现在你需要考虑如何在两个方向上同时动态加载内容……同时,需要为了支持每一种设备进行调整。

前面我曾经说:“请在脑子中牢记 ASRangeController 是幕后英雄”。现在该让它从幕后转到前台了。

在每个容器类中,每个节点都会有一个“interface 状态”的概念。在任何既定时刻,一个节点都会处于下列状态之一:

  • Preload Range: 这个位置距离“可见”最远。这时 cell 中的所有子节点的内容,比如 ASNetworkImageNode 需要从外部数据源、API 或本地缓存中下载内容。以批抓取为例,这个时候应当抓取模型对象以呈现cell 自身。
  • Display Range: 处于这个位置的时候,将进行显示操作,比如绘制文本和对图片进行解码。
  • Visible Range: 处于这个位置的时候,节点至少在屏幕上有一个像素。

这些位置都能以“整屏”的方式进行计算,还可以轻易地通过 ASRangeTuningParameters 属性进行修改。

例如,如果你用一个 ASNetworkImageNode 来显示照片库中的一页图片。当每一页进入 Preload Range 位置时,都要从网络请求数据,而当它进入 Display Range 位置时又会对已经下载的图片进行解码。

通常,如果你不愿意,你可以不考虑这些 Range。它们已经内置了,比如 SNetworkImageNode 和 ASTextNode, 使用它们,就意味着你可以获得这些好处。

注意:有一点没有说清楚,就是这个 Range 是不可重叠的。它们会在 Visible Range 位置汇集起来。如果你将显示范围和预加载范围设置在一屏内,它们会同时发生。数据通常会被尽可能地呈现,因此预加载范围应当会设置得大一点,以便节点能够在到达可见范围时得到显示。

通常,前置范围会设置得比后置范围大。当用户改变滚动方向,范围的大小发生反转,以便符合用户实际移动的方向。

节点 Interface 状态回调

你会问这些 Range 的工作机制到底是怎样的?非常高兴你能这样问。

系统中的每个节点都有一个 interfaceState 属性,它是一个 “bitfield” (NS_OPTION) 类型 ASInterfaceState。因为 ASCellNode 在 Scroll View 中滚动时,Scroll View 是受 ASRangeController 管理的,每个子节点都会适时改变它的 interfaceState 属性。这样,哪怕层次最深的那个节点也可以根据 interfaceState 的改变做出反应。

幸运的是,几乎不需要直接操纵节点的 interfaceState 属性。通常,你只需要对节点的状态改变进行响应即可。也就是所谓的

对节点进行命名

为了明白节点的状态是怎样变化的,我们需要给它命名。这样,你就可以看到节点是什么时候加载数据,显示内容,进入屏幕,以及它离开时的逆过程。

找到 -tableNode:nodeBlockForRowAtIndexPath: 方法中的这个注释:

//You'll add something extra here later...

在注释的下面,加入下句,给每个 cell 一个 debugName 值。

cardNode.debugName = [NSString stringWithFormat:@"cell %zd", indexPath.row];

现在可以跟踪到每个 cell 进入不同范围的变化过程。

观察 Cell

打开 CardNode_InterfaceCallbacks.m。这里你会看到 6 个方法,这些方法用来打印节点进入各种范围的过程。将它们取消注释,运行程序。确认 Xcode 中的控制台是可见的,然后慢慢滚动表格。同时,观察 cell 状态的改变。

注意:大部分情况下,你只需要用到两个关于 ASInterfaceState 的改变方法:-didEnterVisibleState 和 -didExitVisibleState。也就是说,大量底层的工作已经为你完成了。要想知道什么时候需要用到 Preload 和 Display 状态,请看一下 ASNetworkImageNode 的源代码。所有的 Network Image Node 都会自动抓取和解码,以及释放内存,根本不需要你动一根手指头。

(智能化预加载)2

在 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;
}

代码解释如下:

  1. 虽然这个版本不是闭包风格的,但仍然需要先获取一个数据模型。
  2. 这次,你用到的是强大的 -initWithViewControllerBlock: 构造方法。你需要提供一个闭包,在这个块闭包中返回你前面已经做好的 Table Node Controller,这样它的 View 会自动用作每一页的 View。非常酷!

加完这个方法后,你就拥有一个完整的 Pager 了,它的 cell 来自于你前面完成的那个 Table Node Controller。它拥有二维的预加载给你,用户可以从水平、垂直两个方向滚动。

结束

完整的 AsyncDisplayKit 2.0 教程项目,从这里下载。如果你想看 Swift 版的,也可以从这里下载。

接下来,你可以进入第二部分学习,如何利用 ASDK 2.0 强大的布局系统。

如果在进入第二部分之前想学习更多的内容,你可以浏览 AsyncDisplayKit 的官方主页,并查看它的文档。Scott Goodson (AsyncDisplayKit 的原作者) 也会发表一些你感兴趣的文字,它们按时间顺序罗列在这个地方:AsyncDisplayKit.org Resources

你可能会对Building Paper 大事记 感兴趣。尽管它还没有被开源,并且还有许多东西会变化,但在它才开始的时候就关注它仍然是一件很有意思的事情。

最后,ASDK 社区有一个欢迎新人的活动,它有一个公共的 Slack 频道,任何人都可以加入并进行提问。

希望你喜欢这篇教程,如果有任何问题和建议,请在下面留言。

查看原文:AsyncDisplayKit2.0教程(上)

 

需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。