From 8b85942cae24ac11fa711bd2bcd120a86e483180 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Mon, 8 Dec 2025 10:27:45 +0100 Subject: [PATCH 1/5] Replace Combine's `LoadResourcePresentationAdapter` with async solution --- .../EssentialApp/CommentsUIComposer.swift | 2 +- .../EssentialApp/FeedUIComposer.swift | 2 +- .../EssentialApp/FeedViewAdapter.swift | 4 +- .../LoadResourcePresentationAdapter.swift | 55 +------------------ 4 files changed, 6 insertions(+), 57 deletions(-) diff --git a/EssentialApp/EssentialApp/CommentsUIComposer.swift b/EssentialApp/EssentialApp/CommentsUIComposer.swift index fe440a1b..6e6f1492 100644 --- a/EssentialApp/EssentialApp/CommentsUIComposer.swift +++ b/EssentialApp/EssentialApp/CommentsUIComposer.swift @@ -10,7 +10,7 @@ import EssentialFeediOS public final class CommentsUIComposer { private init() {} - private typealias CommentsPresentationAdapter = AsyncLoadResourcePresentationAdapter<[ImageComment], CommentsViewAdapter> + private typealias CommentsPresentationAdapter = LoadResourcePresentationAdapter<[ImageComment], CommentsViewAdapter> public static func commentsComposedWith( commentsLoader: @escaping () async throws -> [ImageComment] diff --git a/EssentialApp/EssentialApp/FeedUIComposer.swift b/EssentialApp/EssentialApp/FeedUIComposer.swift index 8cb9fc60..77119ecd 100644 --- a/EssentialApp/EssentialApp/FeedUIComposer.swift +++ b/EssentialApp/EssentialApp/FeedUIComposer.swift @@ -10,7 +10,7 @@ import EssentialFeediOS public final class FeedUIComposer { private init() {} - private typealias FeedPresentationAdapter = AsyncLoadResourcePresentationAdapter, FeedViewAdapter> + private typealias FeedPresentationAdapter = LoadResourcePresentationAdapter, FeedViewAdapter> public static func feedComposedWith( feedLoader: @MainActor @escaping () async throws -> Paginated, diff --git a/EssentialApp/EssentialApp/FeedViewAdapter.swift b/EssentialApp/EssentialApp/FeedViewAdapter.swift index 537bd471..307cd3a6 100644 --- a/EssentialApp/EssentialApp/FeedViewAdapter.swift +++ b/EssentialApp/EssentialApp/FeedViewAdapter.swift @@ -13,8 +13,8 @@ final class FeedViewAdapter: ResourceView { private let selection: (FeedImage) -> Void private let currentFeed: [FeedImage: CellController] - private typealias ImageDataPresentationAdapter = AsyncLoadResourcePresentationAdapter> - private typealias LoadMorePresentationAdapter = AsyncLoadResourcePresentationAdapter, FeedViewAdapter> + private typealias ImageDataPresentationAdapter = LoadResourcePresentationAdapter> + private typealias LoadMorePresentationAdapter = LoadResourcePresentationAdapter, FeedViewAdapter> init(currentFeed: [FeedImage: CellController] = [:], controller: ListViewController, imageLoader: @escaping (URL) async throws -> Data, selection: @escaping (FeedImage) -> Void) { self.currentFeed = currentFeed diff --git a/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift index bf6133b9..93dec0f7 100644 --- a/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift +++ b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift @@ -2,12 +2,11 @@ // Copyright © Essential Developer. All rights reserved. // -import Combine import EssentialFeed import EssentialFeediOS @MainActor -final class AsyncLoadResourcePresentationAdapter { +final class LoadResourcePresentationAdapter { private let loader: () async throws -> Resource private var cancellable: Task? private var isLoading = false @@ -46,57 +45,6 @@ final class AsyncLoadResourcePresentationAdapter { } } -extension AsyncLoadResourcePresentationAdapter: FeedImageCellControllerDelegate { - func didRequestImage() { - loadResource() - } - - func didCancelImageRequest() { - cancellable?.cancel() - cancellable = nil - isLoading = false - } -} - -@MainActor -final class LoadResourcePresentationAdapter { - private let loader: () -> AnyPublisher - private var cancellable: Cancellable? - private var isLoading = false - - var presenter: LoadResourcePresenter? - - init(loader: @escaping () -> AnyPublisher) { - self.loader = loader - } - - func loadResource() { - guard !isLoading else { return } - - presenter?.didStartLoading() - isLoading = true - - cancellable = loader() - .dispatchOnMainThread() - .handleEvents(receiveCancel: { [weak self] in - self?.isLoading = false - }) - .sink( - receiveCompletion: { [weak self] completion in - switch completion { - case .finished: break - - case let .failure(error): - self?.presenter?.didFinishLoading(with: error) - } - - self?.isLoading = false - }, receiveValue: { [weak self] resource in - self?.presenter?.didFinishLoading(with: resource) - }) - } -} - extension LoadResourcePresentationAdapter: FeedImageCellControllerDelegate { func didRequestImage() { loadResource() @@ -105,5 +53,6 @@ extension LoadResourcePresentationAdapter: FeedImageCellControllerDelegate { func didCancelImageRequest() { cancellable?.cancel() cancellable = nil + isLoading = false } } From 536bd27d15fce8ffd521552d7d56a6754a983398 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Mon, 8 Dec 2025 10:33:21 +0100 Subject: [PATCH 2/5] Remove Combine/DispatchQueue helpers --- .../EssentialApp.xcodeproj/project.pbxproj | 4 - .../EssentialApp/CombineHelpers.swift | 284 ------------------ EssentialApp/EssentialApp/SceneDelegate.swift | 38 +-- 3 files changed, 15 insertions(+), 311 deletions(-) delete mode 100644 EssentialApp/EssentialApp/CombineHelpers.swift diff --git a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj index 74d85304..62653143 100644 --- a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj +++ b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj @@ -24,7 +24,6 @@ 082C00042359E46C008927D3 /* XCTestCase+MemoryLeakTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 082C00032359E46C008927D3 /* XCTestCase+MemoryLeakTracking.swift */; }; 082C00062359E4C6008927D3 /* SharedTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 082C00052359E4C6008927D3 /* SharedTestHelpers.swift */; }; 0832C9D0238D2811002314C9 /* SceneDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0832C9CF238D2811002314C9 /* SceneDelegateTests.swift */; }; - 0835BF6D24850F9800A793D2 /* CombineHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0835BF6C24850F9800A793D2 /* CombineHelpers.swift */; }; 08367CD82486FB51009CD536 /* UIView+TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08367CD72486FB51009CD536 /* UIView+TestHelpers.swift */; }; 084BE5342EB38EC5006886E9 /* LoaderSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084BE5332EB38EC5006886E9 /* LoaderSpy.swift */; }; 0851CDAC239AB13100C19B1D /* HTTPClientStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0851CDAB239AB13100C19B1D /* HTTPClientStub.swift */; }; @@ -83,7 +82,6 @@ 082C00032359E46C008927D3 /* XCTestCase+MemoryLeakTracking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+MemoryLeakTracking.swift"; sourceTree = ""; }; 082C00052359E4C6008927D3 /* SharedTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedTestHelpers.swift; sourceTree = ""; }; 0832C9CF238D2811002314C9 /* SceneDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegateTests.swift; sourceTree = ""; }; - 0835BF6C24850F9800A793D2 /* CombineHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineHelpers.swift; sourceTree = ""; }; 08367CD72486FB51009CD536 /* UIView+TestHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+TestHelpers.swift"; sourceTree = ""; }; 084BE5332EB38EC5006886E9 /* LoaderSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoaderSpy.swift; sourceTree = ""; }; 0851CDAB239AB13100C19B1D /* HTTPClientStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClientStub.swift; sourceTree = ""; }; @@ -169,7 +167,6 @@ children = ( 0895DA86234B3B950031BB2D /* AppDelegate.swift */, 0895DA88234B3B950031BB2D /* SceneDelegate.swift */, - 0835BF6C24850F9800A793D2 /* CombineHelpers.swift */, 08073B42238D2DF900A75DC6 /* FeedUIComposer.swift */, 088B441B25309B6E00D75AAD /* CommentsUIComposer.swift */, 08073B40238D2DF900A75DC6 /* WeakRefVirtualProxy.swift */, @@ -315,7 +312,6 @@ 08073B48238D2DFA00A75DC6 /* WeakRefVirtualProxy.swift in Sources */, 0895DA89234B3B950031BB2D /* SceneDelegate.swift in Sources */, 08073B49238D2DFA00A75DC6 /* FeedViewAdapter.swift in Sources */, - 0835BF6D24850F9800A793D2 /* CombineHelpers.swift in Sources */, 088B441C25309B6E00D75AAD /* CommentsUIComposer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/EssentialApp/EssentialApp/CombineHelpers.swift b/EssentialApp/EssentialApp/CombineHelpers.swift deleted file mode 100644 index 61fd2243..00000000 --- a/EssentialApp/EssentialApp/CombineHelpers.swift +++ /dev/null @@ -1,284 +0,0 @@ -// -// Copyright © Essential Developer. All rights reserved. -// - -import Foundation -import Combine -import EssentialFeed - -@MainActor -public extension HTTPClient { - typealias Publisher = AnyPublisher<(Data, HTTPURLResponse), Error> - - func getPublisher(url: URL) -> Publisher { - var task: Task? - - return Deferred { - Future { completion in - nonisolated(unsafe) let uncheckedCompletion = completion - task = Task.immediate { - do { - let result = try await self.get(from: url) - uncheckedCompletion(.success(result)) - } catch { - uncheckedCompletion(.failure(error)) - } - } - } - } - .handleEvents(receiveCancel: { task?.cancel() }) - .eraseToAnyPublisher() - } -} - -public extension FeedImageDataLoader { - typealias Publisher = AnyPublisher - - func loadImageDataPublisher(from url: URL) -> Publisher { - return Deferred { - Future { completion in - completion(Result { - try self.loadImageData(from: url) - }) - } - } - .eraseToAnyPublisher() - } -} - -extension Publisher where Output == Data { - func caching(to cache: FeedImageDataCache, using url: URL) -> AnyPublisher { - handleEvents(receiveOutput: { data in - cache.saveIgnoringResult(data, for: url) - }).eraseToAnyPublisher() - } -} - -private extension FeedImageDataCache { - func saveIgnoringResult(_ data: Data, for url: URL) { - try? save(data, for: url) - } -} - -public extension LocalFeedLoader { - typealias Publisher = AnyPublisher<[FeedImage], Error> - - func loadPublisher() -> Publisher { - Deferred { - Future { completion in - completion(Result{ try self.load() }) - } - } - .eraseToAnyPublisher() - } -} - -extension Publisher { - func fallback(to fallbackPublisher: @escaping () -> AnyPublisher) -> AnyPublisher { - self.catch { _ in fallbackPublisher() }.eraseToAnyPublisher() - } -} - -extension Publisher { - func caching(to cache: FeedCache) -> AnyPublisher where Output == [FeedImage] { - handleEvents(receiveOutput: cache.saveIgnoringResult).eraseToAnyPublisher() - } - - func caching(to cache: FeedCache) -> AnyPublisher where Output == Paginated { - handleEvents(receiveOutput: cache.saveIgnoringResult).eraseToAnyPublisher() - } -} - -private extension FeedCache { - func saveIgnoringResult(_ feed: [FeedImage]) { - try? save(feed) - } - - func saveIgnoringResult(_ page: Paginated) { - saveIgnoringResult(page.items) - } -} - -extension Publisher { - func dispatchOnMainThread() -> AnyPublisher { - receive(on: DispatchQueue.immediateWhenOnMainThreadScheduler).eraseToAnyPublisher() - } -} - -extension DispatchQueue { - static var immediateWhenOnMainQueueScheduler: ImmediateWhenOnMainQueueScheduler { - ImmediateWhenOnMainQueueScheduler.shared - } - - struct ImmediateWhenOnMainQueueScheduler: Scheduler { - typealias SchedulerTimeType = DispatchQueue.SchedulerTimeType - typealias SchedulerOptions = DispatchQueue.SchedulerOptions - - var now: SchedulerTimeType { - DispatchQueue.main.now - } - - var minimumTolerance: SchedulerTimeType.Stride { - DispatchQueue.main.minimumTolerance - } - - static let shared = Self() - - private static let key = DispatchSpecificKey() - private static let value = UInt8.max - - private init() { - DispatchQueue.main.setSpecific(key: Self.key, value: Self.value) - } - - private func isMainQueue() -> Bool { - DispatchQueue.getSpecific(key: Self.key) == Self.value - } - - func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void) { - guard isMainQueue() 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) - } - } - - 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 - -extension AnyDispatchQueueScheduler { - static var immediateOnMainQueue: Self { - DispatchQueue.immediateWhenOnMainQueueScheduler.eraseToAnyScheduler() - } - - static var immediateOnMainThread: Self { - DispatchQueue.immediateWhenOnMainThreadScheduler.eraseToAnyScheduler() - } - - static func scheduler(for store: CoreDataFeedStore) -> AnyDispatchQueueScheduler { - CoreDataFeedStoreScheduler(store: store).eraseToAnyScheduler() - } - - @MainActor - 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 { - nonisolated(unsafe) let uncheckedAction = action - Task.immediate { - await store.perform { uncheckedAction() } - } - } - 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 { - nonisolated(unsafe) let uncheckedAction = action - Task.immediate { - await store.perform { uncheckedAction() } - } - } - } - - func schedule(options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) { - if store.contextQueue == .main, Thread.isMainThread { - action() - } else { - nonisolated(unsafe) let uncheckedAction = action - Task.immediate { - await store.perform { uncheckedAction() } - } - } - } - } -} - -extension Scheduler { - func eraseToAnyScheduler() -> AnyScheduler { - AnyScheduler(self) - } -} - -struct AnyScheduler: Scheduler where SchedulerTimeType.Stride: SchedulerTimeIntervalConvertible { - private let _now: () -> SchedulerTimeType - private let _minimumTolerance: () -> SchedulerTimeType.Stride - private let _schedule: (SchedulerOptions?, @escaping () -> Void) -> Void - private let _scheduleAfter: (SchedulerTimeType, SchedulerTimeType.Stride, SchedulerOptions?, @escaping () -> Void) -> Void - private let _scheduleAfterInterval: (SchedulerTimeType, SchedulerTimeType.Stride, SchedulerTimeType.Stride, SchedulerOptions?, @escaping () -> Void) -> Cancellable - - init(_ scheduler: S) where SchedulerTimeType == S.SchedulerTimeType, SchedulerOptions == S.SchedulerOptions, S: Scheduler { - _now = { scheduler.now } - _minimumTolerance = { scheduler.minimumTolerance } - _schedule = scheduler.schedule(options:_:) - _scheduleAfter = scheduler.schedule(after:tolerance:options:_:) - _scheduleAfterInterval = scheduler.schedule(after:interval:tolerance:options:_:) - } - - var now: SchedulerTimeType { _now() } - - var minimumTolerance: SchedulerTimeType.Stride { _minimumTolerance() } - - func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void) { - _schedule(options, action) - } - - func schedule(after date: SchedulerTimeType, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void) { - _scheduleAfter(date, tolerance, options, action) - } - - func schedule(after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void) -> Cancellable { - _scheduleAfterInterval(date, interval, tolerance, options, action) - } -} diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 82b3c6a2..439f1232 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -9,18 +9,7 @@ import EssentialFeed class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - - private lazy var scheduler: AnyDispatchQueueScheduler = { - if let store = store as? CoreDataFeedStore { - return .scheduler(for: store) - } - return DispatchQueue( - label: "com.essentialdeveloper.infra.queue", - qos: .userInitiated - ).eraseToAnyScheduler() - }() - private lazy var httpClient: HTTPClient = { URLSessionHTTPClient(session: URLSession(configuration: .ephemeral)) }() @@ -39,11 +28,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { return InMemoryFeedStore() } }() - - private lazy var localFeedLoader: LocalFeedLoader = { - LocalFeedLoader(store: store, currentDate: Date.init) - }() - + private lazy var baseURL = URL(string: "https://ile-api.essentialdeveloper.com/essential-feed")! private lazy var navigationController = UINavigationController( @@ -71,13 +56,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func sceneWillResignActive(_ scene: UIScene) { - scheduler.schedule { [localFeedLoader, logger] in - do { - try localFeedLoader.validateCache() - } catch { - logger.error("Failed to validate cache with error: \(error.localizedDescription)") - } - } + validateCache() } private func showComments(for image: FeedImage) { @@ -86,6 +65,19 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { navigationController.pushViewController(comments, animated: true) } + private func validateCache() { + Task.immediate { @MainActor in + await store.schedule { [store, logger] in + do { + let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) + try localFeedLoader.validateCache() + } catch { + logger.error("Failed to validate cache with error: \(error.localizedDescription)") + } + } + } + } + private func loadComments(url: URL) -> () async throws -> [ImageComment] { return { [httpClient] in let (data, response) = try await httpClient.get(from: url) From dc1ca14e90467f56fcdf4bb75f44d90c1bb6764b Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Mon, 8 Dec 2025 10:36:44 +0100 Subject: [PATCH 3/5] Migrate to Swift 6 --- .../EssentialApp.xcodeproj/project.pbxproj | 8 +++---- EssentialApp/EssentialApp/AppDelegate.swift | 2 +- .../EssentialFeed.xcodeproj/project.pbxproj | 24 +++++++++---------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj index 62653143..7a3e4e05 100644 --- a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj +++ b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj @@ -510,7 +510,7 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -536,7 +536,7 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; @@ -563,7 +563,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EssentialApp.app/EssentialApp"; }; @@ -590,7 +590,7 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EssentialApp.app/EssentialApp"; }; diff --git a/EssentialApp/EssentialApp/AppDelegate.swift b/EssentialApp/EssentialApp/AppDelegate.swift index fc520dc4..238f224d 100644 --- a/EssentialApp/EssentialApp/AppDelegate.swift +++ b/EssentialApp/EssentialApp/AppDelegate.swift @@ -4,5 +4,5 @@ import UIKit -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate {} diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 4714d07a..708e5c79 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -1546,7 +1546,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -1581,7 +1581,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; @@ -1607,7 +1607,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -1632,7 +1632,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; @@ -1656,7 +1656,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -1680,7 +1680,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; @@ -1704,7 +1704,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -1728,7 +1728,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; @@ -1765,7 +1765,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -1801,7 +1801,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; }; @@ -1830,7 +1830,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -1857,7 +1857,7 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; }; From 395b4ef32a4bc542bd79186e913fc44513535a2a Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Mon, 8 Dec 2025 10:57:27 +0100 Subject: [PATCH 4/5] Make View protocols MainActor-isolated --- EssentialApp/EssentialAppTests/Helpers/SharedTestHelpers.swift | 1 + .../Shared Presentation/LoadResourcePresenter.swift | 2 ++ .../EssentialFeed/Shared Presentation/ResourceErrorView.swift | 1 + .../EssentialFeed/Shared Presentation/ResourceLoadingView.swift | 1 + 4 files changed, 5 insertions(+) diff --git a/EssentialApp/EssentialAppTests/Helpers/SharedTestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/SharedTestHelpers.swift index 4e35168c..e1fefaae 100644 --- a/EssentialApp/EssentialAppTests/Helpers/SharedTestHelpers.swift +++ b/EssentialApp/EssentialAppTests/Helpers/SharedTestHelpers.swift @@ -25,6 +25,7 @@ private class DummyView: ResourceView { func display(_ viewModel: Any) {} } +@MainActor var loadError: String { LoadResourcePresenter.loadError } diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift index cee82f61..79ddafb4 100644 --- a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift @@ -4,12 +4,14 @@ import Foundation +@MainActor public protocol ResourceView { associatedtype ResourceViewModel func display(_ viewModel: ResourceViewModel) } +@MainActor public final class LoadResourcePresenter { public typealias Mapper = (Resource) throws -> View.ResourceViewModel diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorView.swift b/EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorView.swift index 27de5e37..8fb0fa14 100644 --- a/EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorView.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorView.swift @@ -4,6 +4,7 @@ import Foundation +@MainActor public protocol ResourceErrorView { func display(_ viewModel: ResourceErrorViewModel) } diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingView.swift b/EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingView.swift index ce87c042..e69dc007 100644 --- a/EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingView.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingView.swift @@ -4,6 +4,7 @@ import Foundation +@MainActor public protocol ResourceLoadingView { func display(_ viewModel: ResourceLoadingViewModel) } From 32022fd58db3623f59e16862b402031978818347 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Mon, 8 Dec 2025 11:13:22 +0100 Subject: [PATCH 5/5] Extract new `FeedService` class to simplify `SceneDelegate` --- .../EssentialApp.xcodeproj/project.pbxproj | 4 + EssentialApp/EssentialApp/FeedService.swift | 164 ++++++++++++++++++ EssentialApp/EssentialApp/SceneDelegate.swift | 162 +---------------- 3 files changed, 175 insertions(+), 155 deletions(-) create mode 100644 EssentialApp/EssentialApp/FeedService.swift diff --git a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj index 7a3e4e05..71d6dd4a 100644 --- a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj +++ b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 0895DAAC234B3F7E0031BB2D /* EssentialFeed.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0895DAA9234B3F7E0031BB2D /* EssentialFeed.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0895DAAD234B3F7E0031BB2D /* EssentialFeediOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0895DAAA234B3F7E0031BB2D /* EssentialFeediOS.framework */; }; 0895DAAE234B3F7E0031BB2D /* EssentialFeediOS.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0895DAAA234B3F7E0031BB2D /* EssentialFeediOS.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 08CD096A2EE6DB0600C2E75A /* FeedService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CD09692EE6DB0600C2E75A /* FeedService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -100,6 +101,7 @@ 08B5033725346BAC003FF218 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/LaunchScreen.strings; sourceTree = ""; }; 08B5033925346BE1003FF218 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/LaunchScreen.strings"; sourceTree = ""; }; 08B5033B25346BFE003FF218 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/LaunchScreen.strings; sourceTree = ""; }; + 08CD09692EE6DB0600C2E75A /* FeedService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedService.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -167,6 +169,7 @@ children = ( 0895DA86234B3B950031BB2D /* AppDelegate.swift */, 0895DA88234B3B950031BB2D /* SceneDelegate.swift */, + 08CD09692EE6DB0600C2E75A /* FeedService.swift */, 08073B42238D2DF900A75DC6 /* FeedUIComposer.swift */, 088B441B25309B6E00D75AAD /* CommentsUIComposer.swift */, 08073B40238D2DF900A75DC6 /* WeakRefVirtualProxy.swift */, @@ -307,6 +310,7 @@ buildActionMask = 2147483647; files = ( 08073B44238D2DFA00A75DC6 /* FeedUIComposer.swift in Sources */, + 08CD096A2EE6DB0600C2E75A /* FeedService.swift in Sources */, 0895DA87234B3B950031BB2D /* AppDelegate.swift in Sources */, 08073B45238D2DFA00A75DC6 /* LoadResourcePresentationAdapter.swift in Sources */, 08073B48238D2DFA00A75DC6 /* WeakRefVirtualProxy.swift in Sources */, diff --git a/EssentialApp/EssentialApp/FeedService.swift b/EssentialApp/EssentialApp/FeedService.swift new file mode 100644 index 00000000..b1663b07 --- /dev/null +++ b/EssentialApp/EssentialApp/FeedService.swift @@ -0,0 +1,164 @@ +// +// Copyright © Essential Developer. All rights reserved. +// + +import os +import CoreData +import EssentialFeed + +@MainActor +final class FeedService { + + private lazy var httpClient: HTTPClient = { + URLSessionHTTPClient(session: URLSession(configuration: .ephemeral)) + }() + + private lazy var logger = Logger(subsystem: "com.essentialdeveloper.EssentialAppCaseStudy", category: "main") + + private lazy var store: FeedStore & FeedImageDataStore & Scheduler & Sendable = { + do { + return try CoreDataFeedStore( + storeURL: NSPersistentContainer + .defaultDirectoryURL() + .appendingPathComponent("feed-store.sqlite")) + } catch { + assertionFailure("Failed to instantiate CoreData store with error: \(error.localizedDescription)") + logger.fault("Failed to instantiate CoreData store with error: \(error.localizedDescription)") + return InMemoryFeedStore() + } + }() + + private lazy var baseURL = URL(string: "https://ile-api.essentialdeveloper.com/essential-feed")! + + convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore & Scheduler & Sendable) { + self.init() + self.httpClient = httpClient + self.store = store + } + + func validateCache() { + Task.immediate { @MainActor in + await store.schedule { [store, logger] in + do { + let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) + try localFeedLoader.validateCache() + } catch { + logger.error("Failed to validate cache with error: \(error.localizedDescription)") + } + } + } + } + + func loadComments(for image: FeedImage) -> () async throws -> [ImageComment] { + return { [httpClient, baseURL] in + let url = ImageCommentsEndpoint.get(image.id).url(baseURL: baseURL) + let (data, response) = try await httpClient.get(from: url) + return try ImageCommentsMapper.map(data, from: response) + } + } + + func loadRemoteFeedWithLocalFallback() async throws -> Paginated { + do { + let feed = try await loadAndCacheRemoteFeed() + return makeFirstPage(items: feed) + } catch { + let feed = try await loadLocalFeed() + return makeFirstPage(items: feed) + } + } + + private func loadAndCacheRemoteFeed() async throws -> [FeedImage] { + let feed = try await loadRemoteFeed() + await store.schedule { [store] in + let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) + try? localFeedLoader.save(feed) + } + return feed + } + + private func loadLocalFeed() async throws -> [FeedImage] { + try await store.schedule { [store] in + let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) + return try localFeedLoader.load() + } + } + + private func loadRemoteFeed(after: FeedImage? = nil) async throws -> [FeedImage] { + let url = FeedEndpoint.get(after: after).url(baseURL: baseURL) + let (data, response) = try await httpClient.get(from: url) + return try FeedItemsMapper.map(data, from: response) + } + + private func loadMoreRemoteFeed(last: FeedImage?) async throws -> Paginated { + async let cachedItems = try await loadLocalFeed() + async let newItems = try await loadRemoteFeed(after: last) + + let items = try await cachedItems + newItems + + await store.schedule { [store] in + let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) + try? localFeedLoader.save(items) + } + + return try await makePage(items: items, last: newItems.last) + } + + private func makeFirstPage(items: [FeedImage]) -> Paginated { + makePage(items: items, last: items.last) + } + + private func makePage(items: [FeedImage], last: FeedImage?) -> Paginated { + Paginated(items: items, loadMore: last.map { last in + { @MainActor @Sendable in try await self.loadMoreRemoteFeed(last: last) } + }) + } + + func loadLocalImageWithRemoteFallback(url: URL) async throws -> Data { + do { + return try await loadLocalImage(url: url) + } catch { + return try await loadAndCacheRemoteImage(url: url) + } + } + + private func loadLocalImage(url: URL) async throws -> Data { + try await store.schedule { [store] in + let localImageLoader = LocalFeedImageDataLoader(store: store) + let imageData = try localImageLoader.loadImageData(from: url) + return imageData + } + } + + private func loadAndCacheRemoteImage(url: URL) async throws -> Data { + let (data, response) = try await httpClient.get(from: url) + let imageData = try FeedImageDataMapper.map(data, from: response) + await store.schedule { [store] in + let localImageLoader = LocalFeedImageDataLoader(store: store) + try? localImageLoader.save(data, for: url) + } + return imageData + } +} + +protocol Scheduler { + @MainActor + func schedule(_ action: @escaping @Sendable () throws -> T) async rethrows -> T +} + +extension CoreDataFeedStore: Scheduler { + @MainActor + func schedule(_ action: @escaping @Sendable () throws -> T) async rethrows -> T { + if contextQueue == .main { + return try action() + } else { + return try await perform(action) + } + } +} + +extension InMemoryFeedStore: Scheduler { + @MainActor + func schedule(_ action: @escaping @Sendable () throws -> T) async rethrows -> T { + try action() + } +} diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 439f1232..0d70d047 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -2,45 +2,23 @@ // Copyright © Essential Developer. All rights reserved. // -import os import UIKit -import CoreData import EssentialFeed class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - - private lazy var httpClient: HTTPClient = { - URLSessionHTTPClient(session: URLSession(configuration: .ephemeral)) - }() - - private lazy var logger = Logger(subsystem: "com.essentialdeveloper.EssentialAppCaseStudy", category: "main") - private lazy var store: FeedStore & FeedImageDataStore & StoreScheduler & Sendable = { - do { - return try CoreDataFeedStore( - storeURL: NSPersistentContainer - .defaultDirectoryURL() - .appendingPathComponent("feed-store.sqlite")) - } catch { - assertionFailure("Failed to instantiate CoreData store with error: \(error.localizedDescription)") - logger.fault("Failed to instantiate CoreData store with error: \(error.localizedDescription)") - return InMemoryFeedStore() - } - }() - - private lazy var baseURL = URL(string: "https://ile-api.essentialdeveloper.com/essential-feed")! + private lazy var feedService = FeedService() private lazy var navigationController = UINavigationController( rootViewController: FeedUIComposer.feedComposedWith( - feedLoader: loadRemoteFeedWithLocalFallback, - imageLoader: loadLocalImageWithRemoteFallback, + feedLoader: feedService.loadRemoteFeedWithLocalFallback, + imageLoader: feedService.loadLocalImageWithRemoteFallback, selection: showComments)) - convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore & StoreScheduler & Sendable) { + convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore & Scheduler & Sendable) { self.init() - self.httpClient = httpClient - self.store = store + self.feedService = FeedService(httpClient: httpClient, store: store) } func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { @@ -56,137 +34,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func sceneWillResignActive(_ scene: UIScene) { - validateCache() + feedService.validateCache() } private func showComments(for image: FeedImage) { - let url = ImageCommentsEndpoint.get(image.id).url(baseURL: baseURL) - let comments = CommentsUIComposer.commentsComposedWith(commentsLoader: loadComments(url: url)) + let comments = CommentsUIComposer.commentsComposedWith(commentsLoader: feedService.loadComments(for: image)) navigationController.pushViewController(comments, animated: true) } - - private func validateCache() { - Task.immediate { @MainActor in - await store.schedule { [store, logger] in - do { - let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) - try localFeedLoader.validateCache() - } catch { - logger.error("Failed to validate cache with error: \(error.localizedDescription)") - } - } - } - } - - private func loadComments(url: URL) -> () async throws -> [ImageComment] { - return { [httpClient] in - let (data, response) = try await httpClient.get(from: url) - return try ImageCommentsMapper.map(data, from: response) - } - } - - private func loadRemoteFeedWithLocalFallback() async throws -> Paginated { - do { - let feed = try await loadAndCacheRemoteFeed() - return makeFirstPage(items: feed) - } catch { - let feed = try await loadLocalFeed() - return makeFirstPage(items: feed) - } - } - - private func loadAndCacheRemoteFeed() async throws -> [FeedImage] { - let feed = try await loadRemoteFeed() - await store.schedule { [store] in - let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) - try? localFeedLoader.save(feed) - } - return feed - } - - private func loadLocalFeed() async throws -> [FeedImage] { - try await store.schedule { [store] in - let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) - return try localFeedLoader.load() - } - } - - private func loadRemoteFeed(after: FeedImage? = nil) async throws -> [FeedImage] { - let url = FeedEndpoint.get(after: after).url(baseURL: baseURL) - let (data, response) = try await httpClient.get(from: url) - return try FeedItemsMapper.map(data, from: response) - } - - private func loadMoreRemoteFeed(last: FeedImage?) async throws -> Paginated { - async let cachedItems = try await loadLocalFeed() - async let newItems = try await loadRemoteFeed(after: last) - - let items = try await cachedItems + newItems - - await store.schedule { [store] in - let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) - try? localFeedLoader.save(items) - } - - return try await makePage(items: items, last: newItems.last) - } - - private func makeFirstPage(items: [FeedImage]) -> Paginated { - makePage(items: items, last: items.last) - } - - private func makePage(items: [FeedImage], last: FeedImage?) -> Paginated { - Paginated(items: items, loadMore: last.map { last in - { @MainActor @Sendable in try await self.loadMoreRemoteFeed(last: last) } - }) - } - - private func loadLocalImageWithRemoteFallback(url: URL) async throws -> Data { - do { - return try await loadLocalImage(url: url) - } catch { - return try await loadAndCacheRemoteImage(url: url) - } - } - - private func loadLocalImage(url: URL) async throws -> Data { - try await store.schedule { [store] in - let localImageLoader = LocalFeedImageDataLoader(store: store) - let imageData = try localImageLoader.loadImageData(from: url) - return imageData - } - } - - private func loadAndCacheRemoteImage(url: URL) async throws -> Data { - let (data, response) = try await httpClient.get(from: url) - let imageData = try FeedImageDataMapper.map(data, from: response) - await store.schedule { [store] in - let localImageLoader = LocalFeedImageDataLoader(store: store) - try? localImageLoader.save(data, for: url) - } - return imageData - } -} - -protocol StoreScheduler { - @MainActor - func schedule(_ action: @escaping @Sendable () throws -> T) async rethrows -> T -} - -extension CoreDataFeedStore: StoreScheduler { - @MainActor - func schedule(_ action: @escaping @Sendable () throws -> T) async rethrows -> T { - if contextQueue == .main { - return try action() - } else { - return try await perform(action) - } - } -} - -extension InMemoryFeedStore: StoreScheduler { - @MainActor - func schedule(_ action: @escaping @Sendable () throws -> T) async rethrows -> T { - try action() - } }