Skip to content

Commit 4d6583b

Browse files
committed
Replace Combine with async/await on image loading
1 parent 93fea86 commit 4d6583b

File tree

6 files changed

+142
-35
lines changed

6 files changed

+142
-35
lines changed

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: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ 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

5656
convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore & StoreScheduler & Sendable) {
@@ -162,22 +162,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
162162
}
163163
return imageData
164164
}
165-
166-
private func makeLocalImageLoaderWithRemoteFallback(url: URL) -> FeedImageDataLoader.Publisher {
167-
return Deferred {
168-
Future { completion in
169-
Task.immediate {
170-
do {
171-
let image = try await self.loadLocalImageWithRemoteFallback(url: url)
172-
completion(.success(image))
173-
} catch {
174-
completion(.failure(error))
175-
}
176-
}
177-
}
178-
}
179-
.eraseToAnyPublisher()
180-
}
181165
}
182166

183167
protocol StoreScheduler {

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: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,29 +65,87 @@ extension FeedUIIntegrationTests {
6565

6666
// MARK: - FeedImageDataLoader
6767

68-
private var imageRequests = [(url: URL, publisher: PassthroughSubject<Data, Error>)]()
68+
private var imageRequests = [(
69+
url: URL,
70+
publisher: AsyncThrowingStream<Data, Error>,
71+
continuation: AsyncThrowingStream<Data, Error>.Continuation,
72+
result: AsyncResult?
73+
)]()
74+
75+
enum AsyncResult {
76+
case success
77+
case failure
78+
case cancelled
79+
}
6980

7081
var loadedImageURLs: [URL] {
7182
return imageRequests.map { $0.url }
7283
}
7384

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

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()
87+
private struct NoResponse: Error {}
88+
private struct Timeout: Error {}
89+
90+
func loadImageData(from url: URL) async throws -> Data {
91+
let (stream, continuation) = AsyncThrowingStream<Data, Error>.makeStream()
92+
let index = imageRequests.count
93+
imageRequests.append((url, stream, continuation, nil))
94+
95+
do {
96+
for try await result in stream {
97+
try Task.checkCancellation()
98+
imageRequests[index].result = .success
99+
return result
100+
}
101+
102+
try Task.checkCancellation()
103+
104+
throw NoResponse()
105+
} catch {
106+
if Task.isCancelled {
107+
cancelledImageURLs.append(url)
108+
imageRequests[index].result = .cancelled
109+
} else {
110+
imageRequests[index].result = .failure
111+
}
112+
throw error
113+
}
82114
}
83115

84116
func completeImageLoading(with imageData: Data = Data(), at index: Int = 0) {
85-
imageRequests[index].publisher.send(imageData)
86-
imageRequests[index].publisher.send(completion: .finished)
117+
imageRequests[index].continuation.yield(imageData)
118+
imageRequests[index].continuation.finish()
119+
120+
while imageRequests[index].result == nil { RunLoop.current.run(until: Date()) }
87121
}
88122

89123
func completeImageLoadingWithError(at index: Int = 0) {
90-
imageRequests[index].publisher.send(completion: .failure(anyNSError()))
124+
imageRequests[index].continuation.finish(throwing: anyNSError())
125+
126+
while imageRequests[index].result == nil { RunLoop.current.run(until: Date()) }
127+
}
128+
129+
func imageResult(at index: Int, timeout: TimeInterval = 1) async throws -> AsyncResult {
130+
let maxDate = Date() + timeout
131+
132+
while Date() <= maxDate {
133+
if let result = imageRequests[index].result {
134+
return result
135+
}
136+
137+
await Task.yield()
138+
}
139+
140+
throw Timeout()
141+
}
142+
143+
func cancelPendingRequests() async throws {
144+
for (index, request) in imageRequests.enumerated() where request.result == nil {
145+
request.continuation.finish(throwing: CancellationError())
146+
147+
while imageRequests[index].result == nil { await Task.yield() }
148+
}
91149
}
92150
}
93151

0 commit comments

Comments
 (0)