使用Diff演算法優化UICollectionView資料更新(譯文)
此文章為本人翻譯的譯文,版權為原作者所有。
英文原文:A better way to update UICollectionView data in Swift with diff framework
Familiar friends
很難想象一款iOS的APP不使用UITableView
和UICollectionView
,大多數時候我們從伺服器,快取和過濾器中獲取資料然後在列表中展示,當資料發生改變的時候更新列表。
這個時候你就你就會想到你最喜歡的方法reloadData
,用reloadData
整個列表都會被重新整理。當你想用最快速的方式重新整理列表這是沒有問題的。但CPU會重新計算UITableView
的size,這會影響效能。更進一步,如果這些改變應該被凸顯出來,並且你想讓使用者感知到發生了什麼,手動插入或刪除某一行是更好的選擇。
如果你是做安卓開發,或許知道通過使用DiffUtil
而不是notifyDataSetChanged
來計算變化,以便更容易地更新RecyclerView
。不幸的是iOS並不提供這樣的介面,但是我們可以學習怎麼去做。
這裡會用UICollectionView
舉例,但UITableView
實踐的方式是一樣的。
Drag and Drop
想象一下App需要實現使用者可以通過拖拽移動UICollectionView
的功能,你可以看看DragAndDrop這個demo,它是用iOS 11中的 drag and drop API介面實現的。
在呼叫UICollectionView
的更新方法之前,必須確保資料更改了。 然後呼叫deleteItems
和insertItems
來反映資料變化。 UICollectionView
會執行一個很棒的的動畫。
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
let destinationIndexPath = coordinator.destinationIndexPath
let sourceIndexPath = coordinator.items.last?.dragItem.localObject as! IndexPath
// remove
sourceItems.remove(at: sourceIndexPath.item)
sourceCollectionView.deleteItems(at: [sourceIndexPath])
// insert
destinationItems.insert(draggedItem, at: destinationIndexPath.item)
destinationCollectionView.insertItems(at: [destinationIndexPath])
}
這是一個簡單的例子,只需從集合中刪除或新增1個item。但在實際專案中,資料要複雜得多,變化並不是那麼微不足道。如果從伺服器拿到大量的items需要插入和刪除,你需要計算正確的IndexPath來呼叫,這不是一件容易的事。大多數時候你會遇到以下崩潰:
NSInternalInconsistencyException
Terminating app due to uncaught exception ‘NSInternalInconsistencyException’,
reason: ‘Invalid update: invalid number of items in section 0.
The number of items contained in an existing section after the update (213)
must be equal to the number of items contained in that section before
the update (154), plus or minus the number of items inserted or
deleted from that section (40 inserted, 0 deleted) and plus
or minus the number of items moved into or out of
that section (0 moved in, 0 moved out).’
依我的經驗來看,這個是隨機發生(實際是因為資料和IndexPath不匹配)。
Game of IndexPath
讓我們通過一些例子來梳理對IndexPath
的瞭解。通過6個item的資料集,我們執行一些更新操作並找出IndexPath
應該是什麼。
tems = ["a", "b", "c", "d", "e", "f"]
為了更好的理解,請檢視這個demoCollectionUpdateExample
1. Insert 3 items at the end
items.append(contentsOf: ["g", "h", "i"])
// a, b, c, d, e, f, g, h, i
let indexPaths = Array(6…8).map { IndexPath(item: $0, section: 0) }
collectionView.insertItems(at: indexPaths)
2. Delete 3 items at the end
items.removeLast()
items.removeLast()
items.removeLast()
// a, b, c
let indexPaths = Array(3…5).map { IndexPath(item: $0, section: 0) }
collectionView.deleteItems(at: indexPaths)
3. Update item at index 2
items[2] = “?”
// a, b, ?, d, e, f
let indexPath = IndexPath(item: 2, section: 0)
collectionView.reloadItems(at: [indexPath])
4. Move item “c” to the end
items.remove(at: 2)
items.append("c")
// a, b, d, e, f, c
collectionView.moveItem(
at: IndexPath(item: 2, section: 0),
to: IndexPath(item: 5, section :0)
)
5. Delete 3 items at the beginning, then insert 3 items at the end
對於多個不同的操作,我們應該使用performBatchUpdates
如果要在一個動畫操作中對集合檢視進行多次更改,則可以使用此方法,而不是在幾個單獨的動畫中。你可以使用此方法插入,刪除,重新載入或移動單元格,或使用它來更改與一個或多個單元格關聯的佈局引數
items.removeFirst()
items.removeFirst()
items.removeFirst()
items.append(contentsOf: ["g", "h", "i"])
// d, e, f, g, h, i
collectionView.performBatchUpdates({
let deleteIndexPaths = Array(0…2).map { IndexPath(item: $0, section: 0) }
collectionView.deleteItems(at: deleteIndexPaths)
let insertIndexPaths = Array(3…5).map { IndexPath(item: $0, section: 0) }
collectionView.insertItems(at: insertIndexPaths)
}, completion: nil)
6. Insert 3 items at the end, then delete 3 items at the beginning
items.append(contentsOf: ["g", "h", "i"])
items.removeFirst()
items.removeFirst()
items.removeFirst()
// d, e, f, g, h, i
collectionView.performBatchUpdates({
let insertIndexPaths = Array(6…8).map { IndexPath(item: $0, section: 0) }
collectionView.insertItems(at: insertIndexPaths)
let deleteIndexPaths = Array(0…2).map { IndexPath(item: $0, section: 0) }
collectionView.deleteItems(at: deleteIndexPaths)
}, completion: nil)
如果你run第6個例子,將會crash
Terminating app due to uncaught exception
‘NSInternalInconsistencyException’,
reason: ‘attempt to insert item 6 into section 0,
but there are only 6 items in section 0 after the update’
performBatchUpdates
這是由performBatchUpdates的工作方式引起的。 看看這裡documentation:
Deletes are processed before inserts in batch operations. This means the indexes for the deletions are processed relative to the indexes of the collection view’s state before the batch operation, and the indexes for the insertions are processed relative to the indexes of the state after all the deletions in the batch operation.
無論我們如何呼叫insert
或delete
,performBatchUpdates
總是先執行刪除操作。因此,如果首先發生刪除,我們需要使用正確的IndexPath
呼叫deleteItems
和insertItems
。
items.append(contentsOf: ["g", "h", "i"])
items.removeFirst()
items.removeFirst()
items.removeFirst()
// d, e, f, g, h, i
collectionView.performBatchUpdates({
let deleteIndexPaths = Array(0…2).map { IndexPath(item: $0, section: 0) }
collectionView.deleteItems(at: deleteIndexPaths)
let insertIndexPaths = Array(3…5).map { IndexPath(item: $0, section: 0) }
collectionView.insertItems(at: insertIndexPaths)
}, completion: nil)
Operation
UICollectionView
上有許多操作,還有一些操作可以更新整個section
。看看Ordering of Operations and Index
insertItems(at indexPaths: [IndexPath])
deleteItems(at indexPaths: [IndexPath])
reloadItems(at indexPaths: [IndexPath])
moveItem(at indexPath: IndexPath, to newIndexPath: IndexPath)
performBatchUpdates(_ updates, completion)
insertSections(_ sections: IndexSet)
deleteSections(_ sections: IndexSet)
reloadSections(_ sections: IndexSet)
moveSection(_ section: Int, toSection newSection: Int)
Edit distance
手動執行這些計算非常繁瑣且容易出錯。我們可以使用一些演算法構建自己的抽象。 最原始的的演算法是Wagner-Fischer,它使用Dynamic_programming(動態規劃)來查詢兩個字串之間的編輯路徑。
編輯路徑表示從一個字串更改為另一個字串所需的步驟集合。字串只是一個字符集合,因此我們可以概括這個概念,使其適用於任何專案集合。 我們要求專案符合Hashable
,而不是比較字元。
"kit" to "kat"
我們怎樣才能將"kit"這個詞改為"kat"? 我們需要執行哪些操作? 你可以告訴"只需將字母i更改為a",但這個簡單的示例可幫助您理解演算法,讓我們開始吧。
Deletions
如果我們將"kit"修改為字串"",需要3個刪除操作
"kit" -> "" ? 3次刪除操作
"ki" -> "" ? 2次刪除操作
"k" -> "" ? 1次刪除操作
Insertions
如果我們將空字串""變為"kit",需要3次插入操作
"" -> "k" ? 1次插入操作
"" -> "ka" ? 2次插入操作
"" -> "kat" ? 3次插入操作
If equal, take value from the top left
你可以將演算法視為從源字串 -> 空字串 -> 目標字串。我們嘗試找到要更新的最小步驟。水平移動意味著插入,垂直意味著刪除,對角意味著替換。
這樣我們就可以構建我們的矩陣,逐行逐列地迭代。首先,源集合中的字母"k"與目標集合中的字母"k"相同,我們只需從左上角取值,即0替換
If not equal
我們繼續看目標結合上的下一個字母。 這裡"k"和"a"不一樣。 我們從左,上,左上取最小值。 然後增加一個
這裡我們從左邊取值,這是水平的,所以我們增加1次插入。
"k" to "kat" ? 2 insertions
繼續,"t"和"k"不一樣,所以我們從左邊水平取值。 在這裡你可以看到它某種意義上是說得通的,從"k"到"kat",我們需要2個插入,即插入字母"a"和"t"。
The bottom right value
一行一行的繼續,直到我們達到右下角的值,這樣就可以得到編輯路徑。 這裡1個替換意味著我們需要執行1次替換以從"kit"變為"kat",這是用"a"更新"i"。
您可以很容易地看到需要更新索引1,但是我們怎麼知道它是索引1?
DeepDiff
這個演算法顯示了兩個字串之間的變化,但由於字串只是字元的集合。 我們可以概括這個概念,使其適用於任何item集合。
DeepDiff的實現在GitHub上。 以下是它的使用方法。 假設一個old
的和new
的陣列,它計算轉換所需的更改。 更改包括:更改型別(insert
, delete
, replace
, move
)和更改的index
。
let old = Array("abc")
let new = Array("bcd")
let changes = diff(old: old, new: new)
// Delete "a" at index 0
// Insert "d" at index 2
程式碼是解釋的最好方式。但在接下來的部分中,我將概述庫中的一些技術要點,以便你輕鬆遵循。 你可以看看here
Complexity
我們遍歷矩陣,其中m
和n
分別是源和目標集合的長度。 所以我們可以看到這個演算法的複雜度是O(mn)
。
此外,效能在很大程度上取決於集合的大小以及專案的複雜程度。 您想要執行的更復雜和更深的Equatable
會極大地影響效能。
如果你檢視wiki page ,會提示我們可以採取一些措施來提高效能。
“We can adapt the algorithm to use less space, O(m) instead of O(mn), since it only requires that the previous row and current row be stored at any one time.”
看到我們一次只操作一行,儲存整個矩陣是低效的,而我們可以只使用2個陣列來計算,這也減少了記憶體佔用。
Change
由於每種change
都是互斥的,因此它們非常適合用作列舉
public enum Change<T> {
case insert(Insert<T>)
case delete(Delete<T>)
case replace(Replace<T>)
case move(Move<T>)
}
- insert:item被插入到一個index下
- delete:item從一個index下移除
- replace:一個index下的item被另一個替換
- move:一個item從一個index下移到另一個index下
如上所述,我們只需要一次跟蹤2行即可執行。每行的slots都是一組改變。這裡diff是一個泛型函式,它接受Hashable
型別的任何集合,包括字串。
public func diff<T: Hashable>(old: Array<T>, new: Array<T>) -> [Change<T>] {
let previousRow = Row<T>()
previousRow.seed(with: new)
let currentRow = Row<T>()
…
}
我喜歡分離關注點,所以每一行都應該自己管理狀態。首先宣告一個持有slots陣列的Row
物件
class Row<T> {
/// Each slot is a collection of Change
var slots: [[Change<T>]] = []
}
回想一下我們逐行逐列的演算法。所以我們使用2個迴圈
old.enumerated().forEach { indexInOld, oldItem in
new.enumerated().forEach { index, item in
}
}
我們的工作只是比較舊陣列和新陣列中的items,並正確更新Row
物件中的slots。
Hashable
vs Equatable
我們需要巧妙地進行equation check,因為當物件很複雜時,Equatable
函式可能需要時間。我們知道Hashable
符合Equatable
,並且2個相同的物件具有相同的雜湊值。 因此,如果它們沒有相同的雜湊值,則它們不是等同的。 反轉並不能保證,但這足以減少對Equatable
函式的呼叫次數。
private func isEqual<T: Hashable>(oldItem: T, newItem: T) -> Bool {
// Same items must have same hashValue
if oldItem.hashValue != newItem.hashValue {
return false
} else {
// Different hashValue does not always mean different items
return oldItem == newItem
}
}
演算法還有其他一些細節,但你應該看一下程式碼,它會告訴你更多。
How about the Move
到目前為止,你已經注意到我們剛剛更新了插入,刪除和替換的步驟。 那移動呢?事實證明這並不困難。移動只是插入相同item後的刪除。 你可以看看MoveReducer,它的實現效率不高,但至少它會給你一些提示。
Inferring IndexPath for UICollectionView
使用DeepDiff
返回的更改陣列,我們可以推斷出要提供給UICollectionView以執行更新的所需IndexPath集合。
從Change
到IndexPath
的轉換幾乎是不言自明的。 您可以檢視UICollectionViewextension。
有一點需要注意,否則你會得到熟悉的NSInternalInconsistencyException
。那就是在performBatchUpdates
之外呼叫reloadItems
。 這是因為此演算法返回的Replace
步驟包含更新集合後的狀態的IndexPath
,但UICollectionView
期望它們在該狀態之前。
除此之外,它非常簡單。你可以通過這個例子對這些changes的速度和有用資訊感到驚訝。
Where to go from here
完成這個指南後,你將瞭解如何通過手動計算IndexPath
手動更新到UICollectionView
。在遇到異常後,你知道這個庫給你提供了多少幫助。你還了解演算法以及如何用Swift實現。你還知道如何使用Hashable
和Equatable
。
DeepDiff
的當前版本現在使用Heckel
演算法,該演算法以線性時間執行並且執行速度更快。 測試結果如下圖
IGListKit也實現了Heckel
演算法,但是用Objective C++中並對其進行了優化。在下一篇文章中,我將介紹Heckel
演算法以及如何在Swift中實現它,以及如何為這些diff
演算法編寫單元測試。 敬請關注!
與此同時,如果你覺得有冒險精神,這裡有一些據說非常高效的其他演算法:
最後個人補充
感興趣的也可以看看這個專案DifferenceKit,也是我們公司專案中在使用的,據它gitlab上個提供的資料顯示,它是以下幾個專案中各方面效能最好的
- DifferenceKit
- RxDataSources
- FlexibleDiff
- IGListKit
- ListDiff
- DeepDiff
- Differ
- Dwifft
相關文章
- 譯文:影象優化(上)優化
- UICollectionView使用UIView
- React 渲染優化:diff 與 shouldComponentUpdateReact優化
- 【譯】Web 效能優化: 使用 Webpack 分離資料的正確方法Web優化
- 一文講透 React Diff 演算法核心React演算法
- TiDB 資料一致性校驗實現:Sync-diff-inspector 優化方案TiDB優化
- 資料庫優化 - SQL優化資料庫優化SQL
- hive優化-資料傾斜優化Hive優化
- [譯] Virtual Dom 和 Diff 演算法在 React 中是如何工作的?演算法React
- React的diff演算法React演算法
- Mysql資料庫的join演算法介紹,優美的執行優化MySql資料庫演算法優化
- 資料庫優化資料庫優化
- 使用陣列來理解vue的diff演算法(一)陣列Vue演算法
- 資料結構系列:圖文詳解氣泡排序 & 優化資料結構排序優化
- MySQL 資料過多時的優化--圖文並茂版MySql優化
- Spark效能優化:優化資料結構Spark優化資料結構
- JDBC 批量插入資料優化, 使用 addBatch 和 executeBatchJDBC優化BAT
- 為什麼要做版本更新? 一文看懂遊戲版本優化“最優解”遊戲優化
- React Native - react-native-code-push-熱更新外掛的使用[譯文]React Native
- React 中的 diff 演算法React演算法
- JVM編譯優化JVM編譯優化
- Android 編譯優化Android編譯優化
- 資料庫優化之臨時表優化資料庫優化
- 【譯】Web 效能優化:理解及使用 JavaScript 快取Web優化JavaScript快取
- MySQL更新資料,如何使用updateMySql
- 資料庫優化SQL資料庫優化SQL
- MySQL資料庫優化MySql資料庫優化
- Oracle效能優化-資料庫CPU使用率100%Oracle優化資料庫
- Django資料庫效能優化之 - 使用Python集合操作Django資料庫優化Python
- 更新關聯資料初始化
- 【資料庫】查詢優化之子連線優化資料庫優化
- 詳解vue的diff演算法Vue演算法
- Vue中diff演算法的理解Vue演算法
- 深入理解React:diff 演算法React演算法
- React原始碼分析 - Diff演算法React原始碼演算法
- vue3的diff演算法Vue演算法
- 虛擬DOM與diff演算法演算法
- React中diff演算法的理解React演算法