本文是 WWDC 2018 Session 225 讀後感,其視訊及配套 PDF文稿 地址如下 A Tour Of UICollectionView。
這篇文章難度不大,由易到難,逐層深入,是一篇很好的 Session。全文總計約2500字,通讀全文花費時間大約15分鐘。
看完這篇 Session,給我的直觀感受是這篇名為 A Tour Of UICollectionView 的文章,是圍繞著一個 CollectionView 的案例,對自定義佈局以及其效能優化、資料操作、動畫做的一次探討。雖然沒有新增的 API 和特性,但是實際意義蠻大。
我們也按照 Session 的思路,將本文主要分為三個模組:
- CollectionView 概述
- 佈局(自定義 Layout)
- 資料的重新整理、動畫
CollectionView 想必各位已經不陌生了,在我們的日常開發中,它的身影隨處可見。如果還有小夥伴對它不熟悉,可以看看之前的 Session :
- WWDC 2016 - What`s New In CollectionView In iOS 10 。
- WWDC 2017 - Drag and Drop with Collection and Table View。
如果我們想搭建一個如下圖的 App ,需要涉及到三點:佈局、重新整理、動畫,我們今天的話題也是圍繞著這三點展開。
CollectionView 概述
CollectionView 的核心概念有三點:佈局(Layout)、資料來源(Data Source)、代理(Delegate)。
UICollectionViewLayout
UICollectionViewLayout 負責管理 UICollectionViewLayoutAttributes,一個 UICollectionViewLayoutAttributes 物件管理著一個 CollectionView 中一個 Item 的佈局相關屬性。包括 Bounds、center、frame 等。同時要注意在當 Bounds 在改變時是否需要重新整理 Layout, 以及佈局時的動畫。
UICollectionViewFlowLayout
UICollectionViewFlowLayout 是 UICollectionViewLayout 的子類,是系統提供給我們一個封裝好的流式佈局的類。
橫向流式佈局(白色線代表佈局方向)
縱向流式佈局(白色線代表佈局方向)
這種流式佈局需要區分方向,方向不同,具體的 Line Spacing 和 Item Spacing 所代表的含義不同,具體差異,可以通過上面的兩張圖進行區分。
因為流式佈局其強大的適用性,所以在設計中這種佈局方式被廣泛使用。
UICollectionViewDataSource
資料來源:顧名思義,提供資料的分組資訊、每組中 Item 數量以及每個 Item
的實際內容。
optional func numberOfSections(in collectionView: UICollectionView) -> Int
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
複製程式碼
UICollectionViewDelegate
delegate 提供了一些細顆粒度的方法:
- Highlighting
- Selection
還有一些檢視的顯示事件:
- willDisplayItem
- didEndDisplayingItem
佈局 - 自定義 Layout
系統提供的
UICollectionViewFlowLayout
雖然使用起來方便快捷,能夠滿足基本的佈局需要。但是遇到如下圖的佈局樣式,顯然就無法達到我們所需的效果,這時就需要自定義 FlowLayout
了。
自定義 FlowLayout
並不複雜 ,有以下四步:
1.提供滾動範圍
override var collectionViewContentSize: CGSize
複製程式碼
2.提供佈局屬性物件
func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
複製程式碼
3.佈局的相關準備工作
// 為每個 invalidateLayout 呼叫
// 快取 UICollectionViewLayoutAttributes
// 計算 collectionViewContentSize
func prepare()
複製程式碼
4.處理自定義佈局中的邊界更改
// 在 CollectionView 滾動時是否允許重新整理佈局
func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool
複製程式碼
效能優化部分
通過以上的方法,我們可以輕鬆實現自定義 layout
的佈局。但是在實際開發中,有一個對效能提升很實用的小技巧很值得我們借鑑。
通常,我們獲取當前螢幕上所有顯示的 UICollectionViewLayoutAttributes
會這麼寫
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return cachedAttributes.filter { (attributes:UICollectionViewLayoutAttributes) -> Bool in
return rect.intersects(attributes.frame)
}
}
複製程式碼
採用以上的寫法,我們會遍歷快取了所有 UICollectionViewLayoutAttributes 的 cachedAttributes 陣列。而隨著使用者的拖動螢幕,這個方法會被頻繁的呼叫,也就是會做大量的計算。當 cachedAttributes 陣列的量級達到一定的規模,對效能的負面影響就會非常明顯,使用者在使用過程中會出現卡頓的負面體驗。
蘋果工程師採用的辦法可以很好地解決這一問題。所有的 UICollectionViewLayoutAttributes 都按照順序被儲存在 cachedAttributes 陣列中,既然是一個有序的陣列,那麼只要我們通過二分查詢,拿到任何一個在當前頁面顯示的 Attribures 物件,就可以以這個 Attribures 物件為中心,向前向後遍歷查詢符合條件的 Attribures 物件即可,這樣查詢的範圍就被大大縮小了。相應地,計算量變小,對效能的提升非常明顯。
為了讓大家易於理解,畫了一張圖,雖然有點醜,但表達思想足夠了。 當前顯示的 CollectionView 的範圍就是 rect。在 rect 內部通過二分查詢,找到第一個合適的 UICollectionViewLayoutAttributes 作為 firstMatchIndex,也就是那個 Attributes 物件。
在 rect 內, firstMatchIndex 以上的 Attributes 都符合 attributes.frame.maxY >= rect.minY
,而在 firstMatchIndex 以下的 Attributes 也都符合 attributes.frame.maxY <= rect.maxY
的條件。
優化後的程式碼如下
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var attributesArray = [UICollectionViewLayoutAttributes]()
// 找到在當前區域內的任何一個 Attributes 的 Index
guard let firstMatchIndex = binarySearchAttributes(range: 0...cachedAttributes.endIndex, rect:rect) else { return attributesArray }
// 從後向前反向遍歷,縮小查詢範圍
for attributes in cachedAttributes[..<firstMatchIndex].reversed {
guard attributes.frame.maxY >= rect.minY else {break}
attributesArray.append(attributes)
}
// 從前向後正向遍歷,縮小查詢範圍
for attributes in cachedAttributes[firstMatchIndex...] {
guard attributes.frame.minY <= rect.maxY else {break}
attributesArray.append(attributes)
}
return attributesArray
}
複製程式碼
通過二分查詢的方式,在處理當前頁面顯示的 UICollectionViewLayoutAttributes
的過程中可以減少遍歷的資料量,在實際體驗中頁面滑動更加順滑,體驗更好,這種處理 Attribures
物件的方式,值得我們在開發過程中借鑑。
資料重新整理和動畫
我們會遇到對 CollectionView
進行編輯的場景,編輯操作一般是新增、刪除、重新整理、插入等。在本 Session 中,主講人為我們做了一個示例。
- 對最後一條資料進行重新整理操作
- 將原本在最後位置的資料移動到第一條的位置
- 刪除原本的第三條資料
為了便於理解,還是貼一下程式碼吧:
// 原函式
func performUpdates() {
people[3].isUpdated = true
let movedPerson = people[3]
people.remove(at:3)
people.remove(at:2)
people.insert(movedPerson, at:0)
// Update Collection View
collectionView.reloadItems(at: [IndexPath(item:3, section:0)])
collectionView.reloadItems(at: [IndexPath(item:2, section:0)])
collectionView.moveItem(at: IndexPath(item:3, section:0), to:IndexPath(item:0, section:0))
}
複製程式碼
這個例子在操作過程中報錯,原因如下:我們刪除和移動的是同一個索引位置的元素。我們顯示地呼叫了 reloadData()
, reloadData()
是一個非同步執行的函式,會直接訪問資料來源方法,進行重新佈局,多次呼叫容易出錯,同時這樣寫也沒有動畫效果。
performBatchUpdates
上面出錯的場景其實挺常見,為了規範操作,避免在編輯的場景下出現問題,應當將對 CollectionView
的新增、刪除、重新整理、插入等操作都放入到 performBatchUpdates()
中的 updates
閉包內,CollectionView
中 Item 的更新順序我們不需要關心,但是資料來源更新的順序是很重要的。
首先認識一下這個方法
func performBatchUpdates(_ updates: (() -> Void)?, completion: ((Bool) -> Void)? = nil)
1.其中 updates 閉包內部會執行新增、刪除、重新整理、插入等一系列操作。
2.而 completion 閉包會在 updates 閉包執行完畢後開始執行,updates 閉包中的相關操作會觸發一些動畫,
當這些動畫執行成功會返回 True,當動畫被打斷或者執行失敗會返回 false,這個引數也有可能會返回 nil。
複製程式碼
這個方法可以用來對 collectionView
中的元素進行批量的新增、刪除、重新整理、插入等操作,同時將觸發collectionView
的 layout
的對應動畫:
1.func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> NSCollectionViewLayoutAttributes?
2.func initialLayoutAttributesForAppearingDecorationElement(ofKind elementKind: NSCollectionView.DecorationElementKind, at decorationIndexPath: IndexPath) -> NSCollectionViewLayoutAttributes?
3.func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> NSCollectionViewLayoutAttributes?
4.func finalLayoutAttributesForDisappearingDecorationElement(ofKind elementKind: String, at decorationIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?
複製程式碼
原因是因為在執行完 performBatchUpdates
操作之後,CollectionView 會自動 reloadData
呼叫資料來源方法重新佈局。所以我們在 Updates 閉包中對資料的編輯操作執行完畢後,一定要同步更新資料來源,否則有極大的機率出現資料越界等錯誤情況。
易出錯的合併更新一般有以下幾種
- 1.移動與刪除同一個索引
- 2.移動與插入同一個索引
- 3.將多個物件移動到同一個索引
- 4.引用了一個無效的索引
既然在執行操作時容易出現問題,我們就該想辦法去規避,蘋果的工程師給出了很好的建議。在上面我們講過對 CollectionView
的新增、刪除、重新整理、插入等操作都放入到 performBatchUpdates()
中的 updates
閉包內,CollectionView
中 Item 的更新順序我們不需要關心,但是資料來源更新的順序很重要。最後的 Item 更新順序和資料來源的更新順序是怎麼回事呢?
你可以這樣理解:
- 在 Updates 閉包內,你可以選擇先刪除一個索引,然後插入一個新的索引,或是把兩者的順序顛倒過來進行操作,這都沒有問題,你可以按照自己的喜好,隨意指定順序。
- 但是涉及到資料來源更新的方法,必須按照一定的順序和規則來操作。
資料來源執行操作的順序及規則
- 1.將移動操作拆分成刪除和插入。
- 2.將所有的刪除操作合併到一起,同理將所有的插入操作也合併到一起。
- 3.以降序優先處理刪除操作。
- 4.最後以升序處理插入操作。
然後我們將剛才出錯的程式碼,改為如下:
// 新的實現
func performUpdates() {
UIView.performWithoutAnimation {
// 先將資料重新整理
CollectionView.performBatchUpdates({
people[3].isUpdate = true
CollectionView.reloadItems(at: [IndexPath(item:3, section:0)])
})
// 再將移動拆分成刪除之後再插入兩個動作
CollectionView.performBatchUpdates({
let movedPerson = people[3]
people.remove(at: 3)
people.remove(at: 2)
people.insert(movedPerson, at:0)
CollectionView.deleteItems(at: [IndexPath(item:2, section:0)])
collectionView.moveItem(at: IndexPath(item:3, section:0), to:IndexPath(item:0, section:0))
})
}
}
複製程式碼
最後總結一下,蘋果的工程師建議我們通過自定義佈局來實現精美的佈局樣式,同時採取二分查詢的方式來高效的處理資料,提升介面的流暢性和使用者體驗。
其次對 CollectionView 的操作建議我們通過 performBatchUpdates
來進行處理,我們不需要去考慮動畫的執行,因為預設都幫助我們處理好了,我們只需要注意資料來源處理的原則和順序,確保資料處理的安全與穩定。
如果對這篇 Session 很感興趣的話,可以在 Twitter 上聯絡作者,只需要在 Twitter 搜尋 A Tour Of CollectionView 即可,作者還是很熱心的。
最後宣告,筆者的英語聽力比較慘,有些地方聽得不是特別明白,一旦發現我的資訊有遺漏或者傳達的資訊有誤,還望大家不吝指教。
檢視更多 WWDC 18 相關文章請前往 老司機x知識小集xSwiftGG WWDC 18 專題目錄