預載入與智慧預載入(iOS)

Draveness發表於2016-11-10

前兩次的分享分別介紹了 ASDK 對於渲染的優化以及 ASDK 中使用的另一種佈局模型;這兩個新機制的引入分別解決了 iOS 在主執行緒渲染檢視以及 Auto Layout 的效能問題,而這一次討論的主要內容是 ASDK 如何預先請求伺服器資料,達到看似無限滾動列表的效果的。

這篇文章是 ASDK 系列中的最後一篇,文章會介紹 iOS 中幾種預載入的方案,以及 ASDK 中是如何處理預載入的。

不過,在介紹 ASDK 中實現智慧預載入的方式之前,文章中會介紹幾種簡單的預載入方式,方便各位開發者進行對比,選擇合適的機制實現預載入這一功能。

網路與效能

ASDK 通過在渲染檢視和佈局方面的優化已經可以使應用在任何使用者的瘋狂操作下都能保持 60 FPS 的流暢程度,也就是說,我們已經充分的利用了當前裝置的效能,調動各種資源加快檢視的渲染。

但是,僅僅在 CPU 以及 GPU 方面的優化往往是遠遠不夠的。在目前的軟體開發中,很難找到一個沒有任何網路請求的應用,哪怕是一個記賬軟體也需要伺服器來同步儲存使用者的資訊,防止資料的丟失;所以,只在渲染這一層面進行優化還不能讓使用者的體驗達到最佳,因為網路請求往往是一個應用最為耗時以及昂貴的操作。

111975281-9915967d9a10f42e
network

每一個應用程式在執行時都可以看做是 CPU 在底層利用各種資源瘋狂做加減法運算,其中最耗時的操作並不是進行加減法的過程,而是資源轉移的過程。

舉一個不是很恰當的例子,主廚(CPU)在炒一道菜(計算)時往往需要的時間並不多,但是菜的採購以及準備(資源的轉移)會佔用大量的時間,如果在每次炒菜之前,都由幫廚提前準備好所有的食材(快取),那麼做一道菜的時間就大大減少了。

而提高資源轉移的效率的最佳辦法就是使用多級快取:

121975281-71027e6effbd6a37
multi-laye

從上到下,雖然容量越來越大,直到 Network 層包含了整個網際網路的內容,但是訪問時間也是直線上升;在 Core 或者三級快取中的資源可能訪問只需要幾個或者幾十個時鐘週期,但是網路中的資源就遠遠大於這個數字,幾分鐘、幾小時都是有可能的。

更糟糕的是,因為天朝的網路情況及其複雜,運營商劫持 DNS、404 無法訪問等問題導致網路問題極其嚴重;而如何加速網路請求成為了很多移動端以及 Web 應用的重要問題。

預載入

本文就會提供一種緩解網路請求緩慢導致使用者體驗較差的解決方案,也就是預載入;在本地真正需要渲染介面之前就通過網路請求獲取資源存入記憶體或磁碟。

預載入並不能徹底解決網路請求緩慢的問題,而是通過提前發起網路請求緩解這一問題。

那麼,預載入到底要關注哪些方面的問題呢?總結下來,有以下兩個關注點:

  • 需要預載入的資源
  • 預載入發出的時間

文章會根據上面的兩個關注點,分別分析四種預載入方式的實現原理以及優缺點:

  1. 無限滾動列表
  2. threshold
  3. 惰性載入
  4. 智慧預載入

無限滾動列表

其實,無限滾動列表並不能算是一種預載入的實現原理,它只是提供一種分頁顯示的方法,在每次滾動到 UITableView 底部時,才會開始發起網路請求向伺服器獲取對應的資源。

雖然這種方法並不是預載入方式的一種,放在這裡的主要作用是作為對比方案,看看如果不使用預載入的機制,使用者體驗是什麼樣的。

131975281-7382bacd133b514f
infinite-list

很多客戶端都使用了分頁的載入方式,並沒有新增額外的預載入的機制來提升使用者體驗,雖然這種方式並不是不能接受,不過每次滑動到檢視底部之後,總要等待網路請求的完成確實對檢視的流暢性有一定影響。

