Valuable Models, Part II: The Difference Engine
In Part I of this series, I discussed how my Apple TV app, Scoreboard, uses a model comprised entirely of value types, allowing me to centralize all view updates in a single property observer. However, the updating code is pretty anemic at this point. It simply looks like this:
var scoreboard: Scoreboard {
didSet {
collectionView?.reloadData()
}
}
We would prefer it to instead insert, delete, and update cells as needed to match the new data. So let's make that happen.
Before and After
In this didSet
observer, we have access to two different variables. oldValue
contains the Scoreboard
as it was before the latest update; scoreboard
contains the Scoreboard
after the latest update.
We want to find the differences between them. To do that, we're going to use a diffing algorithm on the players
arrays in each Scoreboard
. Specifically, we'll use ArrayDiff.swift, a small, generic library written by Frank Krueger.
Now, at this point I need to make two confessions.
One: Scoreboard doesn't allow you to rearrange the Player
s in a Scoreboard
. You can add a Player
to the end of the array, or you can remove a Player
anywhere in the array, but there is currently no way to take a Player
at one position in the array and move it to another. This feature is missing because I couldn't find a good way to incorporate it into the user interface, but admittedly it does make updating by diffing easier to write. The diffing algorithm sees a move as a removal from one place and a separate insertion into another; making it actually move the existing cell instead of just removing and re-adding it would require additional work that I am fortunate enough to not need to do.
Two: If you ask me how ArrayDiff.swift actually works, my answer will be "Very well, thank you." I haven't tried to understand the algorithm, because I don't really need to. I do know that it runs in O(n²), which is perfectly acceptable at the array sizes Scoreboard will probably operate at (dozens of items at most). ArrayDiff works well, it's fast enough for this application, and it doesn't expose any implementation details I need to be aware of. Basically, it's a very well-behaved library.
With these caveats aside, let's look at how we actually use ArrayDiff to update our view.
Using ArrayDiff
ArrayDiff has a few different interfaces, but the one I prefer is the Array.diff(from:match:)
method. It calculates the differences needed to transform from
into self
, returning an array of ArrayDiffAction
s. By looping over that array and then performing the corresponding actions on the collection view, we can insert, delete, and reload cells as needed.
ArrayDiffAction
is an enum with cases .Append
, .Remove
, and .Update
. Each of these carries the corresponding element with it in an associated value; .Update
carries both the old and new values. For a collection view, .Append
corresponds to inserting, .Remove
corresponds to deleting, and .Update
corresponds to reloading. However, you will see .Update
even for elements that haven't changed, so it's sensible to check for changes before you reload.
So, let's take our first crack at updating the collection view through a diff:
var scoreboard: Scoreboard {
didSet {
guard let collectionView = collectionView else {
return
}
collectionView.performBatchUpdates({
for change in self.scoreboard.players.diff(from: oldValue.players, match: ==) {
switch change {
case .Append (_):
collectionView.insertItemsAtIndexPaths([ ... ])
case .Remove (_):
collectionView.deleteItemsAtIndexPaths([ ... ])
case let .Update (old, new):
if old != new {
collectionView.reloadItemsAtIndexPaths([ ... ])
}
}
}
}, completion: nil)
}
}
We immediately notice a problem. See the ...
s? ArrayDiff
doesn't give us indices for the elements, so we have no way to address the right cells. We'll have to keep track of the indices ourselves as we loop through the list of ArrayDiffAction
s.
Let's try again:
var scoreboard: Scoreboard {
didSet {
guard let collectionView = collectionView else {
return
}
collectionView.performBatchUpdates({
var oldIndex = 0
var newIndex = 0
for change in self.scoreboard.players.diff(from: oldValue.players, match: ==) {
switch change {
case .Append (_):
collectionView.insertItemsAtIndexPaths([ NSIndexPath(forItem: newIndex, inSection: 0) ])
newIndex++
case .Remove (_):
collectionView.deleteItemsAtIndexPaths([ NSIndexPath(forItem: oldIndex, inSection: 0) ])
oldIndex++
case let .Update (old, new):
if old != new {
collectionView.reloadItemsAtIndexPaths([ NSIndexPath(forItem: newIndex, inSection: 0) ])
}
newIndex++
oldIndex++
}
}
}, completion: nil)
}
}
Much better. Here, we increment newIndex
for any element that's in the new array, and oldIndex
for any element that's in the old one. That gives us good indices to work with.
If you run this, you'll find that it works just fine—mostly. There's one big problem, though: it never reloads rows. Instead, it removes and re-adds them. Why is that happening, and what can we do about it? I'll discuss that in Part III.