前两次的分享分别介绍了 ASDK 对于渲染的优化以及 ASDK 中使用的另外一种布局模型;这两个新机制的引入分别解决了 iOS 在主线程渲染视图以及 Auto Layout 的性能问题,而这一次讨论的主要内容是 ASDK 如何预先请求服务器数据,达到看似无限滚动列表的效果的。node
这篇文章是 ASDK 系列中的最后一篇,文章会介绍 iOS 中几种预加载的方案,以及 ASDK 中是如何处理预加载的。git
不过,在介绍 ASDK 中实现智能预加载的方式以前,文章中会介绍几种简单的预加载方式,方便各位开发者进行对比,选择合适的机制实现预加载这一功能。github
ASDK 经过在渲染视图和布局方面的优化已经可使应用在任何用户的疯狂操做下都能保持 60 FPS 的流畅程度,也就是说,咱们已经充分的利用了当前设备的性能,调动各类资源加快视图的渲染。swift
可是,仅仅在 CPU 以及 GPU 方面的优化每每是远远不够的。在目前的软件开发中,很难找到一个没有任何网络请求的应用,哪怕是一个记帐软件也须要服务器来同步保存用户的信息,防止资料的丢失;因此,只在渲染这一层面进行优化还不能让用户的体验达到最佳,由于网络请求每每是一个应用最为耗时以及昂贵的操做。数组
每个应用程序在运行时均可以看作是 CPU 在底层利用各类资源疯狂作加减法运算,其中最耗时的操做并非进行加减法的过程,而是资源转移的过程。缓存
举一个不是很恰当的例子,主厨(CPU)在炒一道菜(计算)时每每须要的时间并很少,可是菜的采购以及准备(资源的转移)会占用大量的时间,若是在每次炒菜以前,都由帮厨提早准备好全部的食材(缓存),那么作一道菜的时间就大大减小了。服务器
而提升资源转移的效率的最佳办法就是使用多级缓存:网络
从上到下,虽然容量愈来愈大,直到 Network 层包含了整个互联网的内容,可是访问时间也是直线上升;在 Core 或者三级缓存中的资源可能访问只须要几个或者几十个时钟周期,可是网络中的资源就远远大于这个数字,几分钟、几小时都是有可能的。app
更糟糕的是,由于天朝的网络状况及其复杂,运营商劫持 DNS、404 没法访问等问题致使网络问题极其严重;而如何加速网络请求成为了不少移动端以及 Web 应用的重要问题。异步
本文就会提供一种缓解网络请求缓慢致使用户体验较差的解决方案,也就是预加载;在本地真正须要渲染界面以前就经过网络请求获取资源存入内存或磁盘。
预加载并不能完全解决网络请求缓慢的问题,而是经过提早发起网络请求缓解这一问题。
那么,预加载到底要关注哪些方面的问题呢?总结下来,有如下两个关注点:
须要预加载的资源
预加载发出的时间
文章会根据上面的两个关注点,分别分析四种预加载方式的实现原理以及优缺点:
无限滚动列表
threshold
惰性加载
智能预加载
其实,无限滚动列表并不能算是一种预加载的实现原理,它只是提供一种分页显示的方法,在每次滚动到 UITableView
底部时,才会开始发起网络请求向服务器获取对应的资源。
虽然这种方法并非预加载方式的一种,放在这里的主要做用是做为对比方案,看看若是不使用预加载的机制,用户体验是什么样的。
不少客户端都使用了分页的加载方式,并无添加额外的预加载的机制来提高用户体验,虽然这种方式并非不能接受,不过每次滑动到视图底部以后,总要等待网络请求的完成确实对视图的流畅性有必定影响。
虽然仅仅使用无限滚动列表而不提供预加载机制会在必定程度上影响用户体验,不过,这种须要用户等待几秒钟的方式,在某些时候确实很是好用,好比:投放广告。
QQ 空间就是这么作的,它们投放的广告基本都是在整个列表的最底端,这样,当你滚动到列表最下面的时候,就能看到你急需的租房、租车、同城交友、信用卡办理、只有 iPhone 能玩的游戏以及各类奇奇怪怪的辣鸡广告了,很好的解决了咱们的平常生活中的各类需求。(哈哈哈哈哈哈哈哈哈哈哈哈哈)
使用 Threshold 进行预加载是一种最为常见的预加载方式,知乎客户端就使用了这种方式预加载条目,而其原理也很是简单,根据当前 UITableView
的所在位置,除以目前整个 UITableView.contentView
的高度,来判断当前是否须要发起网络请求:
let threshold: CGFloat = 0.7 var currentPage = 0 override func scrollViewDidScroll(_ scrollView: UIScrollView) { let current = scrollView.contentOffset.y + scrollView.frame.size.height let total = scrollView.contentSize.height let ratio = current / total if ratio >= threshold { currentPage += 1 print("Request page \(currentPage) from server.") } }
上面的代码在当前页面已经划过了 70% 的时候,就请求新的资源,加载数据;可是,仅仅使用这种方法会有另外一个问题,尤为是当列表变得很长时,十分明显,好比说:用户从上向下滑动,总共加载了 5 页数据:
Page 当前总页数;
Total 当前 UITableView
总元素个数;
Threshold 网络请求触发时间;
Diff 表示最新加载的页面被浏览了多少;
当 Threshold 设置为 70% 的时候,其实并非单页 70%,这就会致使新加载的页面都没有看,应用就会发出另外一次请求,获取新的资源。
解决这个问题的办法,仍是比较简单的,经过修改上面的代码,将 Threshold 变成一个动态的值,随着页数的增加而增加:
let threshold: CGFloat = 0.7 let itemPerPage: CGFloat = 10 var currentPage: CGFloat = 0 override func scrollViewDidScroll(_ scrollView: UIScrollView) { let current = scrollView.contentOffset.y + scrollView.frame.size.height let total = scrollView.contentSize.height let ratio = current / total let needRead = itemPerPage * threshold + currentPage * itemPerPage let totalItem = itemPerPage * (currentPage + 1) let newThreshold = needRead / totalItem if ratio >= newThreshold { currentPage += 1 print("Request page \(currentPage) from server.") } }
经过这种方法获取的 newThreshold
就会随着页数的增加而动态的改变,解决了上面出现的问题:
使用 Threshold 进行预加载其实已经适用于大多数应用场景了;可是,下面介绍的方式,惰性加载可以有针对性的加载用户“会看到的” Cell。
惰性加载,就是在用户滚动的时候会对用户滚动结束的区域进行计算,只加载目标区域中的资源。
用户在飞速滚动中会看到巨多的空白条目,由于用户并不想阅读这些条目,因此,咱们并不须要真正去加载这些内容,只须要在 ASTableView/ASCollectionView
中只根据用户滚动的目标区域惰性加载资源。
惰性加载的方式不只仅减小了网络请求的冗余资源,同时也减小了渲染视图、数据绑定的耗时。
计算用户滚动的目标区域能够直接使用下面的代理方法获取:
let markedView = UIView() let rowHeight: CGFloat = 44.0 override func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) { let targetOffset = targetContentOffset.pointee let targetRect = CGRect(origin: targetOffset, size: scrollView.frame.size) markedView.frame = targetRect markedView.backgroundColor = UIColor.black.withAlphaComponent(0.1) tableView.addSubview(markedView) var indexPaths: [IndexPath] = [] let startIndex = Int(targetRect.origin.y / rowHeight) let endIndex = Int((targetRect.origin.y + tableView.frame.height) / rowHeight) for index in startIndex...endIndex { indexPaths.append(IndexPath(row: index, section: 0)) } print("\(targetRect) \(indexPaths)") }
以上代码只会大体计算出目标区域内的
IndexPath
数组,并不会展开新的 page,同时会使用浅黑色标记目标区域。
固然,惰性加载的实现也并不仅是这么简单,不只须要客户端的工做,同时由于须要加载特定 offset 资源,也须要服务端提供相应 API 的支持。
虽然惰性加载的方式可以按照用户的须要请求对应的资源,可是,在用户滑动 UITableView
的过程当中会看到大量的空白条目,这样的用户体验是否能够接受又是值得考虑的问题了。
终于到了智能预加载的部分了,当我第一次得知 ASDK 能够经过滚动的方向预加载不一样数量的内容,感受是很是神奇的。
<img src="http://img.draveness.me/2016-... height=500>
如上图所示 ASDK 把正在滚动的 ASTableView/ASCollectionView
划分为三种状态:
Fetch Data
Display
Visible
上面的这三种状态都是由 ASDK 来管理的,而每个 ASCellNode
的状态都是由 ASRangeController
控制,全部的状态都对应一个 ASInterfaceState
:
ASInterfaceStatePreload
当前元素貌似要显示到屏幕上,须要从磁盘或者网络请求数据;
ASInterfaceStateDisplay
当前元素很是可能要变成可见的,须要进行异步绘制;
ASInterfaceStateVisible
当前元素最少在屏幕上显示了 1px
当用户滚动当前视图时,ASRangeController
就会修改不一样区域内元素的状态:
<img src="http://img.draveness.me/2016-... height=500>
上图是用户在向下滑动时,ASCellNode
是如何被标记的,假设当前视图可见的范围高度为 1,那么在默认状况下,五个区域会按照上图的形式进行划分:
在滚动方向(Leading)上 Fetch Data 区域会是非滚动方向(Trailing)的两倍,ASDK 会根据滚动方向的变化实时改变缓冲区的位置;在向下滚动时,下面的 Fetch Data 区域就是上面的两倍,向上滚动时,上面的 Fetch Data 区域就是下面的两倍。
这里的两倍并非一个肯定的数值,ASDK 会根据当前设备的不一样状态,改变不一样区域的大小,可是滚动方向的区域总会比非滚动方向大一些。
智能预加载可以根据当前的滚动方向,自动改变当前的工做区域,选择合适的区域提早触发请求资源、渲染视图以及异步布局等操做,让视图的滚动达到真正的流畅。
在 ASDK 中整个智能预加载的概念是由三个部分来统一协调管理的:
ASRangeController
ASDataController
ASTableView
与 ASTableNode
对智能预加载实现的分析,也是根据这三个部分来介绍的。
ASRangeController
是 ASTableView
以及 ASCollectionView
内部使用的控制器,主要用于监控视图的可见区域、维护工做区域、触发网络请求以及绘制、单元格的异步布局。
以 ASTableView
为例,在视图进行滚动时,会触发 -[UIScrollView scrollViewDidScroll:]
代理方法:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView { ASInterfaceState interfaceState = [self interfaceStateForRangeController:_rangeController]; if (ASInterfaceStateIncludesVisible(interfaceState)) { [_rangeController updateCurrentRangeWithMode:ASLayoutRangeModeFull]; } ... }
每个
ASTableView
的实例都持有一个ASRangeController
以及ASDataController
用于管理工做区域以及数据更新。
ASRangeController 最重要的私有方法 -[ASRangeController _updateVisibleNodeIndexPaths]
通常都是由于上面的方法间接调用的:
-[ASRangeController updateCurrentRangeWithMode:] -[ASRangeController setNeedsUpdate] -[ASRangeController updateIfNeeded] -[ASRangeController _updateVisibleNodeIndexPaths]
调用栈中间的过程其实并不重要,最后的私有方法的主要工做就是计算不一样区域内 Cell 的 NSIndexPath
数组,而后更新对应 Cell 的状态 ASInterfaceState
触发对应的操做。
咱们将这个私有方法的实现分开来看:
- (void)_updateVisibleNodeIndexPaths { NSArray<NSArray *> *allNodes = [_dataSource completedNodes]; NSUInteger numberOfSections = [allNodes count]; NSArray<NSIndexPath *> *visibleNodePaths = [_dataSource visibleNodeIndexPathsForRangeController:self]; ASScrollDirection scrollDirection = [_dataSource scrollDirectionForRangeController:self]; if (_layoutControllerImplementsSetViewportSize) { [_layoutController setViewportSize:[_dataSource viewportSizeForRangeController:self]]; } if (_layoutControllerImplementsSetVisibleIndexPaths) { [_layoutController setVisibleNodeIndexPaths:visibleNodePaths]; } ... }
当前 ASRangeController
的数据源以及代理就是 ASTableView
,这段代码首先就获取了完成计算和布局的 ASCellNode
以及可见的 ASCellNode
的 NSIndexPath
:
- (void)_updateVisibleNodeIndexPaths { NSArray<ASDisplayNode *> *currentSectionNodes = nil; NSInteger currentSectionIndex = -1; NSUInteger numberOfNodesInSection = 0; NSSet<NSIndexPath *> *visibleIndexPaths = [NSSet setWithArray:visibleNodePaths]; NSSet<NSIndexPath *> *displayIndexPaths = nil; NSSet<NSIndexPath *> *preloadIndexPaths = nil; NSMutableOrderedSet<NSIndexPath *> *allIndexPaths = [[NSMutableOrderedSet alloc] initWithSet:visibleIndexPaths]; ASLayoutRangeMode rangeMode = _currentRangeMode; ASRangeTuningParameters parametersPreload = [_layoutController tuningParametersForRangeMode:rangeMode rangeType:ASLayoutRangeTypePreload]; if (ASRangeTuningParametersEqualToRangeTuningParameters(parametersPreload, ASRangeTuningParametersZero)) { preloadIndexPaths = visibleIndexPaths; } else { preloadIndexPaths = [_layoutController indexPathsForScrolling:scrollDirection rangeMode:rangeMode rangeType:ASLayoutRangeTypePreload]; } #: displayIndexPaths 的计算和 preloadIndexPaths 很是相似 [allIndexPaths unionSet:displayIndexPaths]; [allIndexPaths unionSet:preloadIndexPaths]; ... }
预加载以及展现部分的 ASRangeTuningParameters
都是以二维数组的形式保存在 ASAbstractLayoutController
中的:
在获取了 ASRangeTuningParameters
以后,ASDK 也会经过 ASFlowLayoutController
的方法 -[ASFlowLayoutController indexPathsForScrolling:rangeMode:rangeType:]
获取 NSIndexPath
对象的集合:
- (NSSet *)indexPathsForScrolling:(ASScrollDirection)scrollDirection rangeMode:(ASLayoutRangeMode)rangeMode rangeType:(ASLayoutRangeType)rangeType { #: 获取 directionalBuffer 以及 viewportDirectionalSize ASIndexPath startPath = [self findIndexPathAtDistance:(-directionalBuffer.negativeDirection * viewportDirectionalSize) fromIndexPath:_visibleRange.start]; ASIndexPath endPath = [self findIndexPathAtDistance:(directionalBuffer.positiveDirection * viewportDirectionalSize) fromIndexPath:_visibleRange.end]; NSMutableSet *indexPathSet = [[NSMutableSet alloc] init]; NSArray *completedNodes = [_dataSource completedNodes]; ASIndexPath currPath = startPath; while (!ASIndexPathEqualToIndexPath(currPath, endPath)) { [indexPathSet addObject:[NSIndexPath indexPathWithASIndexPath:currPath]]; currPath.row++; while (currPath.row >= [(NSArray *)completedNodes[currPath.section] count] && currPath.section < endPath.section) { currPath.row = 0; currPath.section++; } } [indexPathSet addObject:[NSIndexPath indexPathWithASIndexPath:endPath]]; return indexPathSet; }
方法的执行过程很是简单,根据 ASRangeTuningParameters
获取该滚动方向上的缓冲区大小,在区域内遍历全部的 ASCellNode
查看其是否在当前区域内,而后加入数组中。
到这里,全部工做区域 visibleIndexPaths
displayIndexPaths
以及 preloadIndexPaths
都已经获取到了;接下来,就到了遍历 NSIndexPath
,修改结点状态的过程了;
- (void)_updateVisibleNodeIndexPaths { ... for (NSIndexPath *indexPath in allIndexPaths) { ASInterfaceState interfaceState = ASInterfaceStateMeasureLayout; if (ASInterfaceStateIncludesVisible(selfInterfaceState)) { if ([visibleIndexPaths containsObject:indexPath]) { interfaceState |= (ASInterfaceStateVisible | ASInterfaceStateDisplay | ASInterfaceStatePreload); } else { if ([preloadIndexPaths containsObject:indexPath]) { interfaceState |= ASInterfaceStatePreload; } if ([displayIndexPaths containsObject:indexPath]) { interfaceState |= ASInterfaceStateDisplay; } } }
根据当前 ASTableView
的状态以及 NSIndexPath
所在的区域,打开 ASInterfaceState
对应的位。
NSInteger section = indexPath.section; NSInteger row = indexPath.row; if (section >= 0 && row >= 0 && section < numberOfSections) { if (section != currentSectionIndex) { currentSectionNodes = allNodes[section]; numberOfNodesInSection = [currentSectionNodes count]; currentSectionIndex = section; } if (row < numberOfNodesInSection) { ASDisplayNode *node = currentSectionNodes[row]; if (node.interfaceState != interfaceState) { BOOL nodeShouldScheduleDisplay = [node shouldScheduleDisplayWithNewInterfaceState:interfaceState]; [node recursivelySetInterfaceState:interfaceState]; if (nodeShouldScheduleDisplay) { [self registerForNodeDisplayNotificationsForInterfaceStateIfNeeded:selfInterfaceState]; if (_didRegisterForNodeDisplayNotifications) { _pendingDisplayNodesTimestamp = CFAbsoluteTimeGetCurrent(); } } } } } } ... }
后面的一部分代码就会递归的设置结点的 interfaceState
,而且在当前 ASRangeController
的 ASLayoutRangeMode
发生改变时,发出通知,调用 -[ASRangeController _updateVisibleNodeIndexPaths]
私有方法,更新结点的状态。
- (void)scheduledNodesDidDisplay:(NSNotification *)notification { CFAbsoluteTime notificationTimestamp = ((NSNumber *) notification.userInfo[ASRenderingEngineDidDisplayNodesScheduledBeforeTimestamp]).doubleValue; if (_pendingDisplayNodesTimestamp < notificationTimestamp) { [[NSNotificationCenter defaultCenter] removeObserver:self name:ASRenderingEngineDidDisplayScheduledNodesNotification object:nil]; _didRegisterForNodeDisplayNotifications = NO; [self setNeedsUpdate]; } }
ASTableNode
既然是对 ASTableView
的封装,那么表视图中显示的数据仍然须要数据源来提供,而在 ASDK 中这一机制就比较复杂:
整个过程是由四部分协做完成的,Controller
、ASTableNode
、ASTableView
以及 ASDataController
,网络请求发起并返回数据以后,会调用 ASTableNode
的 API 执行插入行的方法,最后再经过 ASTableView
的同名方法,执行管理和更新节点数据的 ASDataController
的方法:
- (void)insertRowsAtIndexPaths:(NSArray *)indexPaths withAnimationOptions:(ASDataControllerAnimationOptions)animationOptions { dispatch_group_wait(_editingTransactionGroup, DISPATCH_TIME_FOREVER); NSArray *sortedIndexPaths = [indexPaths sortedArrayUsingSelector:@selector(compare:)]; NSMutableArray<ASIndexedNodeContext *> *contexts = [[NSMutableArray alloc] initWithCapacity:indexPaths.count]; __weak id<ASEnvironment> environment = [self.environmentDelegate dataControllerEnvironment]; for (NSIndexPath *indexPath in sortedIndexPaths) { ASCellNodeBlock nodeBlock = [_dataSource dataController:self nodeBlockAtIndexPath:indexPath]; ASSizeRange constrainedSize = [self constrainedSizeForNodeOfKind:ASDataControllerRowNodeKind atIndexPath:indexPath]; [contexts addObject:[[ASIndexedNodeContext alloc] initWithNodeBlock:nodeBlock indexPath:indexPath supplementaryElementKind:nil constrainedSize:constrainedSize environment:environment]]; } ASInsertElementsIntoMultidimensionalArrayAtIndexPaths(_nodeContexts[ASDataControllerRowNodeKind], sortedIndexPaths, contexts); dispatch_group_async(_editingTransactionGroup, _editingTransactionQueue, ^{ [self _batchLayoutAndInsertNodesFromContexts:contexts withAnimationOptions:animationOptions]; }); }
上面的方法总共作了几件事情:
遍历全部要插入的 NSIndexPath
数组,而后从数据源中获取对应的 ASCellNodeBlock
;
获取每个 NSIndexPath
对应的单元的大小 constrainedSize
(在图中没有表现出来);
初始化一堆 ASIndexedNodeContext
实例,而后加入到控制器维护的 _nodeContexts
数组中;
将节点插入到 _completedNodes
中,用于以后的缓存,以及提供给 ASTableView
的数据源代理方法使用;
ASTableView
会将数据源协议的代理设置为本身,而最多见的数据源协议在 ASTableView
中的实现是这样的:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { _ASTableViewCell *cell = [self dequeueReusableCellWithIdentifier:kCellReuseIdentifier forIndexPath:indexPath]; cell.delegate = self; ASCellNode *node = [_dataController nodeAtCompletedIndexPath:indexPath]; if (node) { [_rangeController configureContentView:cell.contentView forCellNode:node]; cell.node = node; cell.backgroundColor = node.backgroundColor; cell.selectionStyle = node.selectionStyle; cell.clipsToBounds = node.clipsToBounds; } return cell; }
上面的方法会从 ASDataController
中的 _completedNodes
中获取元素的数量信息:
在内部
_externalCompletedNodes
与_completedNodes
做用基本相同,在这里咱们不对它们的区别进行分析以及解释。
当 ASTableView
向数据源请求数据时,ASDK 就会从对应的 ASDataController
中取回最新的 node
,添加在 _ASTableViewCell
的实例上显示出来。
ASTableView
和 ASTableNode
的关系,其实就至关于 CALayer
和 UIView
的关系同样,后者都是前者的一个包装:
ASTableNode
为开发者提供了很是多的接口,其内部实现每每都是直接调用 ASTableView
的对应方法,在这里简单举几个例子:
- (void)insertSections:(NSIndexSet *)sections withRowAnimation:(UITableViewRowAnimation)animation { [self.view insertSections:sections withRowAnimation:animation]; } - (void)deleteSections:(NSIndexSet *)sections withRowAnimation:(UITableViewRowAnimation)animation { [self.view deleteSections:sections withRowAnimation:animation]; }
若是你再去看 ASTableView
中方法的实现的话,会发现不少方法都是由 ASDataController
和 ASRangeController
驱动的,上面的两个方法的实现就是这样的:
- (void)insertSections:(NSIndexSet *)sections withRowAnimation:(UITableViewRowAnimation)animation { if (sections.count == 0) { return; } [_dataController insertSections:sections withAnimationOptions:animation]; } - (void)deleteSections:(NSIndexSet *)sections withRowAnimation:(UITableViewRowAnimation)animation { if (sections.count == 0) { return; } [_dataController deleteSections:sections withAnimationOptions:animation]; }
到这里,整个智能预加载的部分就结束了,从须要预加载的资源以及预加载发出的时间两个方面来考虑,ASDK 在不一样工做区域中合理标记了须要预加载的资源,并在节点状态改变时就发出请求,在用户体验上是很是优秀的。
ASDK 中的表视图以及智能预加载其实都是经过下面这四者共同实现的,上层只会暴露出 ASTableNode
的接口,全部的数据的批量更新、工做区域的管理都是在幕后由 ASDataController
以及 ASRangeController
这两个控制器协做完成。
智能预加载的使用相比其它实现可能相对复杂,可是在笔者看来,ASDK 对于这一套机制的实现仍是很是完善的,同时也提供了极其优秀的用户体验,不过同时带来的也是相对较高的学习成本。
若是真正要选择预加载的机制,笔者以为最好从 Threshold 以及智能预加载两种方式中选择:
这两种方式的选择,其实也就是实现复杂度和用户体验之间的权衡了。
Github Repo:iOS-Source-Code-Analyze
Follow: Draveness · GitHub
Source: http://draveness.me/preload