Skip to content

Commit 9f2fa04

Browse files
authored
Merge pull request #79 from essentialdevelopercom/refactor/non-blocking-async-injection
Non-Blocking Async Injection
2 parents a119271 + 0c13745 commit 9f2fa04

14 files changed

Lines changed: 259 additions & 162 deletions

File tree

EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/CI_iOS.xcscheme

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,12 @@
139139
ReferencedContainer = "container:../EssentialFeed/EssentialFeed.xcodeproj">
140140
</BuildableReference>
141141
</MacroExpansion>
142+
<CommandLineArguments>
143+
<CommandLineArgument
144+
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
145+
isEnabled = "YES">
146+
</CommandLineArgument>
147+
</CommandLineArguments>
142148
</LaunchAction>
143149
<ProfileAction
144150
buildConfiguration = "Release"

EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialApp.xcscheme

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@
7272
ReferencedContainer = "container:EssentialApp.xcodeproj">
7373
</BuildableReference>
7474
</BuildableProductRunnable>
75+
<CommandLineArguments>
76+
<CommandLineArgument
77+
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
78+
isEnabled = "YES">
79+
</CommandLineArgument>
80+
</CommandLineArguments>
7581
</LaunchAction>
7682
<ProfileAction
7783
buildConfiguration = "Release"

EssentialApp/EssentialApp/CombineHelpers.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,43 @@ extension AnyDispatchQueueScheduler {
213213
static var immediateOnMainThread: Self {
214214
DispatchQueue.immediateWhenOnMainThreadScheduler.eraseToAnyScheduler()
215215
}
216+
217+
static func scheduler(for store: CoreDataFeedStore) -> AnyDispatchQueueScheduler {
218+
CoreDataFeedStoreScheduler(store: store).eraseToAnyScheduler()
219+
}
220+
221+
private struct CoreDataFeedStoreScheduler: Scheduler {
222+
let store: CoreDataFeedStore
223+
224+
var now: SchedulerTimeType { .init(.now()) }
225+
226+
var minimumTolerance: SchedulerTimeType.Stride { .zero }
227+
228+
func schedule(after date: DispatchQueue.SchedulerTimeType, interval: DispatchQueue.SchedulerTimeType.Stride, tolerance: DispatchQueue.SchedulerTimeType.Stride, options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) -> any Cancellable {
229+
if store.contextQueue == .main, Thread.isMainThread {
230+
action()
231+
} else {
232+
store.perform(action)
233+
}
234+
return AnyCancellable {}
235+
}
236+
237+
func schedule(after date: DispatchQueue.SchedulerTimeType, tolerance: DispatchQueue.SchedulerTimeType.Stride, options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) {
238+
if store.contextQueue == .main, Thread.isMainThread {
239+
action()
240+
} else {
241+
store.perform(action)
242+
}
243+
}
244+
245+
func schedule(options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) {
246+
if store.contextQueue == .main, Thread.isMainThread {
247+
action()
248+
} else {
249+
store.perform(action)
250+
}
251+
}
252+
}
216253
}
217254

218255
extension Scheduler {

EssentialApp/EssentialApp/SceneDelegate.swift

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,17 @@ import EssentialFeed
1111
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
1212
var window: UIWindow?
1313

14-
private lazy var scheduler: AnyDispatchQueueScheduler = DispatchQueue(
15-
label: "com.essentialdeveloper.infra.queue",
16-
qos: .userInitiated,
17-
attributes: .concurrent
18-
).eraseToAnyScheduler()
14+
private lazy var scheduler: AnyDispatchQueueScheduler = {
15+
if let store = store as? CoreDataFeedStore {
16+
return .scheduler(for: store)
17+
}
18+
19+
return DispatchQueue(
20+
label: "com.essentialdeveloper.infra.queue",
21+
qos: .userInitiated,
22+
attributes: .concurrent
23+
).eraseToAnyScheduler()
24+
}()
1925

2026
private lazy var httpClient: HTTPClient = {
2127
URLSessionHTTPClient(session: URLSession(configuration: .ephemeral))
@@ -48,11 +54,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
4854
imageLoader: makeLocalImageLoaderWithRemoteFallback,
4955
selection: showComments))
5056

