Skip to content

Commit 7228618

Browse files
authored
Merge pull request #93 from essentialdevelopercom/swift-concurrency
Migrate Pagination composition to async/await
2 parents 69c48d7 + 024df12 commit 7228618

File tree

9 files changed

+210
-191
lines changed

9 files changed

+210
-191
lines changed

EssentialApp/EssentialApp/CombineHelpers.swift

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,6 @@ import Foundation
66
import Combine
77
import EssentialFeed
88

9-
public extension Paginated {
10-
init(items: [Item], loadMorePublisher: (() -> AnyPublisher<Self, Error>)?) {
11-
self.init(items: items, loadMore: loadMorePublisher.map { publisher in
12-
return { completion in
13-
publisher().subscribe(Subscribers.Sink(receiveCompletion: { result in
14-
if case let .failure(error) = result {
15-
completion(.failure(error))
16-
}
17-
}, receiveValue: { result in
18-
completion(.success(result))
19-
}))
20-
}
21-
})
22-
}
23-
24-
var loadMorePublisher: (() -> AnyPublisher<Self, Error>)? {
25-
guard let loadMore = loadMore else { return nil }
26-
27-
return {
28-
Deferred {
29-
Future(loadMore)
30-
}.eraseToAnyPublisher()
31-
}
32-
}
33-
}
34-
359
@MainActor
3610
public extension HTTPClient {
3711
typealias Publisher = AnyPublisher<(Data, HTTPURLResponse), Error>

EssentialApp/EssentialApp/FeedUIComposer.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,17 @@
33
//
44

55
import UIKit
6-
import Combine
76
import EssentialFeed
87
import EssentialFeediOS
98

109
@MainActor
1110
public final class FeedUIComposer {
1211
private init() {}
1312

14-
private typealias FeedPresentationAdapter = LoadResourcePresentationAdapter<Paginated<FeedImage>, FeedViewAdapter>
13+
private typealias FeedPresentationAdapter = AsyncLoadResourcePresentationAdapter<Paginated<FeedImage>, FeedViewAdapter>
1514

1615
public static func feedComposedWith(
17-
feedLoader: @MainActor @escaping () -> AnyPublisher<Paginated<FeedImage>, Error>,
16+
feedLoader: @MainActor @escaping () async throws -> Paginated<FeedImage>,
1817
imageLoader: @MainActor @escaping (URL) async throws -> Data,
1918
selection: @MainActor @escaping (FeedImage) -> Void = { _ in }
2019
) -> ListViewController {

EssentialApp/EssentialApp/FeedViewAdapter.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ final class FeedViewAdapter: ResourceView {
1414
private let currentFeed: [FeedImage: CellController]
1515

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

1919
init(currentFeed: [FeedImage: CellController] = [:], controller: ListViewController, imageLoader: @escaping (URL) async throws -> Data, selection: @escaping (FeedImage) -> Void) {
2020
self.currentFeed = currentFeed
@@ -54,12 +54,12 @@ final class FeedViewAdapter: ResourceView {
5454
return controller
5555
}
5656

57-
guard let loadMorePublisher = viewModel.loadMorePublisher else {
57+
guard let loadMoreAsync = viewModel.loadMore else {
5858
controller.display(feed)
5959
return
6060
}
6161

62-
let loadMoreAdapter = LoadMorePresentationAdapter(loader: loadMorePublisher)
62+
let loadMoreAdapter = LoadMorePresentationAdapter(loader: loadMoreAsync)
6363
let loadMore = LoadMoreCellController(callback: loadMoreAdapter.loadResource)
6464

6565
loadMoreAdapter.presenter = LoadResourcePresenter(

EssentialApp/EssentialApp/SceneDelegate.swift

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
4949

5050
private lazy var navigationController = UINavigationController(
5151
rootViewController: FeedUIComposer.feedComposedWith(
52-
feedLoader: makeRemoteFeedLoaderWithLocalFallback,
52+
feedLoader: loadRemoteFeedWithLocalFallback,
5353
imageLoader: loadLocalImageWithRemoteFallback,
5454
selection: showComments))
5555

@@ -96,44 +96,59 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
9696
}
9797
}
9898

99-
private func makeRemoteFeedLoaderWithLocalFallback() -> AnyPublisher<Paginated<FeedImage>, Error> {
100-
makeRemoteFeedLoader()
101-
.receive(on: scheduler)
102-
.caching(to: localFeedLoader)
103-
.fallback(to: localFeedLoader.loadPublisher)
104-
.map(makeFirstPage)
105-
.eraseToAnyPublisher()
99+
private func loadRemoteFeedWithLocalFallback() async throws -> Paginated<FeedImage> {
100+
do {
101+
let feed = try await loadAndCacheRemoteFeed()
102+
return makeFirstPage(items: feed)
103+
} catch {
104+
let feed = try await loadLocalFeed()
105+
return makeFirstPage(items: feed)
106+
}
106107
}
107108

108-
private func makeRemoteLoadMoreLoader(last: FeedImage?) -> AnyPublisher<Paginated<FeedImage>, Error> {
109-
localFeedLoader.loadPublisher()
110-
.zip(makeRemoteFeedLoader(after: last))
111-
.map { (cachedItems, newItems) in
112-
(cachedItems + newItems, newItems.last)
113-
}
114-
.map(makePage)
115-
.receive(on: scheduler)
116-
.caching(to: localFeedLoader)
117-
.subscribe(on: scheduler)
118-
.eraseToAnyPublisher()
109+
private func loadAndCacheRemoteFeed() async throws -> [FeedImage] {
110+
let feed = try await loadRemoteFeed()
111+
await store.schedule { [store] in
112+
let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init)
113+
try? localFeedLoader.save(feed)
114+
}
115+
return feed
116+
}
117+
118+
private func loadLocalFeed() async throws -> [FeedImage] {
119+
try await store.schedule { [store] in
120+
let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init)
121+
return try localFeedLoader.load()
122+
}
119123
}
120124

121-
private func makeRemoteFeedLoader(after: FeedImage? = nil) -> AnyPublisher<[FeedImage], Error> {
125+
private func loadRemoteFeed(after: FeedImage? = nil) async throws -> [FeedImage] {
122126
let url = FeedEndpoint.get(after: after).url(baseURL: baseURL)
127+
let (data, response) = try await httpClient.get(from: url)
128+
return try FeedItemsMapper.map(data, from: response)
129+
}
130+
131+
private func loadMoreRemoteFeed(last: FeedImage?) async throws -> Paginated<FeedImage> {
132+
async let cachedItems = try await loadLocalFeed()
133+
async let newItems = try await loadRemoteFeed(after: last)
134+
135+
let items = try await cachedItems + newItems
136+
137+
await store.schedule { [store] in
138+
let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init)
139+
try? localFeedLoader.save(items)
140+
}
123141

124-
return httpClient
125-
.getPublisher(url: url)
126-
.tryMap(FeedItemsMapper.map)
127-
.eraseToAnyPublisher()
142+
return try await makePage(items: items, last: newItems.last)
128143
}
129144

130145
private func makeFirstPage(items: [FeedImage]) -> Paginated<FeedImage> {
131146
makePage(items: items, last: items.last)
132147
}
133148

134149
private func makePage(items: [FeedImage], last: FeedImage?) -> Paginated<FeedImage> {
135-
Paginated(items: items, loadMorePublisher: last.map { last in
136-
{ self.makeRemoteLoadMoreLoader(last: last) }
150+
Paginated(items: items, loadMore: last.map { last in
151+
{ @MainActor @Sendable in try await self.loadMoreRemoteFeed(last: last) }
137152
})
138153
}
139154

EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,27 @@ import EssentialFeediOS
1111
class FeedAcceptanceTests: XCTestCase {
1212

1313
func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() throws {
14-
let feed = try launch(httpClient: .online(response), store: .empty)
14+
let store = try CoreDataFeedStore.empty
15+
let feed = try launch(httpClient: .online(response), store: store)
1516

1617
XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 2)
1718
XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0())
1819
XCTAssertEqual(feed.renderedFeedImageData(at: 1), makeImageData1())
1920
XCTAssertTrue(feed.canLoadMoreFeed)
2021

21-
feed.simulateLoadMoreFeedAction()
22+
try store.withWaitingChanges {
23+
feed.simulateLoadMoreFeedAction()
24+
}
2225

2326
XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 3)
2427
XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0())
2528
XCTAssertEqual(feed.renderedFeedImageData(at: 1), makeImageData1())
2629
XCTAssertEqual(feed.renderedFeedImageData(at: 2), makeImageData2())
2730
XCTAssertTrue(feed.canLoadMoreFeed)
2831

29-
feed.simulateLoadMoreFeedAction()
32+
try store.withWaitingChanges {
33+
feed.simulateLoadMoreFeedAction()
34+
}
3035

3136
XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 3)
3237
XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0())
@@ -41,7 +46,9 @@ class FeedAcceptanceTests: XCTestCase {
4146
let onlineFeed = try launch(httpClient: .online(response), store: sharedStore)
4247
onlineFeed.simulateFeedImageViewVisible(at: 0)
4348
onlineFeed.simulateFeedImageViewVisible(at: 1)
44-
onlineFeed.simulateLoadMoreFeedAction()
49+
try sharedStore.withWaitingChanges {
50+
onlineFeed.simulateLoadMoreFeedAction()
51+
}
4552
onlineFeed.simulateFeedImageViewVisible(at: 2)
4653

4754
let offlineFeed = try launch(httpClient: .offline, store: sharedStore)
@@ -186,6 +193,25 @@ class FeedAcceptanceTests: XCTestCase {
186193

187194
@MainActor
188195
extension CoreDataFeedStore {
196+
private struct Timeout: Error {}
197+
198+
func withWaitingChanges(_ action: () -> Void, timeout: TimeInterval = 1) throws {
199+
let state = try retrieve()?.timestamp
200+
action()
201+
202+
let maxDate = Date() + timeout
203+
204+
while Date() <= maxDate {
205+
if try retrieve()?.timestamp != state {
206+
return
207+
}
208+
209+
RunLoop.current.run(until: Date())
210+
}
211+
212+
throw Timeout()
213+
}
214+
189215
static var empty: CoreDataFeedStore {
190216
get throws {
191217
try CoreDataFeedStore(storeURL: URL(fileURLWithPath: "/dev/null"), contextQueue: .main)

0 commit comments

Comments
 (0)