Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
cacf900
Deprecate async APIs in favor of new sync APIs
PortoCode Mar 28, 2025
d4a8f48
Save images synchronously
PortoCode Mar 28, 2025
4ec2287
Load images synchronously
PortoCode Mar 28, 2025
abb106f
Make FeedImageDataCache sync
PortoCode Mar 28, 2025
23e1c29
Make FeedImageDataLoader sync
PortoCode Mar 28, 2025
37c8cbb
Rename method to clarify intent
PortoCode Mar 28, 2025
32c5145
Make CoreData FeedImageDataStore implementation sync
PortoCode Mar 28, 2025
5babace
Refactor NullStore class into extensions
PortoCode Mar 28, 2025
5897c38
Make NullStore FeedImageDataStore implementation sync
PortoCode Mar 28, 2025
be545d9
Make in memory FeedImageDataStore implementation sync
PortoCode Mar 28, 2025
aeca19f
Remove unused async methods
PortoCode Mar 28, 2025
b547dd8
Add AnyScheduler
PortoCode Mar 31, 2025
8eac75a
Subscribe upstream store subscriptions in a background queue to avoid…
PortoCode Mar 31, 2025
600b4d6
Deprecate async APIs in favor of new sync APIs
PortoCode Apr 1, 2025
86d0a0d
Perform feed cache operations synchronously
PortoCode Apr 1, 2025
f187016
Make FeedCache sync
PortoCode Apr 1, 2025
ec42cc8
Make LocalFeedLoader.load sync
PortoCode Apr 1, 2025
4374608
Make LocalFeedLoader.validateCache sync
PortoCode Apr 1, 2025
ac87130
Make CoreData FeedStore implementation sync
PortoCode Apr 1, 2025
b242528
Make NullStore FeedStore implementation sync
PortoCode Apr 1, 2025
2e239c3
Make in memory FeedStore implementation sync
PortoCode Apr 1, 2025
db3b01f
Remove unused async methods
PortoCode Apr 1, 2025
4f7bd85
Subscribe upstream store subscriptions in a background queue to avoid…
PortoCode Apr 1, 2025
420e842
Increase coverage by checking errors even when save method doesn't th…
PortoCode Apr 1, 2025
d3a792b
Ensures all store operations run in the same scheduler
PortoCode Apr 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions EssentialApp/EssentialApp/CombineHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,13 @@ public extension FeedImageDataLoader {
typealias Publisher = AnyPublisher<Data, Error>

func loadImageDataPublisher(from url: URL) -> Publisher {
var task: FeedImageDataLoaderTask?

return Deferred {
Future { completion in
task = self.loadImageData(from: url, completion: completion)
completion(Result {
try self.loadImageData(from: url)
})
}
}
.handleEvents(receiveCancel: { task?.cancel() })
.eraseToAnyPublisher()
}
}
Expand All @@ -76,7 +75,7 @@ extension Publisher where Output == Data {

private extension FeedImageDataCache {
func saveIgnoringResult(_ data: Data, for url: URL) {
save(data, for: url) { _ in }
try? save(data, for: url)
}
}

Expand All @@ -85,7 +84,9 @@ public extension LocalFeedLoader {

func loadPublisher() -> Publisher {
Deferred {
Future(self.load)
Future { completion in
completion(Result{ try self.load() })
}
}
.eraseToAnyPublisher()
}
Expand All @@ -109,7 +110,7 @@ extension Publisher {

private extension FeedCache {
func saveIgnoringResult(_ feed: [FeedImage]) {
save(feed) { _ in }
try? save(feed)
}

func saveIgnoringResult(_ page: Paginated<FeedImage>) {
Expand Down Expand Up @@ -170,3 +171,49 @@ extension DispatchQueue {
}
}
}

typealias AnyDispatchQueueScheduler = AnyScheduler<DispatchQueue.SchedulerTimeType, DispatchQueue.SchedulerOptions>

extension AnyDispatchQueueScheduler {
static var immediateOnMainQueue: Self {
DispatchQueue.immediateWhenOnMainQueueScheduler.eraseToAnyScheduler()
}
}

extension Scheduler {
func eraseToAnyScheduler() -> AnyScheduler<SchedulerTimeType, SchedulerOptions> {
AnyScheduler(self)
}
}

struct AnyScheduler<SchedulerTimeType: Strideable, SchedulerOptions>: 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<S>(_ 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)
}
}
28 changes: 11 additions & 17 deletions EssentialApp/EssentialApp/NullStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,18 @@
import Foundation
import EssentialFeed

class NullStore: FeedStore & FeedImageDataStore {
func deleteCachedFeed(completion: @escaping DeletionCompletion) {
completion(.success(()))
}

func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) {
completion(.success(()))
}
class NullStore {}

extension NullStore: FeedStore {
func deleteCachedFeed() throws {}

func retrieve(completion: @escaping RetrievalCompletion) {
completion(.success(.none))
}
func insert(_ feed: [LocalFeedImage], timestamp: Date) throws {}

func insert(_ data: Data, for url: URL, completion: @escaping (InsertionResult) -> Void) {
completion(.success(()))
}
func retrieve() throws -> CachedFeed? { .none }
}

extension NullStore: FeedImageDataStore {
func insert(_ data: Data, for url: URL) throws {}

func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) {
completion(.success(.none))
}
func retrieve(dataForURL url: URL) throws -> Data? { .none }
}
30 changes: 26 additions & 4 deletions EssentialApp/EssentialApp/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ 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 httpClient: HTTPClient = {
URLSessionHTTPClient(session: URLSession(configuration: .ephemeral))
}()
Expand Down Expand Up @@ -43,10 +49,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
imageLoader: makeLocalImageLoaderWithRemoteFallback,
selection: showComments))

convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) {
convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore, scheduler: AnyDispatchQueueScheduler) {
self.init()
self.httpClient = httpClient
self.store = store
self.scheduler = scheduler
}

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
Expand All @@ -62,7 +69,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}

func sceneWillResignActive(_ scene: UIScene) {
localFeedLoader.validateCache { _ in }
scheduler.schedule { [localFeedLoader, logger] in
do {
try localFeedLoader.validateCache()
} catch {
logger.error("Failed to validate cache with error: \(error.localizedDescription)")
}
}
}

private func showComments(for image: FeedImage) {
Expand All @@ -82,6 +95,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {

private func makeRemoteFeedLoaderWithLocalFallback() -> AnyPublisher<Paginated<FeedImage>, Error> {
makeRemoteFeedLoader()
.receive(on: scheduler)
.caching(to: localFeedLoader)
.fallback(to: localFeedLoader.loadPublisher)
.map(makeFirstPage)
Expand All @@ -93,8 +107,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
.zip(makeRemoteFeedLoader(after: last))
.map { (cachedItems, newItems) in
(cachedItems + newItems, newItems.last)
}.map(makePage)
}
.map(makePage)
.receive(on: scheduler)
.caching(to: localFeedLoader)
.subscribe(on: scheduler)
.eraseToAnyPublisher()
}

private func makeRemoteFeedLoader(after: FeedImage? = nil) -> AnyPublisher<[FeedImage], Error> {
Expand All @@ -121,11 +139,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {

return localImageLoader
.loadImageDataPublisher(from: url)
.fallback(to: { [httpClient] in
.fallback(to: { [httpClient, scheduler] in
httpClient
.getPublisher(url: url)
.tryMap(FeedImageDataMapper.map)
.receive(on: scheduler)
.caching(to: localImageLoader, using: url)
.eraseToAnyPublisher()
})
.subscribe(on: scheduler)
.eraseToAnyPublisher()
}
}
4 changes: 2 additions & 2 deletions EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class FeedAcceptanceTests: XCTestCase {
httpClient: HTTPClientStub = .offline,
store: InMemoryFeedStore = .empty
) -> ListViewController {
let sut = SceneDelegate(httpClient: httpClient, store: store)
let sut = SceneDelegate(httpClient: httpClient, store: store, scheduler: .immediateOnMainQueue)
sut.window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 1))
sut.configureWindow()

Expand All @@ -98,7 +98,7 @@ class FeedAcceptanceTests: XCTestCase {
}

private func enterBackground(with store: InMemoryFeedStore) {
let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store)
let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store, scheduler: .immediateOnMainQueue)
sut.sceneWillResignActive(UIApplication.shared.connectedScenes.first!)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Combine

extension FeedUIIntegrationTests {

class LoaderSpy: FeedImageDataLoader {
class LoaderSpy {

// MARK: - FeedLoader

Expand Down Expand Up @@ -65,33 +65,29 @@ extension FeedUIIntegrationTests {

// MARK: - FeedImageDataLoader

private struct TaskSpy: FeedImageDataLoaderTask {
let cancelCallback: () -> Void
func cancel() {
cancelCallback()
}
}

private var imageRequests = [(url: URL, completion: (FeedImageDataLoader.Result) -> Void)]()
private var imageRequests = [(url: URL, publisher: PassthroughSubject<Data, Error>)]()

var loadedImageURLs: [URL] {
return imageRequests.map { $0.url }
}

private(set) var cancelledImageURLs = [URL]()

func loadImageData(from url: URL, completion: @escaping (FeedImageDataLoader.Result) -> Void) -> FeedImageDataLoaderTask {
imageRequests.append((url, completion))
return TaskSpy { [weak self] in self?.cancelledImageURLs.append(url) }
func loadImageDataPublisher(from url: URL) -> AnyPublisher<Data, Error> {
let publisher = PassthroughSubject<Data, Error>()
imageRequests.append((url, publisher))
return publisher.handleEvents(receiveCancel: { [weak self] in
self?.cancelledImageURLs.append(url)
}).eraseToAnyPublisher()
}

func completeImageLoading(with imageData: Data = Data(), at index: Int = 0) {
imageRequests[index].completion(.success(imageData))
imageRequests[index].publisher.send(imageData)
imageRequests[index].publisher.send(completion: .finished)
}

func completeImageLoadingWithError(at index: Int = 0) {
let error = NSError(domain: "an error", code: 404)
imageRequests[index].completion(.failure(error))
imageRequests[index].publisher.send(completion: .failure(anyNSError()))
}
}

Expand Down
17 changes: 7 additions & 10 deletions EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,26 @@ class InMemoryFeedStore {
}

extension InMemoryFeedStore: FeedStore {
func deleteCachedFeed(completion: @escaping FeedStore.DeletionCompletion) {
func deleteCachedFeed() throws {
feedCache = nil
completion(.success(()))
}

func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping FeedStore.InsertionCompletion) {
func insert(_ feed: [LocalFeedImage], timestamp: Date) throws {
feedCache = CachedFeed(feed: feed, timestamp: timestamp)
completion(.success(()))
}

func retrieve(completion: @escaping FeedStore.RetrievalCompletion) {
completion(.success(feedCache))
func retrieve() throws -> CachedFeed? {
feedCache
}
}

extension InMemoryFeedStore: FeedImageDataStore {
func insert(_ data: Data, for url: URL, completion: @escaping (FeedImageDataStore.InsertionResult) -> Void) {
func insert(_ data: Data, for url: URL) throws {
feedImageDataCache[url] = data
completion(.success(()))
}

func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) {
completion(.success(feedImageDataCache[url]))
func retrieve(dataForURL url: URL) throws -> Data? {
feedImageDataCache[url]
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@
import Foundation

public protocol FeedImageDataStore {
typealias RetrievalResult = Swift.Result<Data?, Error>
typealias InsertionResult = Swift.Result<Void, Error>

func insert(_ data: Data, for url: URL, completion: @escaping (InsertionResult) -> Void)
func retrieve(dataForURL url: URL, completion: @escaping (RetrievalResult) -> Void)
func insert(_ data: Data, for url: URL) throws
func retrieve(dataForURL url: URL) throws -> Data?
}
23 changes: 3 additions & 20 deletions EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,7 @@ import Foundation
public typealias CachedFeed = (feed: [LocalFeedImage], timestamp: Date)

public protocol FeedStore {
typealias DeletionResult = Result<Void, Error>
typealias DeletionCompletion = (DeletionResult) -> Void

typealias InsertionResult = Result<Void, Error>
typealias InsertionCompletion = (InsertionResult) -> Void

typealias RetrievalResult = Result<CachedFeed?, Error>
typealias RetrievalCompletion = (RetrievalResult) -> Void

/// The completion handler can be invoked in any thread.
/// Clients are responsible to dispatch to appropriate threads, if needed.
func deleteCachedFeed(completion: @escaping DeletionCompletion)

/// The completion handler can be invoked in any thread.
/// Clients are responsible to dispatch to appropriate threads, if needed.
func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion)

/// The completion handler can be invoked in any thread.
/// Clients are responsible to dispatch to appropriate threads, if needed.
func retrieve(completion: @escaping RetrievalCompletion)
func deleteCachedFeed() throws
func insert(_ feed: [LocalFeedImage], timestamp: Date) throws
func retrieve() throws -> CachedFeed?
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@ import Foundation

extension CoreDataFeedStore: FeedImageDataStore {

public func insert(_ data: Data, for url: URL, completion: @escaping (FeedImageDataStore.InsertionResult) -> Void) {
perform { context in
completion(Result {
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)
})
}
}
}

public func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) {
perform { context in
completion(Result {
public func retrieve(dataForURL url: URL) throws -> Data? {
try performSync { context in
Result {
try ManagedFeedImage.data(with: url, in: context)
})
}
}
}

Expand Down
Loading