51-
convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore, scheduler: AnyDispatchQueueScheduler) {
57+
convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) {
5258
self.init()
5359
self.httpClient = httpClient
5460
self.store = store
55-
self.scheduler = scheduler
5661
}
5762

5863
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import EssentialFeediOS
99

1010
class FeedAcceptanceTests: XCTestCase {
1111

12-
func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() {
13-
let feed = launch(httpClient: .online(response), store: .empty)
12+
func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() throws {
13+
let feed = try launch(httpClient: .online(response), store: .empty)
1414

1515
XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 2)
1616
XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0())
@@ -34,8 +34,8 @@ class FeedAcceptanceTests: XCTestCase {
3434
XCTAssertFalse(feed.canLoadMoreFeed)
3535
}
3636

37-
func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() {
38-
let sharedStore = InMemoryFeedStore.empty
37+
func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() throws {
38+
let sharedStore = try CoreDataFeedStore.empty
3939

4040
let onlineFeed = launch(httpClient: .online(response), store: sharedStore)
4141
onlineFeed.simulateFeedImageViewVisible(at: 0)
@@ -51,30 +51,30 @@ class FeedAcceptanceTests: XCTestCase {
5151
XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 2), makeImageData2())
5252
}
5353

54-
func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() {
55-
let feed = launch(httpClient: .offline, store: .empty)
54+
func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() throws {
55+
let feed = try launch(httpClient: .offline, store: .empty)
5656

5757
XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 0)
5858
}
5959

60-
func test_onEnteringBackground_deletesExpiredFeedCache() {
61-
let store = InMemoryFeedStore.withExpiredFeedCache
60+
func test_onEnteringBackground_deletesExpiredFeedCache() throws {
61+
let store = try CoreDataFeedStore.withExpiredFeedCache
6262

6363
enterBackground(with: store)
6464

65-
XCTAssertNil(store.feedCache, "Expected to delete expired cache")
65+
XCTAssertNil(try store.retrieve(), "Expected to delete expired cache")
6666
}
6767

68-
func test_onEnteringBackground_keepsNonExpiredFeedCache() {
69-
let store = InMemoryFeedStore.withNonExpiredFeedCache
68+
func test_onEnteringBackground_keepsNonExpiredFeedCache() throws {
69+
let store = try CoreDataFeedStore.withNonExpiredFeedCache
7070

7171
enterBackground(with: store)
7272

73-
XCTAssertNotNil(store.feedCache, "Expected to keep non-expired cache")
73+
XCTAssertNotNil(try store.retrieve(), "Expected to keep non-expired cache")
7474
}
7575

76-
func test_onFeedImageSelection_displaysComments() {
77-
let comments = showCommentsForFirstImage()
76+
func test_onFeedImageSelection_displaysComments() throws {
77+
let comments = try showCommentsForFirstImage()
7878

7979
XCTAssertEqual(comments.numberOfRenderedComments(), 1)
8080
XCTAssertEqual(comments.commentMessage(at: 0), makeCommentMessage())
@@ -84,9 +84,9 @@ class FeedAcceptanceTests: XCTestCase {
8484

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

@@ -96,13 +96,13 @@ class FeedAcceptanceTests: XCTestCase {
9696
return vc
9797
}
9898

99-
private func enterBackground(with store: InMemoryFeedStore) {
100-
let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store, scheduler: .immediateOnMainThread)
99+
private func enterBackground(with store: CoreDataFeedStore) {
100+
let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store)
101101
sut.sceneWillResignActive(UIApplication.shared.connectedScenes.first!)
102102
}
103103

104-
private func showCommentsForFirstImage() -> ListViewController {
105-
let feed = launch(httpClient: .online(response), store: .empty)
104+
private func showCommentsForFirstImage() throws -> ListViewController {
105+
let feed = try launch(httpClient: .online(response), store: .empty)
106106

107107
feed.simulateTapOnFeedImage(at: 0)
108108
RunLoop.current.run(until: Date())
@@ -180,3 +180,27 @@ class FeedAcceptanceTests: XCTestCase {
180180
}
181181

182182
}
183+
184+
extension CoreDataFeedStore {
185+
static var empty: CoreDataFeedStore {
186+
get throws {
187+
try CoreDataFeedStore(storeURL: URL(fileURLWithPath: "/dev/null"), contextQueue: .main)
188+
}
189+
}
190+
191+
static var withExpiredFeedCache: CoreDataFeedStore {
192+
get throws {
193+
let store = try CoreDataFeedStore.empty
194+
try store.insert([], timestamp: .distantPast)
195+
return store
196+
}
197+
}
198+
199+
static var withNonExpiredFeedCache: CoreDataFeedStore {
200+
get throws {
201+
let store = try CoreDataFeedStore.empty
202+
try store.insert([], timestamp: Date())
203+
return store
204+
}
205+
}
206+
}

EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@
106106
ReferencedContainer = "container:EssentialFeed.xcodeproj">
107107
</BuildableReference>
108108
</MacroExpansion>
109+
<CommandLineArguments>
110+
<CommandLineArgument
111+
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
112+
isEnabled = "YES">
113+
</CommandLineArgument>
114+
</CommandLineArguments>
109115
</LaunchAction>
110116
<ProfileAction
111117
buildConfiguration = "Release"

EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@
8080
ReferencedContainer = "container:EssentialFeed.xcodeproj">
8181
</BuildableReference>
8282
</MacroExpansion>
83+
<CommandLineArguments>
84+
<CommandLineArgument
85+
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
86+
isEnabled = "YES">
87+
</CommandLineArgument>
88+
</CommandLineArguments>
8389
</LaunchAction>
8490
<ProfileAction
8591
buildConfiguration = "Release"

EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646
debugDocumentVersioning = "YES"
4747
debugServiceExtension = "internal"
4848
allowLocationSimulation = "YES">
49+
<CommandLineArguments>
50+
<CommandLineArgument
51+
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
52+
isEnabled = "YES">
53+
</CommandLineArgument>
54+
</CommandLineArguments>
4955
</LaunchAction>
5056
<ProfileAction
5157
buildConfiguration = "Release"

EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,13 @@ import Foundation
77
extension CoreDataFeedStore: FeedImageDataStore {
88

99
public func insert(_ data: Data, for url: URL) throws {
10-
try performSync { context in
11-
Result {
12-
try ManagedFeedImage.first(with: url, in: context)
13-
.map { $0.data = data }
14-
.map(context.save)
15-
}
16-
}
10+
try ManagedFeedImage.first(with: url, in: context)
11+
.map { $0.data = data }
12+
.map(context.save)
1713
}
1814

1915
public func retrieve(dataForURL url: URL) throws -> Data? {
20-
try performSync { context in
21-
Result {
22-
try ManagedFeedImage.data(with: url, in: context)
23-
}
24-
}
16+
try ManagedFeedImage.data(with: url, in: context)
2517
}
2618

2719
}

EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,20 @@ import CoreData
77
extension CoreDataFeedStore: FeedStore {
88

99
public func retrieve() throws -> CachedFeed? {
10-
try performSync { context in
11-
Result {
12-
try ManagedCache.find(in: context).map {
13-
CachedFeed(feed: $0.localFeed, timestamp: $0.timestamp)
14-
}
15-
}
10+
try ManagedCache.find(in: context).map {
11+
CachedFeed(feed: $0.localFeed, timestamp: $0.timestamp)
1612
}
1713
}
1814

1915
public func insert(_ feed: [LocalFeedImage], timestamp: Date) throws {
20-
try performSync { context in
21-
Result {
22-
let managedCache = try ManagedCache.newUniqueInstance(in: context)
23-
managedCache.timestamp = timestamp
24-
managedCache.feed = ManagedFeedImage.images(from: feed, in: context)
25-
try context.save()
26-
}
27-
}
16+
let managedCache = try ManagedCache.newUniqueInstance(in: context)
17+
managedCache.timestamp = timestamp
18+
managedCache.feed = ManagedFeedImage.images(from: feed, in: context)
19+
try context.save()
2820
}
2921

3022
public func deleteCachedFeed() throws {
31-
try performSync { context in
32-
Result {
33-
try ManagedCache.deleteCache(in: context)
34-
}
35-
}
23+
try ManagedCache.deleteCache(in: context)
3624
}
3725

3826
}

0 commit comments

Comments
 (0)