雖然僅僅使用無限滾動列表而不提供預載入機制會在一定程度上影響使用者體驗,不過,這種需要使用者等待幾秒鐘的方式,在某些時候確實非常好用,比如:投放廣告。

141975281-23a69964e782ea38
advertise

QQ 空間就是這麼做的,它們投放的廣告基本都是在整個列表的最底端,這樣,當你滾動到列表最下面的時候,就能看到你急需的租房、租車、同城交友、信用卡辦理、只有 iPhone 能玩的遊戲以及各種奇奇怪怪的辣雞廣告了,很好的解決了我們的日常生活中的各種需求。(哈哈哈哈哈哈哈哈哈哈哈哈哈)

Threshold

使用 Threshold 進行預載入是一種最為常見的預載入方式,知乎客戶端就使用了這種方式預載入條目,而其原理也非常簡單,根據當前 UITableView 的所在位置,除以目前整個 UITableView.contentView 的高度,來判斷當前是否需要發起網路請求:

上面的程式碼在當前頁面已經劃過了 70% 的時候,就請求新的資源,載入資料;但是,僅僅使用這種方法會有另一個問題,尤其是當列表變得很長時,十分明顯,比如說:使用者從上向下滑動,總共載入了 5 頁資料:

PAGE TOTAL THRESHOLD DIFF
1 10 7 7
2 20 14 4
3 30 21 1
4 40 28 -2
5 50 35 -5
  • Page 當前總頁數;
  • Total 當前 UITableView 總元素個數;
  • Threshold 網路請求觸發時間;
  • Diff 表示最新載入的頁面被瀏覽了多少;

當 Threshold 設定為 70% 的時候,其實並不是單頁 70%,這就會導致新載入的頁面都沒有看,應用就會發出另一次請求,獲取新的資源

動態的 Threshold

解決這個問題的辦法,還是比較簡單的,通過修改上面的程式碼,將 Threshold 變成一個動態的值,隨著頁數的增長而增長:

通過這種方法獲取的 newThreshold 就會隨著頁數的增長而動態的改變,解決了上面出現的問題:

151975281-37fa5ef051faebc4
dynamic-threshold

惰性載入

使用 Threshold 進行預載入其實已經適用於大多數應用場景了;但是,下面介紹的方式,惰性載入能夠有針對性的載入使用者“會看到的” Cell。

惰性載入,就是在使用者滾動的時候會對使用者滾動結束的區域進行計算,只載入目標區域中的資源。

使用者在飛速滾動中會看到巨多的空白條目,因為使用者並不想閱讀這些條目,所以,我們並不需要真正去載入這些內容,只需要在 ASTableView/ASCollectionView 中只根據使用者滾動的目標區域惰性載入資源。

161975281-cc523ebdbdbd2258
lazy-loading

惰性載入的方式不僅僅減少了網路請求的冗餘資源,同時也減少了渲染檢視、資料繫結的耗時。

計算使用者滾動的目標區域可以直接使用下面的代理方法獲取:

以上程式碼只會大致計算出目標區域內的 IndexPath 陣列,並不會展開新的 page,同時會使用淺黑色標記目標區域。

當然,惰性載入的實現也並不只是這麼簡單,不僅需要客戶端的工作,同時因為需要載入特定 offset 資源,也需要服務端提供相應 API 的支援。

雖然惰性載入的方式能夠按照使用者的需要請求對應的資源,但是,在使用者滑動 UITableView 的過程中會看到大量的空白條目,這樣的使用者體驗是否可以接受又是值得考慮的問題了。

智慧預載入

終於到了智慧預載入的部分了,當我第一次得知 ASDK 可以通過滾動的方向預載入不同數量的內容,感覺是非常神奇的。

171975281-1c8c621fef9a1f46

如上圖所示 ASDK 把正在滾動的 ASTableView/ASCollectionView 劃分為三種狀態:

  • Fetch Data
  • Display
  • Visible

