Skip to content

Commit 8ac4ebf

Browse files
authored
Merge pull request #91 from essentialdevelopercom/swift-concurrency
Migrate Image Loading composition to async/await
2 parents 5ac5270 + 58f5577 commit 8ac4ebf

File tree

9 files changed

+228
-38
lines changed

9 files changed

+228
-38
lines changed

EssentialApp/EssentialApp.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
0832C9D0238D2811002314C9 /* SceneDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0832C9CF238D2811002314C9 /* SceneDelegateTests.swift */; };
2727
0835BF6D24850F9800A793D2 /* CombineHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0835BF6C24850F9800A793D2 /* CombineHelpers.swift */; };
2828
08367CD82486FB51009CD536 /* UIView+TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08367CD72486FB51009CD536 /* UIView+TestHelpers.swift */; };
29+
084BE5342EB38EC5006886E9 /* LoaderSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084BE5332EB38EC5006886E9 /* LoaderSpy.swift */; };
2930
0851CDAC239AB13100C19B1D /* HTTPClientStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0851CDAB239AB13100C19B1D /* HTTPClientStub.swift */; };
3031
088B441925309AA300D75AAD /* CommentsUIIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088B441825309AA300D75AAD /* CommentsUIIntegrationTests.swift */; };
3132
088B441C25309B6E00D75AAD /* CommentsUIComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088B441B25309B6E00D75AAD /* CommentsUIComposer.swift */; };
@@ -84,6 +85,7 @@
8485
0832C9CF238D2811002314C9 /* SceneDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegateTests.swift; sourceTree = "<group>"; };
8586
0835BF6C24850F9800A793D2 /* CombineHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineHelpers.swift; sourceTree = "<group>"; };
8687
08367CD72486FB51009CD536 /* UIView+TestHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+TestHelpers.swift"; sourceTree = "<group>"; };
88+
084BE5332EB38EC5006886E9 /* LoaderSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoaderSpy.swift; sourceTree = "<group>"; };
8789
0851CDAB239AB13100C19B1D /* HTTPClientStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClientStub.swift; sourceTree = "<group>"; };
8890
088B441825309AA300D75AAD /* CommentsUIIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsUIIntegrationTests.swift; sourceTree = "<group>"; };
8991
088B441B25309B6E00D75AAD /* CommentsUIComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsUIComposer.swift; sourceTree = "<group>"; };
@@ -137,6 +139,7 @@
137139
082C00032359E46C008927D3 /* XCTestCase+MemoryLeakTracking.swift */,
138140
082C00052359E4C6008927D3 /* SharedTestHelpers.swift */,
139141
0851CDAB239AB13100C19B1D /* HTTPClientStub.swift */,
142+
084BE5332EB38EC5006886E9 /* LoaderSpy.swift */,
140143
);
141144
path = Helpers;
142145
sourceTree = "<group>";
@@ -325,6 +328,7 @@
325328
082C00062359E4C6008927D3 /* SharedTestHelpers.swift in Sources */,
326329
088B441925309AA300D75AAD /* CommentsUIIntegrationTests.swift in Sources */,
327330
08073B57238D2E1000A75DC6 /* UIControl+TestHelpers.swift in Sources */,
331+
084BE5342EB38EC5006886E9 /* LoaderSpy.swift in Sources */,
328332
08073B56238D2E1000A75DC6 /* FeedUIIntegrationTests+Assertions.swift in Sources */,
329333
0832C9D0238D2811002314C9 /* SceneDelegateTests.swift in Sources */,
330334
08073B5B238D2E1000A75DC6 /* ListViewController+TestHelpers.swift in Sources */,

