Skip to content

Commit

Permalink
Merge pull request #79 from essentialdevelopercom/refactor/non-blocki…
Browse files Browse the repository at this point in the history
…ng-async-injection

Non-Blocking Async Injection
  • Loading branch information
mapostolakis authored Mar 26, 2024
2 parents a119271 + 0c13745 commit 9f2fa04
Show file tree
Hide file tree
Showing 14 changed files with 259 additions and 162 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@
ReferencedContainer = "container:../EssentialFeed/EssentialFeed.xcodeproj">
</BuildableReference>
</MacroExpansion>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@
ReferencedContainer = "container:EssentialApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
Expand Down
37 changes: 37 additions & 0 deletions EssentialApp/EssentialApp/CombineHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,43 @@ extension AnyDispatchQueueScheduler {
static var immediateOnMainThread: Self {
DispatchQueue.immediateWhenOnMainThreadScheduler.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 {
Expand Down
19 changes: 12 additions & 7 deletions EssentialApp/EssentialApp/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ 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 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))
Expand Down Expand Up @@ -48,11 +54,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) {
Expand Down
64 changes: 44 additions & 20 deletions EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,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())
Expand All @@ -34,8 +34,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)
Expand All @@ -51,30 +51,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())
Expand All @@ -84,9 +84,9 @@ class FeedAcceptanceTests: XCTestCase {

private func launch(
httpClient: HTTPClientStub = .offline,
store: InMemoryFeedStore = .empty
store: CoreDataFeedStore
) -> ListViewController {
let sut = SceneDelegate(httpClient: httpClient, store: store, scheduler: .immediateOnMainThread)
let sut = SceneDelegate(httpClient: httpClient, store: store)
sut.window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 1))
sut.configureWindow()

Expand All @@ -96,13 +96,13 @@ class FeedAcceptanceTests: XCTestCase {
return vc
}

private func enterBackground(with store: InMemoryFeedStore) {
let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store, scheduler: .immediateOnMainThread)
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())
Expand Down Expand Up @@ -180,3 +180,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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@
ReferencedContainer = "container:EssentialFeed.xcodeproj">
</BuildableReference>
</MacroExpansion>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@
ReferencedContainer = "container:EssentialFeed.xcodeproj">
</BuildableReference>
</MacroExpansion>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,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)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,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)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,37 @@ 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
case failedToLoadPersistentContainer(Error)
}

public init(storeURL: URL) throws {
public enum ContextQueue {
case main
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
}

do {
container = try NSPersistentContainer.load(name: CoreDataFeedStore.modelName, model: model, url: storeURL)
context = container.newBackgroundContext()
context = contextQueue == .main ? container.viewContext : container.newBackgroundContext()
} catch {
throw StoreError.failedToLoadPersistentContainer(error)
}
}

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

private func cleanUpReferencesToPersistentStores() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ class EssentialFeedCacheIntegrationTests: XCTestCase {

private func makeFeedLoader(currentDate: Date = Date(), file: StaticString = #filePath, line: UInt = #line) throws -> 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)
Expand All @@ -116,7 +116,7 @@ class EssentialFeedCacheIntegrationTests: XCTestCase {

private func makeImageLoader(file: StaticString = #filePath, line: UInt = #line) throws -> 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)
Expand Down
Loading

0 comments on commit 9f2fa04

Please sign in to comment.