Skip to content

Commit 193e123

Browse files
authored
[Perf] Do more diffing work on a background queue (#137)
This re-works `DiffableDataSource` to perform more operations on a background thread — namely building the initial snapshot and searching for items that need to be reconfigured. In extremely large collections, performing these operations on the main thread can produce noticeable lag. ### `Sendable` changes This also makes `DiffableViewModel` inherit from `Sendable`, which means this also applies to `CellViewModel`, `SupplementaryViewModel`, `SectionViewModel`, and `CollectionViewModel`. Early in development, I avoided doing this because I did not want to place the burden of `Sendable` on clients. Instead, I opted to make everything `@MainActor` (which is also a burden, in different ways). However, that was changed in #135. After the performance improvements in faabe72, making these types `Sendable` is more necessary. However, I think we can justify making all view models `Sendable` because all the view models _should_ be stateless / immutable. If you want to update the collection view, then you need to apply a new view model via `update(viewModel:)` — so it's not as if you could be mutating view model state outside of the `Driver` because those changes would not be reflected anyway. ### Swift 6 The only issue with adopting Swift 6 right now is that [`apply(_:animatingDifferences:completion:)`](https://developer.apple.com/documentation/uikit/uicollectionviewdiffabledatasource/3375795-apply) is incorrectly marked as `@MainActor`, which becomes an error in Swift 6. See #116. Otherwise, the library compiles successfully with Swift 6 and complete concurrency checking. 🎉
1 parent bbd57e6 commit 193e123

File tree

5 files changed

+140
-84
lines changed

5 files changed

+140
-84
lines changed

CHANGELOG.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ NEXT
77

88
- TBA
99

10-
0.1.8 - NEXT
10+
0.1.8
1111
-----
1212

1313
- Allow setting a `UICollectionViewDelegateFlowLayout` object to receive flow layout events from the collection view. ([@jessesquires](https://github.com/jessesquires), [#134](https://github.com/jessesquires/ReactiveCollectionsKit/pull/134))
14-
- Swift Concurrency improvements: `@MainActor` annotations have been removed from most top-level types and protocols, instead opting to apply `@MainActor` to individual members only where necessary. The goal is to impose fewer restrictions/burdens on clients. ([@jessesquires](https://github.com/jessesquires), [#135](https://github.com/jessesquires/ReactiveCollectionsKit/pull/135))
15-
- Various performance improvements. ([@jessesquires](https://github.com/jessesquires), [#136](https://github.com/jessesquires/ReactiveCollectionsKit/pull/136), [@lachenmayer](https://github.com/lachenmayer), [#138](https://github.com/jessesquires/ReactiveCollectionsKit/pull/138))
14+
- Swift Concurrency improvements:
15+
- `@MainActor` annotations have been removed from most top-level types and protocols, instead opting to apply `@MainActor` to individual members only where necessary. ([@jessesquires](https://github.com/jessesquires), [#135](https://github.com/jessesquires/ReactiveCollectionsKit/pull/135))
16+
- `DiffableViewModel` is now marked as `Sendable`. This means `Sendable` also applies to `CellViewModel`, `SupplementaryViewModel`, `SectionViewModel`, and `CollectionViewModel`. ([@jessesquires](https://github.com/jessesquires), [#137](https://github.com/jessesquires/ReactiveCollectionsKit/pull/137))
17+
- Various performance improvements. Notably, when configuring `CollectionViewDriver` to perform diffing on a background queue via `CollectionViewDriverOptions.diffOnBackgroundQueue`, more operations are now performed in the background that were previously running on the main thread. ([@jessesquires](https://github.com/jessesquires), [#136](https://github.com/jessesquires/ReactiveCollectionsKit/pull/136), [#137](https://github.com/jessesquires/ReactiveCollectionsKit/pull/137), [@lachenmayer](https://github.com/lachenmayer), [#138](https://github.com/jessesquires/ReactiveCollectionsKit/pull/138))
1618

1719
0.1.7
1820
-----

Sources/CellViewModel.swift

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -251,13 +251,13 @@ public struct AnyCellViewModel: CellViewModel {
251251
private let _shouldDeselect: Bool
252252
private let _shouldHighlight: Bool
253253
private let _contextMenuConfiguration: UIContextMenuConfiguration?
254-
private let _configure: @MainActor (CellType) -> Void
255-
private let _didSelect: @MainActor (CellEventCoordinator?) -> Void
256-
private let _didDeselect: @MainActor (CellEventCoordinator?) -> Void
257-
private let _willDisplay: @MainActor () -> Void
258-
private let _didEndDisplaying: @MainActor () -> Void
259-
private let _didHighlight: @MainActor() -> Void
260-
private let _didUnhighlight: @MainActor () -> Void
254+
private let _configure: @Sendable @MainActor (CellType) -> Void
255+
private let _didSelect: @Sendable @MainActor (CellEventCoordinator?) -> Void
256+
private let _didDeselect: @Sendable @MainActor (CellEventCoordinator?) -> Void
257+
private let _willDisplay: @Sendable @MainActor () -> Void
258+
private let _didEndDisplaying: @Sendable @MainActor () -> Void
259+
private let _didHighlight: @Sendable @MainActor() -> Void
260+
private let _didUnhighlight: @Sendable @MainActor () -> Void
261261

262262
// MARK: Init
263263

@@ -277,13 +277,27 @@ public struct AnyCellViewModel: CellViewModel {
277277
self._shouldDeselect = viewModel.shouldDeselect
278278
self._shouldHighlight = viewModel.shouldHighlight
279279
self._contextMenuConfiguration = viewModel.contextMenuConfiguration
280-
self._configure = viewModel._configureGeneric(cell:)
281-
self._didSelect = viewModel.didSelect(with:)
282-
self._didDeselect = viewModel.didDeselect(with:)
283-
self._willDisplay = viewModel.willDisplay
284-
self._didEndDisplaying = viewModel.didEndDisplaying
285-
self._didHighlight = viewModel.didHighlight
286-
self._didUnhighlight = viewModel.didUnhighlight
280+
self._configure = {
281+
viewModel._configureGeneric(cell: $0)
282+
}
283+
self._didSelect = {
284+
viewModel.didSelect(with: $0)
285+
}
286+
self._didDeselect = {
287+
viewModel.didDeselect(with: $0)
288+
}
289+
self._willDisplay = {
290+
viewModel.willDisplay()
291+
}
292+
self._didEndDisplaying = {
293+
viewModel.didEndDisplaying()
294+
}
295+
self._didHighlight = {
296+
viewModel.didHighlight()
297+
}
298+
self._didUnhighlight = {
299+
viewModel.didUnhighlight()
300+
}
287301
self.cellClass = viewModel.cellClass
288302
self.reuseIdentifier = viewModel.reuseIdentifier
289303
}

Sources/DiffableDataSource.swift

Lines changed: 94 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,43 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource<AnyHashable,
7575
to destination: CollectionViewModel,
7676
animated: Bool,
7777
completion: SnapshotCompletion?
78+
) {
79+
// Get all the currently visible items, so we can reconfigure them if needed.
80+
//
81+
// This queries the collection view for visible items, so it must happen on the main thread.
82+
// We need to inspect the current collection view state first, then pass this info downstream.
83+
let visibleItemIdentifiers = self._visibleItemIdentifiers()
84+
85+
if self._diffOnBackgroundQueue {
86+
self._diffingQueue.async {
87+
self._applySnapshot(
88+
from: source,
89+
to: destination,
90+
withVisibleItems: visibleItemIdentifiers,
91+
animated: animated,
92+
completion: completion
93+
)
94+
}
95+
} else {
96+
dispatchPrecondition(condition: .onQueue(.main))
97+
self._applySnapshot(
98+
from: source,
99+
to: destination,
100+
withVisibleItems: visibleItemIdentifiers,
101+
animated: animated,
102+
completion: completion
103+
)
104+
}
105+
}
106+
107+
// MARK: Private
108+
109+
nonisolated private func _applySnapshot(
110+
from source: CollectionViewModel,
111+
to destination: CollectionViewModel,
112+
withVisibleItems visibleItemIdentifiers: Set<UniqueIdentifier>,
113+
animated: Bool,
114+
completion: SnapshotCompletion?
78115
) {
79116
// Build initial destination snapshot, then make adjustments below.
80117
// This takes care of newly added items and newly added sections,
@@ -99,51 +136,71 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource<AnyHashable,
99136

100137
// Find and perform item (cell) updates first.
101138
// Add the item reconfigure updates to the snapshot.
102-
let itemsToReconfigure = self._findItemsToReconfigure(from: source, to: destination)
139+
let itemsToReconfigure = self._findItemsToReconfigure(
140+
from: source,
141+
to: destination,
142+
withVisibleItems: visibleItemIdentifiers
143+
)
103144
destinationSnapshot.reconfigureItems(itemsToReconfigure)
104145

105146
// Apply the snapshot with item reconfigure updates.
106-
self._applyDiffSnapshot(destinationSnapshot, animated: animated) { [weak self] in
107-
108-
// Once the snapshot with item reconfigures is applied,
109-
// we need to find and apply supplementary view reconfigures, if needed.
110-
//
111-
// This is necessary to update all headers, footers, and supplementary views.
112-
// Per notes above, supplementary views do not get reloaded / reconfigured
113-
// automatically by `DiffableDataSource` when they change.
114-
//
115-
// To trigger updates on supplementary views with the existing APIs,
116-
// the entire section must be reloaded. Yes, that sucks. We don't want to do that.
117-
// That causes all items in the section to be hard-reloaded, too.
118-
// Aside from the performance impact, doing that results in an ugly UI "flash"
119-
// for all item cells in the collection. Gross.
120-
//
121-
// However, we can actually do much better than a hard reload!
122-
// Instead of reloading the entire section, we can find and compare
123-
// the supplementary views and manually reconfigure them if they changed.
124-
//
125-
// NOTE: this only matters if supplementary views are not static.
126-
// That is, if they reflect data in the data source.
127-
//
128-
// For example, a header with a fixed title (e.g. "My Items") will NOT need to be reloaded.
129-
// However, a header that displays changing data WILL need to be reloaded.
130-
// (e.g. "My 10 Items")
131-
132-
// Check all the supplementary views and reconfigure them, if needed.
133-
self?._reconfigureSupplementaryViewsIfNeeded(from: source, to: destination)
134-
135-
// Finally, we're done and can call completion.
136-
completion?()
147+
//
148+
// Swift 6 complains about 'call to main actor-isolated instance method' here.
149+
// However, call this method from a background thread is valid according to the docs.
150+
self.apply(destinationSnapshot, animatingDifferences: animated) { [weak self] in
151+
// UIKit guarantees `completion` is called on the main queue.
152+
dispatchPrecondition(condition: .onQueue(.main))
153+
154+
guard let self else {
155+
MainActor.assumeIsolated {
156+
completion?()
157+
}
158+
return
159+
}
160+
161+
MainActor.assumeIsolated {
162+
// Once the snapshot with item reconfigures is applied,
163+
// we need to find and apply supplementary view reconfigures, if needed.
164+
//
165+
// This is necessary to update all headers, footers, and supplementary views.
166+
// Per notes above, supplementary views do not get reloaded / reconfigured
167+
// automatically by `DiffableDataSource` when they change.
168+
//
169+
// To trigger updates on supplementary views with the existing APIs,
170+
// the entire section must be reloaded. Yes, that sucks. We don't want to do that.
171+
// That causes all items in the section to be hard-reloaded, too.
172+
// Aside from the performance impact, doing that results in an ugly UI "flash"
173+
// for all item cells in the collection. Gross.
174+
//
175+
// However, we can actually do much better than a hard reload!
176+
// Instead of reloading the entire section, we can find and compare
177+
// the supplementary views and manually reconfigure them if they changed.
178+
//
179+
// NOTE: this only matters if supplementary views are not static.
180+
// That is, if they reflect data in the data source.
181+
//
182+
// For example, a header with a fixed title (e.g. "My Items") will NOT need to be reloaded.
183+
// However, a header that displays changing data WILL need to be reloaded.
184+
// (e.g. "My 10 Items")
185+
186+
// Check all the supplementary views and reconfigure them, if needed.
187+
self._reconfigureSupplementaryViewsIfNeeded(from: source, to: destination)
188+
189+
// Finally, we're done and can call completion.
190+
completion?()
191+
}
137192
}
138193
}
139194

140-
private func _findItemsToReconfigure(
195+
// MARK: Reconfiguring Cells
196+
197+
nonisolated private func _findItemsToReconfigure(
141198
from source: CollectionViewModel,
142-
to destination: CollectionViewModel
199+
to destination: CollectionViewModel,
200+
withVisibleItems visibleItemIdentifiers: Set<UniqueIdentifier>
143201
) -> [UniqueIdentifier] {
144202
let allSourceCells = source.allCellsByIdentifier()
145203
let allDestinationCells = destination.allCellsByIdentifier()
146-
let visibleItemIdentifiers = self._visibleItemIdentifiers()
147204

148205
var itemsToReconfigure = [UniqueIdentifier]()
149206

@@ -180,6 +237,8 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource<AnyHashable,
180237
return Set(visibleSourceItemIdentifiers)
181238
}
182239

240+
// MARK: Reconfiguring Supplementary Views
241+
183242
private func _reconfigureSupplementaryViewsIfNeeded(
184243
from source: CollectionViewModel,
185244
to destination: CollectionViewModel
@@ -283,29 +342,4 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource<AnyHashable,
283342
model.configure(view: view)
284343
}
285344
}
286-
287-
// MARK: Diffing
288-
289-
private func _applyDiffSnapshot(_ snapshot: Snapshot, animated: Bool, completion: SnapshotCompletion?) {
290-
self._performOnDiffingQueueIfNeeded {
291-
// Swift 6 complains about 'Call to main actor-isolated instance method' here.
292-
// However, this is valid according to the docs.
293-
self.apply(snapshot, animatingDifferences: animated) {
294-
// UIKit guarantees `completion` is called on the main queue.
295-
dispatchPrecondition(condition: .onQueue(.main))
296-
MainActor.assumeIsolated {
297-
completion?()
298-
}
299-
}
300-
}
301-
}
302-
303-
private func _performOnDiffingQueueIfNeeded(_ action: @Sendable @escaping () -> Void) {
304-
if self._diffOnBackgroundQueue {
305-
self._diffingQueue.async(execute: action)
306-
} else {
307-
dispatchPrecondition(condition: .onQueue(.main))
308-
action()
309-
}
310-
}
311345
}

Sources/DiffableViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import Foundation
1717
public typealias UniqueIdentifier = AnyHashable
1818

1919
/// Describes a view model that is uniquely identifiable and diffable.
20-
public protocol DiffableViewModel: Identifiable, Hashable {
20+
public protocol DiffableViewModel: Identifiable, Hashable, Sendable {
2121
/// An identifier that uniquely identifies this instance.
2222
var id: UniqueIdentifier { get }
2323
}

Sources/SupplementaryViewModel.swift

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,9 @@ public struct AnySupplementaryViewModel: SupplementaryViewModel {
150150
private let _viewModel: AnyHashable
151151
private let _id: UniqueIdentifier
152152
private let _registration: ViewRegistration
153-
private let _configure: @MainActor (ViewType) -> Void
154-
private let _willDisplay: @MainActor () -> Void
155-
private let _didEndDisplaying: @MainActor () -> Void
153+
private let _configure: @Sendable @MainActor (ViewType) -> Void
154+
private let _willDisplay: @Sendable @MainActor () -> Void
155+
private let _didEndDisplaying: @Sendable @MainActor () -> Void
156156

157157
// MARK: Init
158158

@@ -168,9 +168,15 @@ public struct AnySupplementaryViewModel: SupplementaryViewModel {
168168
self._viewModel = viewModel
169169
self._id = viewModel.id
170170
self._registration = viewModel.registration
171-
self._configure = viewModel._configureGeneric(view:)
172-
self._willDisplay = viewModel.willDisplay
173-
self._didEndDisplaying = viewModel.didEndDisplaying
171+
self._configure = {
172+
viewModel._configureGeneric(view: $0)
173+
}
174+
self._willDisplay = {
175+
viewModel.willDisplay()
176+
}
177+
self._didEndDisplaying = {
178+
viewModel.didEndDisplaying()
179+
}
174180
self.viewClass = viewModel.viewClass
175181
self.reuseIdentifier = viewModel.reuseIdentifier
176182
}

0 commit comments

Comments
 (0)