EssentialApp/EssentialApp/FeedUIComposer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public final class FeedUIComposer {
1515

1616
public static func feedComposedWith(
1717
feedLoader: @MainActor @escaping () -> AnyPublisher<Paginated<FeedImage>, Error>,
18-
imageLoader: @MainActor @escaping (URL) -> FeedImageDataLoader.Publisher,
18+
imageLoader: @MainActor @escaping (URL) async throws -> Data,
1919
selection: @MainActor @escaping (FeedImage) -> Void = { _ in }
2020
) -> ListViewController {
2121
let presentationAdapter = FeedPresentationAdapter(loader: feedLoader)

EssentialApp/EssentialApp/FeedViewAdapter.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import EssentialFeediOS
99
@MainActor
1010
final class FeedViewAdapter: ResourceView {
1111
private weak var controller: ListViewController?
12-
private let imageLoader: (URL) -> FeedImageDataLoader.Publisher
12+
private let imageLoader: (URL) async throws -> Data
1313
private let selection: (FeedImage) -> Void
1414
private let currentFeed: [FeedImage: CellController]
1515

16-
private typealias ImageDataPresentationAdapter = LoadResourcePresentationAdapter<Data, WeakRefVirtualProxy<FeedImageCellController>>
16+
private typealias ImageDataPresentationAdapter = AsyncLoadResourcePresentationAdapter<Data, WeakRefVirtualProxy<FeedImageCellController>>
1717
private typealias LoadMorePresentationAdapter = LoadResourcePresentationAdapter<Paginated<FeedImage>, FeedViewAdapter>
1818

19-
init(currentFeed: [FeedImage: CellController] = [:], controller: ListViewController, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher, selection: @escaping (FeedImage) -> Void) {
19+
init(currentFeed: [FeedImage: CellController] = [:], controller: ListViewController, imageLoader: @escaping (URL) async throws -> Data, selection: @escaping (FeedImage) -> Void) {
2020
self.currentFeed = currentFeed
2121
self.controller = controller
2222
self.imageLoader = imageLoader
@@ -33,7 +33,7 @@ final class FeedViewAdapter: ResourceView {
3333
}
3434

3535
let adapter = ImageDataPresentationAdapter(loader: { [imageLoader] in
36-
imageLoader(model.url)
36+
try await imageLoader(model.url)
3737
})
3838

3939
let view = FeedImageCellController(

EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,58 @@ import Combine
66
import EssentialFeed
77
import EssentialFeediOS
88

9+
@MainActor
10+
final class AsyncLoadResourcePresentationAdapter<Resource, View: ResourceView> {
11+
private let loader: () async throws -> Resource
12+
private var cancellable: Task<Void, Never>?
13+
private var isLoading = false
14+
15+
var presenter: LoadResourcePresenter<Resource, View>?
16+
17+
init(loader: @escaping () async throws -> Resource) {
18+
self.loader = loader
19+
}
20+
21+
func loadResource() {
22+
guard !isLoading else { return }
23+
24+
presenter?.didStartLoading()
25+
isLoading = true
26+
27+
cancellable = Task.immediate { @MainActor [weak self] in
28+
defer { self?.isLoading = false }
29+
30+
do {
31+
if let resource = try await self?.loader() {
32+
if Task.isCancelled { return }
33+
34+
self?.presenter?.didFinishLoading(with: resource)
35+
}
36+
} catch {
37+
if Task.isCancelled { return }
38+
39+
self?.presenter?.didFinishLoading(with: error)
40+
}
41+
}
42+
}
43+
44+
deinit {
45+
cancellable?.cancel()
46+
}
47+
}
48+
49+
extension AsyncLoadResourcePresentationAdapter: FeedImageCellControllerDelegate {
50+
func didRequestImage() {
51+
loadResource()
52+
}
53+
54+
func didCancelImageRequest() {
55+
cancellable?.cancel()
56+
cancellable = nil
57+
isLoading = false
58+
}
59+
}
60+
961
@MainActor
1062
final class LoadResourcePresentationAdapter<Resource, View: ResourceView> {
1163
private let loader: () -> AnyPublisher<Resource, Error>

EssentialApp/EssentialApp/SceneDelegate.swift

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
2828

2929
private lazy var logger = Logger(subsystem: "com.essentialdeveloper.EssentialAppCaseStudy", category: "main")
3030

31-
private lazy var store: FeedStore & FeedImageDataStore = {
31+
private lazy var store: FeedStore & FeedImageDataStore & StoreScheduler & Sendable = {
3232
do {
3333
return try CoreDataFeedStore(
3434
storeURL: NSPersistentContainer
@@ -50,10 +50,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
5050
private lazy var navigationController = UINavigationController(
5151
rootViewController: FeedUIComposer.feedComposedWith(
5252
feedLoader: makeRemoteFeedLoaderWithLocalFallback,
53-
imageLoader: makeLocalImageLoaderWithRemoteFallback,
53+
imageLoader: loadLocalImageWithRemoteFallback,
5454
selection: showComments))
5555

56-
convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) {
56+
convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore & StoreScheduler & Sendable) {
5757
self.init()
5858
self.httpClient = httpClient
5959
self.store = store
@@ -137,20 +137,52 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
137137
})
138138
}
139139

140-
private func makeLocalImageLoaderWithRemoteFallback(url: URL) -> FeedImageDataLoader.Publisher {
141-
let localImageLoader = LocalFeedImageDataLoader(store: store)
142-
143-
return localImageLoader
144-
.loadImageDataPublisher(from: url)
145-
.fallback(to: { [httpClient, scheduler] in
146-
httpClient
147-
.getPublisher(url: url)
148-
.tryMap(FeedImageDataMapper.map)
149-
.receive(on: scheduler)
150-
.caching(to: localImageLoader, using: url)
151-
.eraseToAnyPublisher()
152-
})
153-
.subscribe(on: scheduler)
154-
.eraseToAnyPublisher()
140+
private func loadLocalImageWithRemoteFallback(url: URL) async throws -> Data {
141+
do {
142+
return try await loadLocalImage(url: url)
143+
} catch {
144+
return try await loadAndCacheRemoteImage(url: url)
145+
}
146+
}
147+
148+
private func loadLocalImage(url: URL) async throws -> Data {
149+
try await store.schedule { [store] in
150+
let localImageLoader = LocalFeedImageDataLoader(store: store)
151+
let imageData = try localImageLoader.loadImageData(from: url)
152+
return imageData
153+
}
154+
}
155+
156+
private func loadAndCacheRemoteImage(url: URL) async throws -> Data {
157+
let (data, response) = try await httpClient.get(from: url)
158+
let imageData = try FeedImageDataMapper.map(data, from: response)
159+
await store.schedule { [store] in
160+
let localImageLoader = LocalFeedImageDataLoader(store: store)
161+
try? localImageLoader.save(data, for: url)
162+
}
163+
return imageData
164+
}
165+
}
166+
167+
protocol StoreScheduler {
168+
@MainActor
169+
func schedule<T>(_ action: @escaping @Sendable () throws -> T) async rethrows -> T
170+
}
171+
172+
extension CoreDataFeedStore: StoreScheduler {
173+
@MainActor
174+
func schedule<T>(_ action: @escaping @Sendable () throws -> T) async rethrows -> T {
175+
if contextQueue == .main {
176+
return try action()
177+
} else {
178+
return try await perform(action)
179+
}
180+
}
181+
}
182+
183+
extension InMemoryFeedStore: StoreScheduler {
184+
@MainActor
185+
func schedule<T>(_ action: @escaping @Sendable () throws -> T) async rethrows -> T {
186+
try action()
155187
}
156188
}

EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ class FeedUIIntegrationTests: XCTestCase {
263263
XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected second image URL request once second view also becomes visible")
264264
}
265265

266-
func test_feedImageView_cancelsImageLoadingWhenNotVisibleAnymore() {
266+
func test_feedImageView_cancelsImageLoadingWhenNotVisibleAnymore() async throws {
267267
let image0 = makeImage(url: URL(string: "http://url-0.com")!)
268268
let image1 = makeImage(url: URL(string: "http://url-1.com")!)
269269
let (sut, loader) = makeSUT()
@@ -273,9 +273,13 @@ class FeedUIIntegrationTests: XCTestCase {
273273
XCTAssertEqual(loader.cancelledImageURLs, [], "Expected no cancelled image URL requests until image is not visible")
274274

275275
sut.simulateFeedImageViewNotVisible(at: 0)
276+
let result0 = try await loader.imageResult(at: 0)
277+
XCTAssertEqual(result0, .cancelled)
276278
XCTAssertEqual(loader.cancelledImageURLs, [image0.url], "Expected one cancelled image URL request once first image is not visible anymore")
277279

278280
sut.simulateFeedImageViewNotVisible(at: 1)
281+
let result1 = try await loader.imageResult(at: 1)
282+
XCTAssertEqual(result1, .cancelled)
279283
XCTAssertEqual(loader.cancelledImageURLs, [image0.url, image1.url], "Expected two cancelled image URL requests once second image is also not visible anymore")
280284
}
281285

@@ -420,7 +424,7 @@ class FeedUIIntegrationTests: XCTestCase {
420424
XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected second image URL request once second image is near visible")
421425
}
422426

423-
func test_feedImageView_cancelsImageURLPreloadingWhenNotNearVisibleAnymore() {
427+
func test_feedImageView_cancelsImageURLPreloadingWhenNotNearVisibleAnymore() async throws {
424428
let image0 = makeImage(url: URL(string: "http://url-0.com")!)
425429
let image1 = makeImage(url: URL(string: "http://url-1.com")!)
426430
let (sut, loader) = makeSUT()
@@ -430,9 +434,13 @@ class FeedUIIntegrationTests: XCTestCase {
430434
XCTAssertEqual(loader.cancelledImageURLs, [], "Expected no cancelled image URL requests until image is not near visible")
431435

432436
sut.simulateFeedImageViewNotNearVisible(at: 0)
437+
let result0 = try await loader.imageResult(at: 0)
438+
XCTAssertEqual(result0, .cancelled)
433439
XCTAssertEqual(loader.cancelledImageURLs, [image0.url], "Expected first cancelled image URL request once first image is not near visible anymore")
434440

435441
sut.simulateFeedImageViewNotNearVisible(at: 1)
442+
let result1 = try await loader.imageResult(at: 1)
443+
XCTAssertEqual(result1, .cancelled)
436444
XCTAssertEqual(loader.cancelledImageURLs, [image0.url, image1.url], "Expected second cancelled image URL request once second image is not near visible anymore")
437445
}
438446

@@ -556,11 +564,16 @@ class FeedUIIntegrationTests: XCTestCase {
556564
let loader = LoaderSpy()
557565
let sut = FeedUIComposer.feedComposedWith(
558566
feedLoader: loader.loadPublisher,
559-
imageLoader: loader.loadImageDataPublisher,
567+
imageLoader: loader.loadImageData,
560568
selection: selection
561569
)
562570
trackForMemoryLeaks(loader, file: file, line: line)
563571
trackForMemoryLeaks(sut, file: file, line: line)
572+
573+
addTeardownBlock { [weak loader] in
574+
try await loader?.cancelPendingRequests()
575+
}
576+
564577
return (sut, loader)
565578
}
566579

EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,29 +65,37 @@ extension FeedUIIntegrationTests {
6565

6666
// MARK: - FeedImageDataLoader
6767

68-
private var imageRequests = [(url: URL, publisher: PassthroughSubject<Data, Error>)]()
68+
private var imageLoader = EssentialAppTests.LoaderSpy<URL, Data>()
6969

7070
var loadedImageURLs: [URL] {
71-
return imageRequests.map { $0.url }
71+
return imageLoader.requests.map { $0.param }
7272
}
7373

74-
private(set) var cancelledImageURLs = [URL]()
74+
var cancelledImageURLs: [URL] {
75+
return imageLoader.requests.filter({ $0.result == .cancelled }).map { $0.param }
76+
}
77+
78+
private struct NoResponse: Error {}
79+
private struct Timeout: Error {}
7580

76-
func loadImageDataPublisher(from url: URL) -> AnyPublisher<Data, Error> {
77-
let publisher = PassthroughSubject<Data, Error>()
78-
imageRequests.append((url, publisher))
79-
return publisher.handleEvents(receiveCancel: { [weak self] in
80-
self?.cancelledImageURLs.append(url)
81-
}).eraseToAnyPublisher()
81+
func loadImageData(from url: URL) async throws -> Data {
82+
try await imageLoader.load(url)
8283
}
8384

8485
func completeImageLoading(with imageData: Data = Data(), at index: Int = 0) {
85-
imageRequests[index].publisher.send(imageData)
86-
imageRequests[index].publisher.send(completion: .finished)
86+
imageLoader.complete(with: imageData, at: index)
8787
}
8888

8989
func completeImageLoadingWithError(at index: Int = 0) {
90-
imageRequests[index].publisher.send(completion: .failure(anyNSError()))
90+
imageLoader.fail(with: anyNSError(), at: index)
91+
}
92+
93+
func imageResult(at index: Int, timeout: TimeInterval = 1) async throws -> AsyncResult {
94+
try await imageLoader.result(at: index, timeout: timeout)
95+
}
96+
97+
func cancelPendingRequests() async throws {
98+
try await imageLoader.cancelPendingRequests()
9199
}
92100
}
93101

0 commit comments

Comments
 (0)