From 353b9db489e4bba0724a239d4fff0310508cbe41 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 1 Apr 2025 08:23:33 -0300 Subject: [PATCH 1/9] Enable CoreData concurrency debug in EssentialApp scheme --- .../xcshareddata/xcschemes/EssentialApp.xcscheme | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialApp.xcscheme b/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialApp.xcscheme index 71a9fb6..43488d6 100644 --- a/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialApp.xcscheme +++ b/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialApp.xcscheme @@ -79,6 +79,12 @@ ReferencedContainer = "container:EssentialApp.xcodeproj"> + + + + Date: Tue, 1 Apr 2025 08:41:56 -0300 Subject: [PATCH 2/9] Move context.perform call up --- .../xcschemes/EssentialFeed.xcscheme | 6 + .../CoreData/CoreDataFeedStore.swift | 4 + .../CoreDataFeedImageDataStoreTests.swift | 135 ++++++++++-------- .../Feed Cache/CoreDataFeedStoreTests.swift | 76 +++++----- 4 files changed, 123 insertions(+), 98 deletions(-) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme index f774e6f..4fae60c 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme @@ -58,6 +58,12 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> + + + + Void) { + context.perform(action) + } + private func cleanUpReferencesToPersistentStores() { context.performAndWait { let coordinator = self.container.persistentStoreCoordinator diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift index 56798b2..c92c3ef 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift @@ -8,85 +8,94 @@ import EssentialFeed class CoreDataFeedImageDataStoreTests: XCTestCase { - func test_retrieveImageData_deliversNotFoundWhenEmpty() { - let sut = makeSUT() - - expect(sut, toCompleteRetrievalWith: notFound(), for: anyURL()) + func test_retrieveImageData_deliversNotFoundWhenEmpty() throws { + try makeSUT { sut in + expect(sut, toCompleteRetrievalWith: notFound(), for: anyURL()) + } } - func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() { - let sut = makeSUT() - let url = URL(string: "http://a-url.com")! - let nonMatchingURL = URL(string: "http://another-url.com")! - - insert(anyData(), for: url, into: sut) - - expect(sut, toCompleteRetrievalWith: notFound(), for: nonMatchingURL) + func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() throws { + try makeSUT { sut in + let url = URL(string: "http://a-url.com")! + let nonMatchingURL = URL(string: "http://another-url.com")! + + insert(anyData(), for: url, into: sut) + + expect(sut, toCompleteRetrievalWith: notFound(), for: nonMatchingURL) + } } - func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() { - let sut = makeSUT() - let storedData = anyData() - let matchingURL = URL(string: "http://a-url.com")! - - insert(storedData, for: matchingURL, into: sut) - - expect(sut, toCompleteRetrievalWith: found(storedData), for: matchingURL) + func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() throws { + try makeSUT { sut in + let storedData = anyData() + let matchingURL = URL(string: "http://a-url.com")! + + insert(storedData, for: matchingURL, into: sut) + + expect(sut, toCompleteRetrievalWith: found(storedData), for: matchingURL) + } } - func test_retrieveImageData_deliversLastInsertedValue() { - let sut = makeSUT() - let firstStoredData = Data("first".utf8) - let lastStoredData = Data("last".utf8) - let url = URL(string: "http://a-url.com")! - - insert(firstStoredData, for: url, into: sut) - insert(lastStoredData, for: url, into: sut) - - expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: url) + func test_retrieveImageData_deliversLastInsertedValue() throws { + try makeSUT { sut in + let firstStoredData = Data("first".utf8) + let lastStoredData = Data("last".utf8) + let url = URL(string: "http://a-url.com")! + + insert(firstStoredData, for: url, into: sut) + insert(lastStoredData, for: url, into: sut) + + expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: url) + } } // - MARK: Helpers - private func makeSUT(file: StaticString = #file, line: UInt = #line) -> CoreDataFeedStore { + private func makeSUT(_ test: @escaping (CoreDataFeedStore) -> Void, file: StaticString = #filePath, line: UInt = #line) throws { let storeURL = URL(fileURLWithPath: "/dev/null") - let sut = try! CoreDataFeedStore(storeURL: storeURL) + let sut = try CoreDataFeedStore(storeURL: storeURL) trackForMemoryLeaks(sut, file: file, line: line) - return sut - } - - private func notFound() -> Result { - return .success(.none) - } - - private func found(_ data: Data) -> Result { - return .success(data) + + let exp = expectation(description: "wait for operation") + sut.perform { + test(sut) + exp.fulfill() + } + wait(for: [exp], timeout: 0.1) } - private func localImage(url: URL) -> LocalFeedImage { - return LocalFeedImage(id: UUID(), description: "any", location: "any", url: url) - } +} + +private func notFound() -> Result { + return .success(.none) +} + +private func found(_ data: Data) -> Result { + return .success(data) +} + +private func localImage(url: URL) -> LocalFeedImage { + return LocalFeedImage(id: UUID(), description: "any", location: "any", url: url) +} + +private func expect(_ sut: CoreDataFeedStore, toCompleteRetrievalWith expectedResult: Result, for url: URL, file: StaticString = #filePath, line: UInt = #line) { + let receivedResult = Result { try sut.retrieve(dataForURL: url) } - private func expect(_ sut: CoreDataFeedStore, toCompleteRetrievalWith expectedResult: Result, for url: URL, file: StaticString = #file, line: UInt = #line) { - let receivedResult = Result { try sut.retrieve(dataForURL: url) } + switch (receivedResult, expectedResult) { + case let (.success( receivedData), .success(expectedData)): + XCTAssertEqual(receivedData, expectedData, file: file, line: line) - switch (receivedResult, expectedResult) { - case let (.success( receivedData), .success(expectedData)): - XCTAssertEqual(receivedData, expectedData, file: file, line: line) - - default: - XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) - } + default: + XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) } - - private func insert(_ data: Data, for url: URL, into sut: CoreDataFeedStore, file: StaticString = #file, line: UInt = #line) { - do { - let image = localImage(url: url) - try sut.insert([image], timestamp: Date()) - try sut.insert(data, for: url) - } catch { - XCTFail("Failed to insert \(data) with error \(error)", file: file, line: line) - } +} + +private func insert(_ data: Data, for url: URL, into sut: CoreDataFeedStore, file: StaticString = #filePath, line: UInt = #line) { + do { + let image = localImage(url: url) + try sut.insert([image], timestamp: Date()) + try sut.insert(data, for: url) + } catch { + XCTFail("Failed to insert \(data) with error \(error)", file: file, line: line) } - } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift index cb094d8..556ba26 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift @@ -9,79 +9,85 @@ import EssentialFeed class CoreDataFeedStoreTests: XCTestCase, FeedStoreSpecs { func test_retrieve_deliversEmptyOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) + } } func test_retrieve_hasNoSideEffectsOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) + } } func test_retrieve_deliversFoundValuesOnNonEmptyCache() throws { - let sut = try makeSUT() - - assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) + } } func test_retrieve_hasNoSideEffectsOnNonEmptyCache() throws { - let sut = try makeSUT() - - assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) + } } func test_insert_deliversNoErrorOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) + } } func test_insert_deliversNoErrorOnNonEmptyCache() throws { - let sut = try makeSUT() - - assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) + } } func test_insert_overridesPreviouslyInsertedCacheValues() throws { - let sut = try makeSUT() - - assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) + try makeSUT { sut in + self.assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) + } } func test_delete_deliversNoErrorOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) + } } func test_delete_hasNoSideEffectsOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatDeleteHasNoSideEffectsOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatDeleteHasNoSideEffectsOnEmptyCache(on: sut) + } } func test_delete_deliversNoErrorOnNonEmptyCache() throws { - let sut = try makeSUT() - - assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) + } } func test_delete_emptiesPreviouslyInsertedCache() throws { - let sut = try makeSUT() - - assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) + try makeSUT { sut in + self.assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) + } } // MARK: - Helpers - private func makeSUT(file: StaticString = #file, line: UInt = #line) throws -> FeedStore { + private func makeSUT(_ test: @escaping (CoreDataFeedStore) -> Void, file: StaticString = #file, line: UInt = #line) throws { let storeURL = URL(fileURLWithPath: "/dev/null") let sut = try CoreDataFeedStore(storeURL: storeURL) trackForMemoryLeaks(sut, file: file, line: line) - return sut + + let exp = expectation(description: "wait for operation") + sut.perform { + test(sut) + exp.fulfill() + } + wait(for: [exp], timeout: 0.1) } } From 114c1d29678ef9913d4cd8df2e249791ef2e59ed Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 1 Apr 2025 08:47:03 -0300 Subject: [PATCH 3/9] Run cache integration tests with a Core Data main queue context --- .../EssentialFeedCacheIntegrationTests.xcscheme | 6 ++++++ .../Infrastructure/CoreData/CoreDataFeedStore.swift | 9 +++++++-- .../EssentialFeedCacheIntegrationTests.swift | 4 ++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme index 8ec8721..5241595 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme @@ -42,6 +42,12 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> + + + + LocalFeedLoader { let storeURL = testSpecificStoreURL() - let store = try CoreDataFeedStore(storeURL: storeURL) + let store = try CoreDataFeedStore(storeURL: storeURL, contextQueue: .main) let sut = LocalFeedLoader(store: store, currentDate: { currentDate }) trackForMemoryLeaks(store, file: file, line: line) trackForMemoryLeaks(sut, file: file, line: line) @@ -117,7 +117,7 @@ class EssentialFeedCacheIntegrationTests: XCTestCase { private func makeImageLoader(file: StaticString = #file, line: UInt = #line) -> LocalFeedImageDataLoader { let storeURL = testSpecificStoreURL() - let store = try! CoreDataFeedStore(storeURL: storeURL) + let store = try! CoreDataFeedStore(storeURL: storeURL, contextQueue: .main) let sut = LocalFeedImageDataLoader(store: store) trackForMemoryLeaks(store, file: file, line: line) trackForMemoryLeaks(sut, file: file, line: line) From fbace87ec7d2a05db462e9951093785108d83768 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 1 Apr 2025 08:49:05 -0300 Subject: [PATCH 4/9] Enable CoreData concurrency debug in CI_macOS scheme --- .../xcshareddata/xcschemes/CI_macOS.xcscheme | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme index 34174fb..3adb467 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme @@ -45,6 +45,12 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> + + + + Date: Tue, 1 Apr 2025 22:08:48 -0300 Subject: [PATCH 5/9] Wrap store operations with Core Data store queue context scheduler --- .../EssentialApp/CombineHelpers.swift | 37 +++++++++++ EssentialApp/EssentialApp/SceneDelegate.swift | 19 ++++-- .../FeedAcceptanceTests.swift | 66 +++++++++++++------ .../CoreData/CoreDataFeedStore.swift | 4 ++ 4 files changed, 98 insertions(+), 28 deletions(-) diff --git a/EssentialApp/EssentialApp/CombineHelpers.swift b/EssentialApp/EssentialApp/CombineHelpers.swift index c8d51d6..c91cb9a 100644 --- a/EssentialApp/EssentialApp/CombineHelpers.swift +++ b/EssentialApp/EssentialApp/CombineHelpers.swift @@ -178,6 +178,43 @@ extension AnyDispatchQueueScheduler { static var immediateOnMainQueue: Self { DispatchQueue.immediateWhenOnMainQueueScheduler.eraseToAnyScheduler() } + + static func scheduler(for store: CoreDataFeedStore) -> AnyDispatchQueueScheduler { + CoreDataFeedStoreScheduler(store: store).eraseToAnyScheduler() + } + + private struct CoreDataFeedStoreScheduler: Scheduler { + let store: CoreDataFeedStore + + var now: SchedulerTimeType { .init(.now()) } + + var minimumTolerance: SchedulerTimeType.Stride { .zero } + + func schedule(after date: DispatchQueue.SchedulerTimeType, interval: DispatchQueue.SchedulerTimeType.Stride, tolerance: DispatchQueue.SchedulerTimeType.Stride, options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) -> any Cancellable { + if store.contextQueue == .main, Thread.isMainThread { + action() + } else { + store.perform(action) + } + return AnyCancellable {} + } + + func schedule(after date: DispatchQueue.SchedulerTimeType, tolerance: DispatchQueue.SchedulerTimeType.Stride, options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) { + if store.contextQueue == .main, Thread.isMainThread { + action() + } else { + store.perform(action) + } + } + + func schedule(options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) { + if store.contextQueue == .main, Thread.isMainThread { + action() + } else { + store.perform(action) + } + } + } } extension Scheduler { diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 6aad13c..9969e31 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -12,11 +12,17 @@ import EssentialFeed class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - private lazy var scheduler: AnyDispatchQueueScheduler = DispatchQueue( - label: "portocode.infra.queue", - qos: .userInitiated, - attributes: .concurrent - ).eraseToAnyScheduler() + private lazy var scheduler: AnyDispatchQueueScheduler = { + if let store = store as? CoreDataFeedStore { + return .scheduler(for: store) + } + + return DispatchQueue( + label: "com.essentialdeveloper.infra.queue", + qos: .userInitiated, + attributes: .concurrent + ).eraseToAnyScheduler() + }() private lazy var httpClient: HTTPClient = { URLSessionHTTPClient(session: URLSession(configuration: .ephemeral)) @@ -49,11 +55,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { imageLoader: makeLocalImageLoaderWithRemoteFallback, selection: showComments)) - convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore, scheduler: AnyDispatchQueueScheduler) { + convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) { self.init() self.httpClient = httpClient self.store = store - self.scheduler = scheduler } func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index cd60660..254caaa 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -10,8 +10,8 @@ import EssentialFeediOS class FeedAcceptanceTests: XCTestCase { - func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() { - let feed = launch(httpClient: .online(response), store: .empty) + func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() throws { + let feed = try launch(httpClient: .online(response), store: .empty) XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 2) XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0()) @@ -35,8 +35,8 @@ class FeedAcceptanceTests: XCTestCase { XCTAssertFalse(feed.canLoadMoreFeed) } - func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() { - let sharedStore = InMemoryFeedStore.empty + func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() throws { + let sharedStore = try CoreDataFeedStore.empty let onlineFeed = launch(httpClient: .online(response), store: sharedStore) onlineFeed.simulateFeedImageViewVisible(at: 0) @@ -52,30 +52,30 @@ class FeedAcceptanceTests: XCTestCase { XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 2), makeImageData2()) } - func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() { - let feed = launch(httpClient: .offline, store: .empty) + func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() throws { + let feed = try launch(httpClient: .offline, store: .empty) XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 0) } - func test_onEnteringBackground_deletesExpiredFeedCache() { - let store = InMemoryFeedStore.withExpiredFeedCache + func test_onEnteringBackground_deletesExpiredFeedCache() throws { + let store = try CoreDataFeedStore.withExpiredFeedCache enterBackground(with: store) - XCTAssertNil(store.feedCache, "Expected to delete expired cache") + XCTAssertNil(try store.retrieve(), "Expected to delete expired cache") } - func test_onEnteringBackground_keepsNonExpiredFeedCache() { - let store = InMemoryFeedStore.withNonExpiredFeedCache + func test_onEnteringBackground_keepsNonExpiredFeedCache() throws { + let store = try CoreDataFeedStore.withNonExpiredFeedCache enterBackground(with: store) - XCTAssertNotNil(store.feedCache, "Expected to keep non-expired cache") + XCTAssertNotNil(try store.retrieve(), "Expected to keep non-expired cache") } - func test_onFeedImageSelection_displaysComments() { - let comments = showCommentsForFirstImage() + func test_onFeedImageSelection_displaysComments() throws { + let comments = try showCommentsForFirstImage() XCTAssertEqual(comments.numberOfRenderedComments(), 1) XCTAssertEqual(comments.commentMessage(at: 0), makeCommentMessage()) @@ -85,9 +85,9 @@ class FeedAcceptanceTests: XCTestCase { private func launch( httpClient: HTTPClientStub = .offline, - store: InMemoryFeedStore = .empty + store: CoreDataFeedStore ) -> ListViewController { - let sut = SceneDelegate(httpClient: httpClient, store: store, scheduler: .immediateOnMainQueue) + let sut = SceneDelegate(httpClient: httpClient, store: store) sut.window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 1)) sut.configureWindow() @@ -97,13 +97,13 @@ class FeedAcceptanceTests: XCTestCase { return vc } - private func enterBackground(with store: InMemoryFeedStore) { - let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store, scheduler: .immediateOnMainQueue) + private func enterBackground(with store: CoreDataFeedStore) { + let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store) sut.sceneWillResignActive(UIApplication.shared.connectedScenes.first!) } - private func showCommentsForFirstImage() -> ListViewController { - let feed = launch(httpClient: .online(response), store: .empty) + private func showCommentsForFirstImage() throws -> ListViewController { + let feed = try launch(httpClient: .online(response), store: .empty) feed.simulateTapOnFeedImage(at: 0) RunLoop.current.run(until: Date()) @@ -133,7 +133,7 @@ class FeedAcceptanceTests: XCTestCase { case "/essential-feed/v1/feed" where url.query?.contains("after_id=166FCDD7-C9F4-420A-B2D6-CE2EAFA3D82F") == true: return makeLastEmptyFeedPageData() - + case "/essential-feed/v1/image/2AB2AE66-A4B7-4A16-B374-51BBAC8DB086/comments": return makeCommentsData() @@ -181,3 +181,27 @@ class FeedAcceptanceTests: XCTestCase { } } + +extension CoreDataFeedStore { + static var empty: CoreDataFeedStore { + get throws { + try CoreDataFeedStore(storeURL: URL(fileURLWithPath: "/dev/null"), contextQueue: .main) + } + } + + static var withExpiredFeedCache: CoreDataFeedStore { + get throws { + let store = try CoreDataFeedStore.empty + try store.insert([], timestamp: .distantPast) + return store + } + } + + static var withNonExpiredFeedCache: CoreDataFeedStore { + get throws { + let store = try CoreDataFeedStore.empty + try store.insert([], timestamp: Date()) + return store + } + } +} diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index 4ebc449..a76d9fe 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -22,6 +22,10 @@ public final class CoreDataFeedStore { case background } + public var contextQueue: ContextQueue { + context == container.viewContext ? .main : .background + } + public init(storeURL: URL, contextQueue: ContextQueue = .background) throws { guard let model = CoreDataFeedStore.model else { throw StoreError.modelNotFound From 37e2a93fd2799e273c28dafdb9c3d371f78c4216 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 1 Apr 2025 22:13:28 -0300 Subject: [PATCH 6/9] Remove performSync method --- ...CoreDataFeedStore+FeedImageDataStore.swift | 16 +++--------- .../CoreDataFeedStore+FeedStore.swift | 26 +++++-------------- .../CoreData/CoreDataFeedStore.swift | 9 +------ 3 files changed, 12 insertions(+), 39 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift index 032b52b..6d0ffc2 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift @@ -8,21 +8,13 @@ import Foundation extension CoreDataFeedStore: FeedImageDataStore { public func insert(_ data: Data, for url: URL) throws { - try performSync { context in - Result { - try ManagedFeedImage.first(with: url, in: context) - .map { $0.data = data } - .map(context.save) - } - } + try ManagedFeedImage.first(with: url, in: context) + .map { $0.data = data } + .map(context.save) } public func retrieve(dataForURL url: URL) throws -> Data? { - try performSync { context in - Result { - try ManagedFeedImage.data(with: url, in: context) - } - } + try ManagedFeedImage.data(with: url, in: context) } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift index 7b53624..e2cee46 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift @@ -8,32 +8,20 @@ import CoreData extension CoreDataFeedStore: FeedStore { public func retrieve() throws -> CachedFeed? { - try performSync { context in - Result { - try ManagedCache.find(in: context).map { - CachedFeed(feed: $0.localFeed, timestamp: $0.timestamp) - } - } + try ManagedCache.find(in: context).map { + CachedFeed(feed: $0.localFeed, timestamp: $0.timestamp) } } public func insert(_ feed: [LocalFeedImage], timestamp: Date) throws { - try performSync { context in - Result { - let managedCache = try ManagedCache.newUniqueInstance(in: context) - managedCache.timestamp = timestamp - managedCache.feed = ManagedFeedImage.images(from: feed, in: context) - try context.save() - } - } + let managedCache = try ManagedCache.newUniqueInstance(in: context) + managedCache.timestamp = timestamp + managedCache.feed = ManagedFeedImage.images(from: feed, in: context) + try context.save() } public func deleteCachedFeed() throws { - try performSync { context in - Result { - try ManagedCache.deleteCache(in: context) - } - } + try ManagedCache.deleteCache(in: context) } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index a76d9fe..88ad780 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -10,7 +10,7 @@ public final class CoreDataFeedStore { private static let model = NSManagedObjectModel.with(name: modelName, in: Bundle(for: CoreDataFeedStore.self)) private let container: NSPersistentContainer - private let context: NSManagedObjectContext + let context: NSManagedObjectContext enum StoreError: Error { case modelNotFound @@ -39,13 +39,6 @@ public final class CoreDataFeedStore { } } - func performSync(_ action: (NSManagedObjectContext) -> Result) throws -> R { - let context = self.context - var result: Result! - context.performAndWait { result = action(context) } - return try result.get() - } - public func perform(_ action: @escaping () -> Void) { context.perform(action) } From 31277a273a775a2686b7203d37c83375d0827fb3 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 1 Apr 2025 22:14:18 -0300 Subject: [PATCH 7/9] Enable CoreData concurrency debug in CI_iOS scheme --- .../xcshareddata/xcschemes/CI_iOS.xcscheme | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/CI_iOS.xcscheme b/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/CI_iOS.xcscheme index cc463eb..46d0428 100644 --- a/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/CI_iOS.xcscheme +++ b/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/CI_iOS.xcscheme @@ -45,6 +45,12 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> + + + + Date: Tue, 1 Apr 2025 22:35:53 -0300 Subject: [PATCH 8/9] 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 | 3 +- .../FeedUIIntegrationTests.swift | 11 +++-- .../Controllers/ListViewController.swift | 2 +- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/EssentialApp/EssentialApp/CombineHelpers.swift b/EssentialApp/EssentialApp/CombineHelpers.swift index c91cb9a..13dac68 100644 --- a/EssentialApp/EssentialApp/CombineHelpers.swift +++ b/EssentialApp/EssentialApp/CombineHelpers.swift @@ -119,8 +119,8 @@ private extension FeedCache { } extension Publisher { - func dispatchOnMainQueue() -> AnyPublisher { - receive(on: DispatchQueue.immediateWhenOnMainQueueScheduler).eraseToAnyPublisher() + func dispatchOnMainThread() -> AnyPublisher { + receive(on: DispatchQueue.immediateWhenOnMainThreadScheduler).eraseToAnyPublisher() } } @@ -170,6 +170,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 @@ -179,6 +212,10 @@ extension AnyDispatchQueueScheduler { DispatchQueue.immediateWhenOnMainQueueScheduler.eraseToAnyScheduler() } + static var immediateOnMainThread: Self { + DispatchQueue.immediateWhenOnMainThreadScheduler.eraseToAnyScheduler() + } + static func scheduler(for store: CoreDataFeedStore) -> AnyDispatchQueueScheduler { CoreDataFeedStoreScheduler(store: store).eraseToAnyScheduler() } diff --git a/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift index 53282ab..4302c1c 100644 --- a/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift +++ b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift @@ -11,6 +11,7 @@ final class LoadResourcePresentationAdapter { private let loader: () -> AnyPublisher private var cancellable: Cancellable? private var isLoading = false + var presenter: LoadResourcePresenter? init(loader: @escaping () -> AnyPublisher) { @@ -24,7 +25,7 @@ final class LoadResourcePresentationAdapter { isLoading = true cancellable = loader() - .dispatchOnMainQueue() + .dispatchOnMainThread() .handleEvents(receiveCancel: { [weak self] in self?.isLoading = false }) diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 8698061..035ad76 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -182,7 +182,7 @@ class FeedUIIntegrationTests: XCTestCase { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading() + loader.completeFeedLoading(with: [makeImage()]) XCTAssertEqual(loader.loadMoreCallCount, 0, "Expected no requests before until load more action") sut.simulateLoadMoreFeedAction() @@ -210,13 +210,13 @@ class FeedUIIntegrationTests: XCTestCase { sut.simulateAppearance() XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once view is loaded") - 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() @@ -279,10 +279,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") @@ -433,9 +432,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 ca2aa90..9f3f4c0 100644 --- a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift +++ b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift @@ -70,7 +70,7 @@ public final class ListViewController: UITableViewController, UITableViewDataSou snapshot.appendItems(cellControllers, toSection: section) } - dataSource.applySnapshotUsingReloadData(snapshot) + dataSource.apply(snapshot) } public func display(_ viewModel: ResourceLoadingViewModel) { From 8cff21aa8223d302ecdd3e19823e50301250ef63 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 1 Apr 2025 22:42:34 -0300 Subject: [PATCH 9/9] Resume shimmering animation when application re-enters foreground --- .../Views/Helpers/UIView+Shimmering.swift | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIView+Shimmering.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIView+Shimmering.swift index 7422d4d..414d1d3 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIView+Shimmering.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIView+Shimmering.swift @@ -16,37 +16,43 @@ extension UIView { } get { - return layer.mask?.animation(forKey: shimmerAnimationKey) != nil + layer.mask is ShimmeringLayer } } - private var shimmerAnimationKey: String { - return "shimmer" - } - private func startShimmering() { - let white = UIColor.white.cgColor - let alpha = UIColor.white.withAlphaComponent(0.75).cgColor - let width = bounds.width - let height = bounds.height - - let gradient = CAGradientLayer() - gradient.colors = [alpha, white, alpha] - gradient.startPoint = CGPoint(x: 0.0, y: 0.4) - gradient.endPoint = CGPoint(x: 1.0, y: 0.6) - gradient.locations = [0.4, 0.5, 0.6] - gradient.frame = CGRect(x: -width, y: 0, width: width*3, height: height) - layer.mask = gradient - - let animation = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.locations)) - animation.fromValue = [0.0, 0.1, 0.2] - animation.toValue = [0.8, 0.9, 1.0] - animation.duration = 1.25 - animation.repeatCount = .infinity - gradient.add(animation, forKey: shimmerAnimationKey) + layer.mask = ShimmeringLayer(size: bounds.size) } private func stopShimmering() { layer.mask = nil } + + private class ShimmeringLayer: CAGradientLayer { + private var observer: Any? + + convenience init(size: CGSize) { + self.init() + + let white = UIColor.white.cgColor + let alpha = UIColor.white.withAlphaComponent(0.75).cgColor + + colors = [alpha, white, alpha] + startPoint = CGPoint(x: 0.0, y: 0.4) + endPoint = CGPoint(x: 1.0, y: 0.6) + locations = [0.4, 0.5, 0.6] + frame = CGRect(x: -size.width, y: 0, width: size.width*3, height: size.height) + + let animation = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.locations)) + animation.fromValue = [0.0, 0.1, 0.2] + animation.toValue = [0.8, 0.9, 1.0] + animation.duration = 1.25 + animation.repeatCount = .infinity + add(animation, forKey: "shimmer") + + observer = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in + self?.add(animation, forKey: "shimmer") + } + } + } }