Skip to content

Commit

Permalink
Merge pull request essentialdevelopercom#60 from essentialdeveloperco…
Browse files Browse the repository at this point in the history
…m/refactor/async-injection

Async Injection
  • Loading branch information
mapostolakis committed Nov 23, 2020
2 parents 2d8a969 + 8f3eed1 commit 68fc135
Show file tree
Hide file tree
Showing 28 changed files with 343 additions and 607 deletions.
61 changes: 54 additions & 7 deletions EssentialApp/EssentialApp/CombineHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,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 @@ -74,7 +73,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 @@ -83,7 +82,9 @@ public extension LocalFeedLoader {

func loadPublisher() -> Publisher {
Deferred {
Future(self.load)
Future { completion in
completion(Result{ try self.load() })
}
}
.eraseToAnyPublisher()
}
Expand All @@ -107,7 +108,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 @@ -168,3 +169,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)
}
}
20 changes: 5 additions & 15 deletions EssentialApp/EssentialApp/NullStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,15 @@ import EssentialFeed
class NullStore {}

extension NullStore: FeedStore {
func deleteCachedFeed(completion: @escaping DeletionCompletion) {
completion(.success(()))
}
func deleteCachedFeed() throws {}

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

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

extension NullStore: FeedImageDataStore {
func insert(_ data: Data, for url: URL, completion: @escaping (InsertionResult) -> Void) {
completion(.success(()))
}
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 }
}
27 changes: 23 additions & 4 deletions EssentialApp/EssentialApp/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import EssentialFeed
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

private lazy var scheduler: AnyDispatchQueueScheduler = DispatchQueue(
label: "com.essentialdeveloper.infra.queue",
qos: .userInitiated,
attributes: .concurrent
).eraseToAnyScheduler()

private lazy var httpClient: HTTPClient = {
URLSessionHTTPClient(session: URLSession(configuration: .ephemeral))
}()
Expand Down Expand Up @@ -42,10 +48,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 @@ -61,7 +68,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}

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

private func showComments(for image: FeedImage) {
Expand All @@ -84,6 +95,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
.caching(to: localFeedLoader)
.fallback(to: localFeedLoader.loadPublisher)
.map(makeFirstPage)
.subscribe(on: scheduler)
.eraseToAnyPublisher()
}

Expand All @@ -92,8 +104,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
.zip(makeRemoteFeedLoader(after: last))
.map { (cachedItems, newItems) in
(cachedItems + newItems, newItems.last)
}.map(makePage)
}
.map(makePage)
.caching(to: localFeedLoader)
.subscribe(on: scheduler)
.eraseToAnyPublisher()
}

private func makeRemoteFeedLoader(after: FeedImage? = nil) -> AnyPublisher<[FeedImage], Error> {
Expand All @@ -120,11 +135,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)
.caching(to: localImageLoader, using: url)
.subscribe(on: scheduler)
.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 @@ -86,7 +86,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: 1, height: 1))
sut.configureWindow()

Expand All @@ -95,7 +95,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
21 changes: 9 additions & 12 deletions EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,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 @@ -5,9 +5,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 @@ -7,24 +7,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 @@ -6,21 +6,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
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,32 @@ import CoreData

extension CoreDataFeedStore: FeedStore {

public func retrieve(completion: @escaping RetrievalCompletion) {
perform { context in
completion(Result {
public func retrieve() throws -> CachedFeed? {
try performSync { context in
Result {
try ManagedCache.find(in: context).map {
CachedFeed(feed: $0.localFeed, timestamp: $0.timestamp)
}
})
}
}
}

public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) {
perform { context in
completion(Result {
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()
})
}
}
}

public func deleteCachedFeed(completion: @escaping DeletionCompletion) {
perform { context in
completion(Result {
public func deleteCachedFeed() throws {
try performSync { context in
Result {
try ManagedCache.deleteCache(in: context)
})
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ public final class CoreDataFeedStore {
}
}

func perform(_ action: @escaping (NSManagedObjectContext) -> Void) {
func performSync<R>(_ action: (NSManagedObjectContext) -> Result<R, Error>) throws -> R {
let context = self.context
context.perform { action(context) }
var result: Result<R, Error>!
context.performAndWait { result = action(context) }
return try result.get()
}

private func cleanUpReferencesToPersistentStores() {
Expand Down
Loading

0 comments on commit 68fc135

Please sign in to comment.