Skip to content

Commit 3926d69

Browse files
authored
Adopt Swift 6 + concurrency + remove background diffing (#158)
Closes #157. - Adopt Swift 6 - Support Concurrency - Remove background diffing Maintaining `CollectionViewDriverOptions.diffOnBackgroundQueue` is too large of an effort for very little benefit. It was always questionable, and against guidance from the UIKit team. **See this blog post for more:** https://www.jessesquires.com/blog/2024/12/19/diffable-data-source-main-actor-inconsistency **From the UIKit team:** > We have seen a number of issues stemming from usage of diffable data source on background queues/threads, and the performance benefits of doing this are generally minimal due to the fact that only the diffing of identifiers in the old & new snapshots happens on the background queue/thread; the work to set up and execute the Ul updates and animations for cells always happens on the main thread. Therefore, we made the decision to restrict diffable data source to the main actor when using Swift Concurrency, as this ensures correctness in all cases and is nearly always the best approach anyways. If you were previously applying snapshots from a background queue, we recommend you update your implementation to do so on the main queue instead.
1 parent ec0a461 commit 3926d69

13 files changed

+58
-137
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ NEXT
77

88
- TBA
99

10+
0.2.0
11+
-----
12+
13+
This release closes the [0.2.0 milestone](https://github.com/jessesquires/ReactiveCollectionsKit/milestone/3?closed=1).
14+
15+
**Breaking Changes:**
16+
- Adopt Swift 6 and Swift Concurrency, remove `CollectionViewDriverOptions.diffOnBackgroundQueue`. ([@jessesquires](https://github.com/jessesquires), [#157](https://github.com/jessesquires/ReactiveCollectionsKit/issues/157), [#158](https://github.com/jessesquires/ReactiveCollectionsKit/pull/158)) **See linked issue and pull request for decision to remove `diffOnBackgroundQueue`.**
17+
1018
0.1.9
1119
-----
1220

Example/Sources/List/ListViewController.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ final class ListViewController: ExampleViewController, CellEventCoordinator {
2020
lazy var driver: CollectionViewDriver = {
2121
let driver = CollectionViewDriver(
2222
view: self.collectionView,
23-
options: .init(diffOnBackgroundQueue: true),
2423
emptyViewProvider: sharedEmptyViewProvider,
2524
cellEventCoordinator: self
2625
)

Package.swift

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,5 @@ let package = Package(
4141
]
4242
)
4343
],
44-
swiftLanguageModes: [.v5]
44+
swiftLanguageModes: [.v6]
4545
)
46-
47-
#warning("Remove after Swift 6 language mode")
48-
let swiftSettings = [
49-
SwiftSetting.enableExperimentalFeature("StrictConcurrency")
50-
]
51-
52-
for target in package.targets {
53-
var settings = target.swiftSettings ?? []
54-
settings.append(contentsOf: swiftSettings)
55-
target.swiftSettings = settings
56-
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ driver.update(viewModel: updated)
9191
## Requirements
9292

9393
- iOS 16.0+
94-
- Swift 5.10+
94+
- Swift 6.0+
9595
- Xcode 26.0+
9696
- [SwiftLint](https://github.com/realm/SwiftLint)
9797

ReactiveCollectionsKit.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@
288288
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
289289
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
290290
SWIFT_STRICT_CONCURRENCY = complete;
291-
SWIFT_VERSION = 5.0;
291+
SWIFT_VERSION = 6.0;
292292
TARGETED_DEVICE_FAMILY = "1,2";
293293
VERSIONING_SYSTEM = "apple-generic";
294294
};
@@ -351,7 +351,7 @@
351351
SWIFT_COMPILATION_MODE = wholemodule;
352352
SWIFT_OPTIMIZATION_LEVEL = "-O";
353353
SWIFT_STRICT_CONCURRENCY = complete;
354-
SWIFT_VERSION = 5.0;
354+
SWIFT_VERSION = 6.0;
355355
TARGETED_DEVICE_FAMILY = "1,2";
356356
VALIDATE_PRODUCT = YES;
357357
VERSIONING_SYSTEM = "apple-generic";

Sources/CollectionViewDriver.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ public final class CollectionViewDriver: NSObject {
9292

9393
// workaround for swift initialization rules.
9494
// the "real" init is below.
95-
self._dataSource = DiffableDataSource(view: view, diffOnBackgroundQueue: false)
95+
self._dataSource = DiffableDataSource(view: view)
9696

9797
super.init()
9898

@@ -106,7 +106,6 @@ public final class CollectionViewDriver: NSObject {
106106
// `self` owns the `_dataSource`, so we know that `self` will always exist.
107107
self._dataSource = DiffableDataSource(
108108
view: view,
109-
diffOnBackgroundQueue: options.diffOnBackgroundQueue,
110109
cellProvider: { [unowned self] view, indexPath, itemIdentifier in
111110
self._cellProvider(
112111
collectionView: view,

Sources/CollectionViewDriverOptions.swift

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,6 @@ import Foundation
1515

1616
/// Defines various options to customize behavior of a ``CollectionViewDriver``.
1717
public struct CollectionViewDriverOptions: Hashable {
18-
/// Specifies whether or not to perform diffing on a background queue.
19-
/// Pass `true` to perform diffing in the background,
20-
/// pass `false` to perform diffing on the main thread.
21-
public let diffOnBackgroundQueue: Bool
22-
2318
/// Specifies whether or not the ``CollectionViewDriver`` should
2419
/// perform a hard `reloadData()` when replacing the ``CollectionViewModel`` with
2520
/// a new one, or if it should always perform a diff.
@@ -31,13 +26,10 @@ public struct CollectionViewDriverOptions: Hashable {
3126
/// Initializes a `CollectionViewDriverOptions` object.
3227
///
3328
/// - Parameters:
34-
/// - diffOnBackgroundQueue: Whether or not to perform diffing on a background queue. Default is `false`.
3529
/// - reloadDataOnReplacingViewModel: Whether or not to reload or diff during replacement. Default is `false`.
3630
public init(
37-
diffOnBackgroundQueue: Bool = false,
3831
reloadDataOnReplacingViewModel: Bool = false
3932
) {
40-
self.diffOnBackgroundQueue = diffOnBackgroundQueue
4133
self.reloadDataOnReplacingViewModel = reloadDataOnReplacingViewModel
4234
}
4335
}

Sources/DebugDescriptions.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@ private func debugDescriptionBuilder<Target: TextOutputStream>(
133133
debugDescriptionBuilder(
134134
elements: [
135135
(.type(CollectionViewDriverOptions.self), indent + 2),
136-
(.field(label: "diffOnBackgroundQueue", value: options.diffOnBackgroundQueue), indent + 4),
137136
(.field(label: "reloadDataOnReplacingViewModel", value: options.reloadDataOnReplacingViewModel), indent + 4),
138137
(.end, indent + 2)
139138
],
@@ -203,7 +202,6 @@ func driverOptionsDebugDescription(_ options: CollectionViewDriverOptions) -> St
203202
debugDescriptionBuilder(
204203
elements: [
205204
(.type(CollectionViewDriverOptions.self), 0),
206-
(.field(label: "diffOnBackgroundQueue", value: options.diffOnBackgroundQueue), 2),
207205
(.field(label: "reloadDataOnReplacingViewModel", value: options.reloadDataOnReplacingViewModel), 2),
208206
(.end, 0)
209207
],

Sources/DiffableDataSource.swift

Lines changed: 44 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -26,34 +26,23 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource<AnyHashable,
2626
// Thus, unowned is safe here.
2727
private unowned let _collectionView: UICollectionView
2828

29-
private let _diffOnBackgroundQueue: Bool
30-
31-
private lazy var _diffingQueue = DispatchQueue(
32-
label: "com.jessesquires.ReactiveCollectionsKit",
33-
qos: .userInteractive,
34-
autoreleaseFrequency: .workItem
35-
)
36-
3729
var logger: Logging?
3830

3931
// MARK: Init
4032

4133
init(
4234
view: UICollectionView,
43-
diffOnBackgroundQueue: Bool,
4435
cellProvider: @escaping DiffableDataSource.CellProvider,
4536
supplementaryViewProvider: @escaping DiffableDataSource.SupplementaryViewProvider
4637
) {
4738
self._collectionView = view
48-
self._diffOnBackgroundQueue = diffOnBackgroundQueue
4939
super.init(collectionView: view, cellProvider: cellProvider)
5040
self.supplementaryViewProvider = supplementaryViewProvider
5141
}
5242

53-
convenience init(view: UICollectionView, diffOnBackgroundQueue: Bool) {
43+
convenience init(view: UICollectionView) {
5444
self.init(
5545
view: view,
56-
diffOnBackgroundQueue: diffOnBackgroundQueue,
5746
cellProvider: { _, _, _ in nil },
5847
supplementaryViewProvider: { _, _, _ in nil }
5948
)
@@ -66,12 +55,8 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource<AnyHashable,
6655

6756
let snapshot = DiffableSnapshot(viewModel: viewModel)
6857
self.applySnapshotUsingReloadData(snapshot) {
69-
// UIKit guarantees `completion` is called on the main queue.
70-
dispatchPrecondition(condition: .onQueue(.main))
71-
MainActor.assumeIsolated {
72-
completion?()
73-
self.logger?.log("DataSource reload snapshot completed")
74-
}
58+
completion?()
59+
self.logger?.log("DataSource reload snapshot completed")
7560
}
7661
}
7762

@@ -89,33 +74,18 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource<AnyHashable,
8974
// We need to inspect the current collection view state first, then pass this info downstream.
9075
let visibleItemIdentifiers = self._visibleItemIdentifiers()
9176

92-
if self._diffOnBackgroundQueue {
93-
self.logger?.log("DataSource using background queue")
94-
self._diffingQueue.async {
95-
self._applySnapshot(
96-
from: source,
97-
to: destination,
98-
withVisibleItems: visibleItemIdentifiers,
99-
animated: animated,
100-
completion: completion
101-
)
102-
}
103-
} else {
104-
self.logger?.log("DataSource using main queue")
105-
dispatchPrecondition(condition: .onQueue(.main))
106-
self._applySnapshot(
107-
from: source,
108-
to: destination,
109-
withVisibleItems: visibleItemIdentifiers,
110-
animated: animated,
111-
completion: completion
112-
)
113-
}
77+
self._applySnapshot(
78+
from: source,
79+
to: destination,
80+
withVisibleItems: visibleItemIdentifiers,
81+
animated: animated,
82+
completion: completion
83+
)
11484
}
11585

11686
// MARK: Private
11787

118-
nonisolated private func _applySnapshot(
88+
private func _applySnapshot(
11989
from source: CollectionViewModel,
12090
to destination: CollectionViewModel,
12191
withVisibleItems visibleItemIdentifiers: Set<UniqueIdentifier>,
@@ -153,59 +123,49 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource<AnyHashable,
153123
destinationSnapshot.reconfigureItems(itemsToReconfigure)
154124

155125
// Apply the snapshot with item reconfigure updates.
156-
//
157-
// Swift 6 complains about 'call to main actor-isolated instance method' here.
158-
// However, call this method from a background thread is valid according to the docs.
159126
self.apply(destinationSnapshot, animatingDifferences: animated) { [weak self] in
160-
// UIKit guarantees `completion` is called on the main queue.
161-
dispatchPrecondition(condition: .onQueue(.main))
162-
163127
guard let self else {
164-
MainActor.assumeIsolated {
165-
completion?()
166-
}
128+
completion?()
167129
return
168130
}
169131

170-
MainActor.assumeIsolated {
171-
// Once the snapshot with item reconfigures is applied,
172-
// we need to find and apply supplementary view reconfigures, if needed.
173-
//
174-
// This is necessary to update all headers, footers, and supplementary views.
175-
// Per notes above, supplementary views do not get reloaded / reconfigured
176-
// automatically by `DiffableDataSource` when they change.
177-
//
178-
// To trigger updates on supplementary views with the existing APIs,
179-
// the entire section must be reloaded. Yes, that sucks. We don't want to do that.
180-
// That causes all items in the section to be hard-reloaded, too.
181-
// Aside from the performance impact, doing that results in an ugly UI "flash"
182-
// for all item cells in the collection. Gross.
183-
//
184-
// However, we can actually do much better than a hard reload!
185-
// Instead of reloading the entire section, we can find and compare
186-
// the supplementary views and manually reconfigure them if they changed.
187-
//
188-
// NOTE: this only matters if supplementary views are not static.
189-
// That is, if they reflect data in the data source.
190-
//
191-
// For example, a header with a fixed title (e.g. "My Items") will NOT need to be reloaded.
192-
// However, a header that displays changing data WILL need to be reloaded.
193-
// (e.g. "My 10 Items")
194-
195-
// Check all the supplementary views and reconfigure them, if needed.
196-
self._reconfigureSupplementaryViewsIfNeeded(from: source, to: destination)
197-
198-
// Finally, we're done and can call completion.
199-
completion?()
200-
201-
self.logger?.log("DataSource diffing snapshot complete")
202-
}
132+
// Once the snapshot with item reconfigures is applied,
133+
// we need to find and apply supplementary view reconfigures, if needed.
134+
//
135+
// This is necessary to update all headers, footers, and supplementary views.
136+
// Per notes above, supplementary views do not get reloaded / reconfigured
137+
// automatically by `DiffableDataSource` when they change.
138+
//
139+
// To trigger updates on supplementary views with the existing APIs,
140+
// the entire section must be reloaded. Yes, that sucks. We don't want to do that.
141+
// That causes all items in the section to be hard-reloaded, too.
142+
// Aside from the performance impact, doing that results in an ugly UI "flash"
143+
// for all item cells in the collection. Gross.
144+
//
145+
// However, we can actually do much better than a hard reload!
146+
// Instead of reloading the entire section, we can find and compare
147+
// the supplementary views and manually reconfigure them if they changed.
148+
//
149+
// NOTE: this only matters if supplementary views are not static.
150+
// That is, if they reflect data in the data source.
151+
//
152+
// For example, a header with a fixed title (e.g. "My Items") will NOT need to be reloaded.
153+
// However, a header that displays changing data WILL need to be reloaded.
154+
// (e.g. "My 10 Items")
155+
156+
// Check all the supplementary views and reconfigure them, if needed.
157+
self._reconfigureSupplementaryViewsIfNeeded(from: source, to: destination)
158+
159+
// Finally, we're done and can call completion.
160+
completion?()
161+
162+
self.logger?.log("DataSource diffing snapshot complete")
203163
}
204164
}
205165

206166
// MARK: Reconfiguring Cells
207167

208-
nonisolated private func _findItemsToReconfigure(
168+
private func _findItemsToReconfigure(
209169
from source: CollectionViewModel,
210170
to destination: CollectionViewModel,
211171
withVisibleItems visibleItemIdentifiers: Set<UniqueIdentifier>

Tests/TestCollectionViewDriver.swift

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -430,23 +430,6 @@ final class TestCollectionViewDriver: UnitTestCase, @unchecked Sendable {
430430
self.waitForExpectations()
431431
}
432432

433-
@MainActor
434-
func test_update_callsCompletion_withBackgroundDiffing() {
435-
let driver = CollectionViewDriver(
436-
view: self.collectionView,
437-
options: .init(diffOnBackgroundQueue: true)
438-
)
439-
440-
let expectation = self.expectation()
441-
442-
let newModel = self.fakeCollectionViewModel()
443-
driver.update(viewModel: newModel, animated: true) { _ in
444-
expectation.fulfillAndLog()
445-
}
446-
447-
self.waitForExpectations()
448-
}
449-
450433
@MainActor
451434
func test_update_callsCompletion_withReloadOnReplace() {
452435
let driver = CollectionViewDriver(

0 commit comments

Comments
 (0)