上面的這三種狀態都是由 ASDK 來管理的,而每一個 ASCellNode 的狀態都是由 ASRangeController 控制,所有的狀態都對應一個 ASInterfaceState

  • ASInterfaceStatePreload 當前元素貌似要顯示到螢幕上,需要從磁碟或者網路請求資料;
  • ASInterfaceStateDisplay 當前元素非常可能要變成可見的,需要進行非同步繪製;
  • ASInterfaceStateVisible 當前元素最少在螢幕上顯示了 1px

當使用者滾動當前檢視時,ASRangeController 就會修改不同區域內元素的狀態:

181975281-cfb2f10af0911bc1

上圖是使用者在向下滑動時,ASCellNode 是如何被標記的,假設當前檢視可見的範圍高度為 1,那麼在預設情況下,五個區域會按照上圖的形式進行劃分:

BUFFER SIZE
Fetch Data Leading Buffer 2
Display Leading Buffer 1
Visible 1
Display Trailing Buffer 1
Fetch Data Trailing Buffer 1

在滾動方向(Leading)上 Fetch Data 區域會是非滾動方向(Trailing)的兩倍,ASDK 會根據滾動方向的變化實時改變緩衝區的位置;在向下滾動時,下面的 Fetch Data 區域就是上面的兩倍,向上滾動時,上面的 Fetch Data 區域就是下面的兩倍。

這裡的兩倍並不是一個確定的數值,ASDK 會根據當前裝置的不同狀態,改變不同區域的大小,但是滾動方向的區域總會比非滾動方向大一些

智慧預載入能夠根據當前的滾動方向,自動改變當前的工作區域,選擇合適的區域提前觸發請求資源、渲染檢視以及非同步佈局等操作,讓檢視的滾動達到真正的流暢。

原理

在 ASDK 中整個智慧預載入的概念是由三個部分來統一協調管理的:

  • ASRangeController
  • ASDataController
  • ASTableViewASTableNode

對智慧預載入實現的分析,也是根據這三個部分來介紹的。

工作區域的管理

ASRangeControllerASTableView 以及 ASCollectionView 內部使用的控制器,主要用於監控檢視的可見區域、維護工作區域、觸發網路請求以及繪製、單元格的非同步佈局。

ASTableView 為例,在檢視進行滾動時,會觸發 -[UIScrollView scrollViewDidScroll:] 代理方法:

每一個 ASTableView 的例項都持有一個 ASRangeController 以及 ASDataController 用於管理工作區域以及資料更新。

ASRangeController 最重要的私有方法 -[ASRangeController _updateVisibleNodeIndexPaths] 一般都是因為上面的方法間接呼叫的:

呼叫棧中間的過程其實並不重要,最後的私有方法的主要工作就是計算不同區域內 Cell 的 NSIndexPath 陣列,然後更新對應 Cell 的狀態 ASInterfaceState 觸發對應的操作。

我們將這個私有方法的實現分開來看:

當前 ASRangeController 的資料來源以及代理就是 ASTableView,這段程式碼首先就獲取了完成計算和佈局的 ASCellNode 以及可見的 ASCellNodeNSIndexPath

預載入以及展示部分的 ASRangeTuningParameters 都是以二維陣列的形式儲存在 ASAbstractLayoutController 中的:

191975281-39fbbee1f838cfab
aslayout-range-mode-display-preload

在獲取了 ASRangeTuningParameters 之後,ASDK 也會通過 ASFlowLayoutController 的方法 -[ASFlowLayoutController indexPathsForScrolling:rangeMode:rangeType:] 獲取 NSIndexPath 物件的集合:

方法的執行過程非常簡單,根據 ASRangeTuningParameters 獲取該滾動方向上的緩衝區大小,在區域內遍歷所有的 ASCellNode 檢視其是否在當前區域內,然後加入陣列中。

到這裡,所有工作區域 visibleIndexPaths displayIndexPaths 以及 preloadIndexPaths 都已經獲取到了;接下來,就到了遍歷 NSIndexPath,修改結點狀態的過程了;

