From 0fbdd1a47a6df8a356d89d668c44dfbcc2b159ac Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Thu, 21 Mar 2024 13:55:43 +0200 Subject: [PATCH] Use `UIDiffableDataSource.apply` to perform diff and only update what's changed (with animation) Since the diffable data source diffing happens on its own dedicated queue (not the main queue) that may also run work on the main thread, we had to replace the `ImmediateWhenOnMainQueueScheduler` with the `ImmediateWhenOnMainThreadScheduler`. Also, we had to rearrange the setup of some tests since the diffable data source eagerly preloads cells. --- .../EssentialApp/CombineHelpers.swift | 41 ++++++++++++++++++- .../LoadResourcePresentationAdapter.swift | 2 +- .../FeedAcceptanceTests.swift | 4 +- .../FeedUIIntegrationTests.swift | 15 ++++--- .../Controllers/ListViewController.swift | 2 +- 5 files changed, 50 insertions(+), 14 deletions(-) diff --git a/EssentialApp/EssentialApp/CombineHelpers.swift b/EssentialApp/EssentialApp/CombineHelpers.swift index 8b0bdda7..23c205d4 100644 --- a/EssentialApp/EssentialApp/CombineHelpers.swift +++ b/EssentialApp/EssentialApp/CombineHelpers.swift @@ -117,8 +117,8 @@ private extension FeedCache { } extension Publisher { - func dispatchOnMainQueue() -> AnyPublisher { - receive(on: DispatchQueue.immediateWhenOnMainQueueScheduler).eraseToAnyPublisher() + func dispatchOnMainThread() -> AnyPublisher { + receive(on: DispatchQueue.immediateWhenOnMainThreadScheduler).eraseToAnyPublisher() } } @@ -168,6 +168,39 @@ extension DispatchQueue { DispatchQueue.main.schedule(after: date, interval: interval, tolerance: tolerance, options: options, action) } } + + static var immediateWhenOnMainThreadScheduler: ImmediateWhenOnMainThreadScheduler { + ImmediateWhenOnMainThreadScheduler() + } + + struct ImmediateWhenOnMainThreadScheduler: Scheduler { + typealias SchedulerTimeType = DispatchQueue.SchedulerTimeType + typealias SchedulerOptions = DispatchQueue.SchedulerOptions + + var now: SchedulerTimeType { + DispatchQueue.main.now + } + + var minimumTolerance: SchedulerTimeType.Stride { + DispatchQueue.main.minimumTolerance + } + + func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void) { + guard Thread.isMainThread else { + return DispatchQueue.main.schedule(options: options, action) + } + + action() + } + + func schedule(after date: SchedulerTimeType, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void) { + DispatchQueue.main.schedule(after: date, tolerance: tolerance, options: options, action) + } + + func schedule(after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void) -> Cancellable { + DispatchQueue.main.schedule(after: date, interval: interval, tolerance: tolerance, options: options, action) + } + } } typealias AnyDispatchQueueScheduler = AnyScheduler @@ -176,6 +209,10 @@ extension AnyDispatchQueueScheduler { static var immediateOnMainQueue: Self { DispatchQueue.immediateWhenOnMainQueueScheduler.eraseToAnyScheduler() } + + static var immediateOnMainThread: Self { + DispatchQueue.immediateWhenOnMainThreadScheduler.eraseToAnyScheduler() + } } extension Scheduler { diff --git a/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift index d5a8611f..80f1df18 100644 --- a/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift +++ b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift @@ -24,7 +24,7 @@ final class LoadResourcePresentationAdapter { isLoading = true cancellable = loader() - .dispatchOnMainQueue() + .dispatchOnMainThread() .handleEvents(receiveCancel: { [weak self] in self?.isLoading = false }) diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 5901aef4..43958298 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -86,7 +86,7 @@ class FeedAcceptanceTests: XCTestCase { httpClient: HTTPClientStub = .offline, store: InMemoryFeedStore = .empty ) -> ListViewController { - let sut = SceneDelegate(httpClient: httpClient, store: store, scheduler: .immediateOnMainQueue) + let sut = SceneDelegate(httpClient: httpClient, store: store, scheduler: .immediateOnMainThread) sut.window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 1)) sut.configureWindow() @@ -97,7 +97,7 @@ class FeedAcceptanceTests: XCTestCase { } private func enterBackground(with store: InMemoryFeedStore) { - let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store, scheduler: .immediateOnMainQueue) + let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store, scheduler: .immediateOnMainThread) sut.sceneWillResignActive(UIApplication.shared.connectedScenes.first!) } diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 6b2f652c..a8b28cb6 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -180,7 +180,7 @@ class FeedUIIntegrationTests: XCTestCase { func test_loadMoreActions_requestMoreFromLoader() { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading() + loader.completeFeedLoading(with: [makeImage()]) XCTAssertEqual(loader.loadMoreCallCount, 0, "Expected no requests before until load more action") @@ -209,13 +209,13 @@ class FeedUIIntegrationTests: XCTestCase { sut.simulateAppearance() XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once view appears") - loader.completeFeedLoading(at: 0) + loader.completeFeedLoading(with: [makeImage()], at: 0) XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once loading completes successfully") sut.simulateLoadMoreFeedAction() XCTAssertTrue(sut.isShowingLoadMoreFeedIndicator, "Expected loading indicator on load more action") - loader.completeLoadMore(at: 0) + loader.completeLoadMore(with: [makeImage()], at: 0) XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once user initiated loading completes successfully") sut.simulateLoadMoreFeedAction() @@ -278,10 +278,9 @@ class FeedUIIntegrationTests: XCTestCase { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [image0, image1]) - XCTAssertEqual(loader.loadedImageURLs, [], "Expected no image URL requests until views become visible") - + + loader.completeFeedLoading(with: [image0, image1]) sut.simulateFeedImageViewVisible(at: 0) XCTAssertEqual(loader.loadedImageURLs, [image0.url], "Expected first image URL request once first view becomes visible") @@ -436,9 +435,9 @@ class FeedUIIntegrationTests: XCTestCase { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [image0, image1]) XCTAssertEqual(loader.loadedImageURLs, [], "Expected no image URL requests until image is near visible") - + + loader.completeFeedLoading(with: [image0, image1]) sut.simulateFeedImageViewNearVisible(at: 0) XCTAssertEqual(loader.loadedImageURLs, [image0.url], "Expected first image URL request once first image is near visible") diff --git a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift index 0533ebec..18e7f9c9 100644 --- a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift +++ b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift @@ -73,7 +73,7 @@ public final class ListViewController: UITableViewController, UITableViewDataSou snapshot.appendItems(cellControllers, toSection: section) } - dataSource.applySnapshotUsingReloadData(snapshot) + dataSource.apply(snapshot) } public func display(_ viewModel: ResourceLoadingViewModel) {