使用Diff演算法優化UICollectionView資料更新(譯文)

weixin_34320159發表於2018-11-24

此文章為本人翻譯的譯文,版權為原作者所有。
英文原文:A better way to update UICollectionView data in Swift with diff framework

Familiar friends

很難想象一款iOS的APP不使用UITableViewUICollectionView,大多數時候我們從伺服器,快取和過濾器中獲取資料然後在列表中展示,當資料發生改變的時候更新列表。

這個時候你就你就會想到你最喜歡的方法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的更新方法之前,必須確保資料更改了。 然後呼叫deleteItemsinsertItems來反映資料變化。 UICollectionView會執行一個很棒的的動畫。

2086987-57064920f5a7618e.png
0_kCCB1lwaDLCrqTy3.png

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

2086987-bc514ba5718a17c7.png
1_aqssz9GRKOt2O9OEQDRPrg (1).png

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.

無論我們如何呼叫insertdeleteperformBatchUpdates總是先執行刪除操作。因此,如果首先發生刪除,我們需要使用正確的IndexPath呼叫deleteItemsinsertItems

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)
2086987-3351355e3dd13a1a.jpeg
0_kBveuRgnlHYlk1YZ.jpeg

Edit distance

手動執行這些計算非常繁瑣且容易出錯。我們可以使用一些演算法構建自己的抽象。 最原始的的演算法是Wagner-Fischer,它使用Dynamic_programming(動態規劃)來查詢兩個字串之間的編輯路徑。
編輯路徑表示從一個字串更改為另一個字串所需的步驟集合。字串只是一個字符集合,因此我們可以概括這個概念,使其適用於任何專案集合。 我們要求專案符合Hashable,而不是比較字元。

"kit" to "kat"

我們怎樣才能將"kit"這個詞改為"kat"? 我們需要執行哪些操作? 你可以告訴"只需將字母i更改為a",但這個簡單的示例可幫助您理解演算法,讓我們開始吧。

2086987-ae35974135992d26.jpeg
0_YB9HNWh-W_RSSy49.jpeg

Deletions

如果我們將"kit"修改為字串"",需要3個刪除操作


2086987-95a3e510840aa2e8.png
0_CWTVVW4_OriCrwlA.png

"kit" -> "" ? 3次刪除操作

"ki" -> "" ? 2次刪除操作

"k" -> "" ? 1次刪除操作

Insertions

如果我們將空字串""變為"kit",需要3次插入操作


2086987-4d67d74c0580012a.png
0_XfqiIXOZ4eSr_2OQ.png

"" -> "k" ? 1次插入操作

"" -> "ka" ? 2次插入操作

"" -> "kat" ? 3次插入操作

If equal, take value from the top left

你可以將演算法視為從源字串 -> 空字串 -> 目標字串。我們嘗試找到要更新的最小步驟。水平移動意味著插入,垂直意味著刪除,對角意味著替換。

這樣我們就可以構建我們的矩陣,逐行逐列地迭代。首先,源集合中的字母"k"與目標集合中的字母"k"相同,我們只需從左上角取值,即0替換

2086987-cd21c25489bc714c.png
0_xrFKcGJkr38NKt0C.png

If not equal

我們繼續看目標結合上的下一個字母。 這裡"k"和"a"不一樣。 我們從左,上,左上取最小值。 然後增加一個


2086987-e5c6e07e6719966b.png
0_d9B7IDqkP-tjyX4_.png

這裡我們從左邊取值,這是水平的,所以我們增加1次插入。

"k" to "kat" ? 2 insertions

繼續,"t"和"k"不一樣,所以我們從左邊水平取值。 在這裡你可以看到它某種意義上是說得通的,從"k"到"kat",我們需要2個插入,即插入字母"a"和"t"。

2086987-02a7baaf0f41d1f5.png
0_ZRyek7fZgFF2na8d.png

The bottom right value

一行一行的繼續,直到我們達到右下角的值,這樣就可以得到編輯路徑。 這裡1個替換意味著我們需要執行1次替換以從"kit"變為"kat",這是用"a"更新"i"。


2086987-2cec0a82ea03fce8.png
1_KfUkGg_KZWGwDRAhxAIaFg.png

您可以很容易地看到需要更新索引1,但是我們怎麼知道它是索引1?

DeepDiff

這個演算法顯示了兩個字串之間的變化,但由於字串只是字元的集合。 我們可以概括這個概念,使其適用於任何item集合。

2086987-e5d89edadb31264c.gif
1_w5n7s2u_eXRN_F9DdwIdIA.gif

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

我們遍歷矩陣,其中mn分別是源和目標集合的長度。 所以我們可以看到這個演算法的複雜度是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集合。

ChangeIndexPath的轉換幾乎是不言自明的。 您可以檢視UICollectionViewextension

有一點需要注意,否則你會得到熟悉的NSInternalInconsistencyException。那就是在performBatchUpdates之外呼叫reloadItems。 這是因為此演算法返回的Replace步驟包含更新集合後的狀態的IndexPath,但UICollectionView期望它們在該狀態之前。

除此之外,它非常簡單。你可以通過這個例子對這些changes的速度和有用資訊感到驚訝。

Where to go from here

完成這個指南後,你將瞭解如何通過手動計算IndexPath手動更新到UICollectionView。在遇到異常後,你知道這個庫給你提供了多少幫助。你還了解演算法以及如何用Swift實現。你還知道如何使用HashableEquatable

DeepDiff的當前版本現在使用Heckel演算法,該演算法以線性時間執行並且執行速度更快。 測試結果如下圖

2086987-bed3c7b5a2d4f3f1.png
0_6lsIlvbAErwQNbXn.png

IGListKit也實現了Heckel演算法,但是用Objective C++中並對其進行了優化。在下一篇文章中,我將介紹Heckel演算法以及如何在Swift中實現它,以及如何為這些diff演算法編寫單元測試。 敬請關注!

與此同時,如果你覺得有冒險精神,這裡有一些據說非常高效的其他演算法:

最後個人補充

感興趣的也可以看看這個專案DifferenceKit,也是我們公司專案中在使用的,據它gitlab上個提供的資料顯示,它是以下幾個專案中各方面效能最好的

  • DifferenceKit
  • RxDataSources
  • FlexibleDiff
  • IGListKit
  • ListDiff
  • DeepDiff
  • Differ
  • Dwifft

相關文章