前兩次的分享分別介紹了 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