Skip to content

Commit

Permalink
[Perf] Do more diffing work on a background queue (#137)
Browse files Browse the repository at this point in the history
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. 🎉
  • Loading branch information
jessesquires authored Oct 14, 2024
1 parent bbd57e6 commit 193e123
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 84 deletions.
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ NEXT

- TBA

0.1.8 - NEXT
0.1.8
-----

- 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))
- 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))
- 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))
- 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. ([@jessesquires](https://github.com/jessesquires), [#135](https://github.com/jessesquires/ReactiveCollectionsKit/pull/135))
- `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))
- 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))

0.1.7
-----
Expand Down
42 changes: 28 additions & 14 deletions Sources/CellViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,13 +251,13 @@ public struct AnyCellViewModel: CellViewModel {
private let _shouldDeselect: Bool
private let _shouldHighlight: Bool
private let _contextMenuConfiguration: UIContextMenuConfiguration?
private let _configure: @MainActor (CellType) -> Void
private let _didSelect: @MainActor (CellEventCoordinator?) -> Void
private let _didDeselect: @MainActor (CellEventCoordinator?) -> Void
private let _willDisplay: @MainActor () -> Void
private let _didEndDisplaying: @MainActor () -> Void
private let _didHighlight: @MainActor() -> Void
private let _didUnhighlight: @MainActor () -> Void
private let _configure: @Sendable @MainActor (CellType) -> Void
private let _didSelect: @Sendable @MainActor (CellEventCoordinator?) -> Void
private let _didDeselect: @Sendable @MainActor (CellEventCoordinator?) -> Void
private let _willDisplay: @Sendable @MainActor () -> Void
private let _didEndDisplaying: @Sendable @MainActor () -> Void
private let _didHighlight: @Sendable @MainActor() -> Void
private let _didUnhighlight: @Sendable @MainActor () -> Void

// MARK: Init

Expand All @@ -277,13 +277,27 @@ public struct AnyCellViewModel: CellViewModel {
self._shouldDeselect = viewModel.shouldDeselect
self._shouldHighlight = viewModel.shouldHighlight
self._contextMenuConfiguration = viewModel.contextMenuConfiguration
self._configure = viewModel._configureGeneric(cell:)
self._didSelect = viewModel.didSelect(with:)
self._didDeselect = viewModel.didDeselect(with:)
self._willDisplay = viewModel.willDisplay
self._didEndDisplaying = viewModel.didEndDisplaying
self._didHighlight = viewModel.didHighlight
self._didUnhighlight = viewModel.didUnhighlight
self._configure = {
viewModel._configureGeneric(cell: $0)
}
self._didSelect = {
viewModel.didSelect(with: $0)
}
self._didDeselect = {
viewModel.didDeselect(with: $0)
}
self._willDisplay = {
viewModel.willDisplay()
}
self._didEndDisplaying = {
viewModel.didEndDisplaying()
}
self._didHighlight = {
viewModel.didHighlight()
}
self._didUnhighlight = {
viewModel.didUnhighlight()
}
self.cellClass = viewModel.cellClass
self.reuseIdentifier = viewModel.reuseIdentifier
}
Expand Down
154 changes: 94 additions & 60 deletions Sources/DiffableDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,43 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource<AnyHashable,
to destination: CollectionViewModel,
animated: Bool,
completion: SnapshotCompletion?
) {
// Get all the currently visible items, so we can reconfigure them if needed.
//
// This queries the collection view for visible items, so it must happen on the main thread.
// We need to inspect the current collection view state first, then pass this info downstream.
let visibleItemIdentifiers = self._visibleItemIdentifiers()

if self._diffOnBackgroundQueue {
self._diffingQueue.async {
self._applySnapshot(
from: source,
to: destination,
withVisibleItems: visibleItemIdentifiers,
animated: animated,
completion: completion
)
}
} else {
dispatchPrecondition(condition: .onQueue(.main))
self._applySnapshot(
from: source,
to: destination,
withVisibleItems: visibleItemIdentifiers,
animated: animated,
completion: completion
)
}
}

// MARK: Private

nonisolated private func _applySnapshot(
from source: CollectionViewModel,
to destination: CollectionViewModel,
withVisibleItems visibleItemIdentifiers: Set<UniqueIdentifier>,
animated: Bool,
completion: SnapshotCompletion?
) {
// Build initial destination snapshot, then make adjustments below.
// This takes care of newly added items and newly added sections,
Expand All @@ -99,51 +136,71 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource<AnyHashable,

// Find and perform item (cell) updates first.
// Add the item reconfigure updates to the snapshot.
let itemsToReconfigure = self._findItemsToReconfigure(from: source, to: destination)
let itemsToReconfigure = self._findItemsToReconfigure(
from: source,
to: destination,
withVisibleItems: visibleItemIdentifiers
)
destinationSnapshot.reconfigureItems(itemsToReconfigure)

// Apply the snapshot with item reconfigure updates.
self._applyDiffSnapshot(destinationSnapshot, animated: animated) { [weak self] in

// Once the snapshot with item reconfigures is applied,
// we need to find and apply supplementary view reconfigures, if needed.
//
// This is necessary to update all headers, footers, and supplementary views.
// Per notes above, supplementary views do not get reloaded / reconfigured
// automatically by `DiffableDataSource` when they change.
//
// To trigger updates on supplementary views with the existing APIs,
// the entire section must be reloaded. Yes, that sucks. We don't want to do that.
// That causes all items in the section to be hard-reloaded, too.
// Aside from the performance impact, doing that results in an ugly UI "flash"
// for all item cells in the collection. Gross.
//
// However, we can actually do much better than a hard reload!
// Instead of reloading the entire section, we can find and compare
// the supplementary views and manually reconfigure them if they changed.
//
// NOTE: this only matters if supplementary views are not static.
// That is, if they reflect data in the data source.
//
// For example, a header with a fixed title (e.g. "My Items") will NOT need to be reloaded.
// However, a header that displays changing data WILL need to be reloaded.
// (e.g. "My 10 Items")

// Check all the supplementary views and reconfigure them, if needed.
self?._reconfigureSupplementaryViewsIfNeeded(from: source, to: destination)

// Finally, we're done and can call completion.
completion?()
//
// Swift 6 complains about 'call to main actor-isolated instance method' here.
// However, call this method from a background thread is valid according to the docs.
self.apply(destinationSnapshot, animatingDifferences: animated) { [weak self] in
// UIKit guarantees `completion` is called on the main queue.
dispatchPrecondition(condition: .onQueue(.main))

guard let self else {
MainActor.assumeIsolated {
completion?()
}
return
}

MainActor.assumeIsolated {
// Once the snapshot with item reconfigures is applied,
// we need to find and apply supplementary view reconfigures, if needed.
//
// This is necessary to update all headers, footers, and supplementary views.
// Per notes above, supplementary views do not get reloaded / reconfigured
// automatically by `DiffableDataSource` when they change.
//
// To trigger updates on supplementary views with the existing APIs,
// the entire section must be reloaded. Yes, that sucks. We don't want to do that.
// That causes all items in the section to be hard-reloaded, too.
// Aside from the performance impact, doing that results in an ugly UI "flash"
// for all item cells in the collection. Gross.
//
// However, we can actually do much better than a hard reload!
// Instead of reloading the entire section, we can find and compare
// the supplementary views and manually reconfigure them if they changed.
//
// NOTE: this only matters if supplementary views are not static.
// That is, if they reflect data in the data source.
//
// For example, a header with a fixed title (e.g. "My Items") will NOT need to be reloaded.
// However, a header that displays changing data WILL need to be reloaded.
// (e.g. "My 10 Items")

// Check all the supplementary views and reconfigure them, if needed.
self._reconfigureSupplementaryViewsIfNeeded(from: source, to: destination)

// Finally, we're done and can call completion.
completion?()
}
}
}

private func _findItemsToReconfigure(
// MARK: Reconfiguring Cells

nonisolated private func _findItemsToReconfigure(
from source: CollectionViewModel,
to destination: CollectionViewModel
to destination: CollectionViewModel,
withVisibleItems visibleItemIdentifiers: Set<UniqueIdentifier>
) -> [UniqueIdentifier] {
let allSourceCells = source.allCellsByIdentifier()
let allDestinationCells = destination.allCellsByIdentifier()
let visibleItemIdentifiers = self._visibleItemIdentifiers()

var itemsToReconfigure = [UniqueIdentifier]()

Expand Down Expand Up @@ -180,6 +237,8 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource<AnyHashable,
return Set(visibleSourceItemIdentifiers)
}

// MARK: Reconfiguring Supplementary Views

private func _reconfigureSupplementaryViewsIfNeeded(
from source: CollectionViewModel,
to destination: CollectionViewModel
Expand Down Expand Up @@ -283,29 +342,4 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource<AnyHashable,
model.configure(view: view)
}
}

// MARK: Diffing

private func _applyDiffSnapshot(_ snapshot: Snapshot, animated: Bool, completion: SnapshotCompletion?) {
self._performOnDiffingQueueIfNeeded {
// Swift 6 complains about 'Call to main actor-isolated instance method' here.
// However, this is valid according to the docs.
self.apply(snapshot, animatingDifferences: animated) {
// UIKit guarantees `completion` is called on the main queue.
dispatchPrecondition(condition: .onQueue(.main))
MainActor.assumeIsolated {
completion?()
}
}
}
}

private func _performOnDiffingQueueIfNeeded(_ action: @Sendable @escaping () -> Void) {
if self._diffOnBackgroundQueue {
self._diffingQueue.async(execute: action)
} else {
dispatchPrecondition(condition: .onQueue(.main))
action()
}
}
}
2 changes: 1 addition & 1 deletion Sources/DiffableViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import Foundation
public typealias UniqueIdentifier = AnyHashable

/// Describes a view model that is uniquely identifiable and diffable.
public protocol DiffableViewModel: Identifiable, Hashable {
public protocol DiffableViewModel: Identifiable, Hashable, Sendable {
/// An identifier that uniquely identifies this instance.
var id: UniqueIdentifier { get }
}
18 changes: 12 additions & 6 deletions Sources/SupplementaryViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,9 @@ public struct AnySupplementaryViewModel: SupplementaryViewModel {
private let _viewModel: AnyHashable
private let _id: UniqueIdentifier
private let _registration: ViewRegistration
private let _configure: @MainActor (ViewType) -> Void
private let _willDisplay: @MainActor () -> Void
private let _didEndDisplaying: @MainActor () -> Void
private let _configure: @Sendable @MainActor (ViewType) -> Void
private let _willDisplay: @Sendable @MainActor () -> Void
private let _didEndDisplaying: @Sendable @MainActor () -> Void

// MARK: Init

Expand All @@ -168,9 +168,15 @@ public struct AnySupplementaryViewModel: SupplementaryViewModel {
self._viewModel = viewModel
self._id = viewModel.id
self._registration = viewModel.registration
self._configure = viewModel._configureGeneric(view:)
self._willDisplay = viewModel.willDisplay
self._didEndDisplaying = viewModel.didEndDisplaying
self._configure = {
viewModel._configureGeneric(view: $0)
}
self._willDisplay = {
viewModel.willDisplay()
}
self._didEndDisplaying = {
viewModel.didEndDisplaying()
}
self.viewClass = viewModel.viewClass
self.reuseIdentifier = viewModel.reuseIdentifier
}
Expand Down

0 comments on commit 193e123

Please sign in to comment.