根據當前 ASTableView 的狀態以及 NSIndexPath 所在的區域,開啟 ASInterfaceState 對應的位。

後面的一部分程式碼就會遞迴的設定結點的 interfaceState,並且在當前 ASRangeControllerASLayoutRangeMode 發生改變時,發出通知,呼叫 -[ASRangeController _updateVisibleNodeIndexPaths] 私有方法,更新結點的狀態。

資料的載入和更新

ASTableNode 既然是對 ASTableView 的封裝,那麼表檢視中顯示的資料仍然需要資料來源來提供,而在 ASDK 中這一機制就比較複雜:

201975281-4d6268365abc975e
astableview-data

整個過程是由四部分協作完成的,ControllerASTableNodeASTableView 以及 ASDataController,網路請求發起並返回資料之後,會呼叫 ASTableNode 的 API 執行插入行的方法,最後再通過 ASTableView 的同名方法,執行管理和更新節點資料的 ASDataController 的方法:

上面的方法總共做了幾件事情:

  1. 遍歷所有要插入的 NSIndexPath 陣列,然後從資料來源中獲取對應的 ASCellNodeBlock
  2. 獲取每一個 NSIndexPath 對應的單元的大小 constrainedSize(在圖中沒有表現出來);
  3. 初始化一堆 ASIndexedNodeContext 例項,然後加入到控制器維護的 _nodeContexts 陣列中;
  4. 將節點插入到 _completedNodes 中,用於之後的快取,以及提供給 ASTableView 的資料來源代理方法使用;

ASTableView 會將資料來源協議的代理設定為自己,而最常見的資料來源協議在 ASTableView 中的實現是這樣的:

上面的方法會從 ASDataController 中的 _completedNodes 中獲取元素的數量資訊:

211975281-deb751041c9fe5c1
cellforrowatindexpath

在內部 _externalCompletedNodes_completedNodes 作用基本相同,在這裡我們不對它們的區別進行分析以及解釋。

ASTableView 向資料來源請求資料時,ASDK 就會從對應的 ASDataController 中取回最新的 node,新增在 _ASTableViewCell 的例項上顯示出來。

ASTableView 和 ASTableNode

ASTableViewASTableNode 的關係,其實就相當於 CALayerUIView 的關係一樣,後者都是前者的一個包裝:

221975281-5c9a239f5e97e20d
astableview-astablenode

ASTableNode 為開發者提供了非常多的介面,其內部實現往往都是直接呼叫 ASTableView 的對應方法,在這裡簡單舉幾個例子:

如果你再去看 ASTableView 中方法的實現的話,會發現很多方法都是由 ASDataControllerASRangeController 驅動的,上面的兩個方法的實現就是這樣的:

到這裡,整個智慧預載入的部分就結束了,從需要預載入的資源以及預載入發出的時間兩個方面來考慮,ASDK 在不同工作區域中合理標記了需要預載入的資源,並在節點狀態改變時就發出請求,在使用者體驗上是非常優秀的。

總結

ASDK 中的表檢視以及智慧預載入其實都是通過下面這四者共同實現的,上層只會暴露出 ASTableNode 的介面,所有的資料的批量更新、工作區域的管理都是在幕後由 ASDataController 以及 ASRangeController 這兩個控制器協作完成。

231975281-adc66a24698be981
multi-layer-asdk

智慧預載入的使用相比其它實現可能相對複雜,但是在筆者看來,ASDK 對於這一套機制的實現還是非常完善的,同時也提供了極其優秀的使用者體驗,不過同時帶來的也是相對較高的學習成本。

如果真正要選擇預載入的機制,筆者覺得最好從 Threshold 以及智慧預載入兩種方式中選擇:

241975281-d19503606c4ec28d
pros-cons

這兩種方式的選擇,其實也就是實現複雜度和使用者體驗之間的權衡了。

Github Repo:iOS-Source-Code-Analyze

Follow: Draveness · GitHub

Source: http://draveness.me/preload

相關文章