Combine.frameworkを体験する取っ掛かりとして
APIKitのRequestにPublisherを生やして
複数の非同期通信を直列・並列で叩くサンプルを実装してみました。
まだまだCombineニュービーですのでオススメの方法や
間違い等有りましたらご指摘頂けると幸いです🙇🏻♂️
環境
- Xcode 12.1
- APIKit 5.1.0
- SwiftUI
サンプルコード
プロジェクト構成
APIKitにPublisherを生やす実装方法については
Developers.IO さんの以下の記事が
非常にわかりやすかったのでほぼコピさせていただきました。
[iOS 13] SwiftUI + Combine + APIKitでインクリメンタルサーチ
今回のサンプルコードでは複数の通信を叩くために
GitHub REST APIを使う形に変更しています。
処理の流れ
主な登場人物は上記赤枠の3つです。
処理の流れは以下になります。
-
ContentView
のUISearchBar
の入力を受け取る -
SampleModel
でリクエストを作成して直列の通信を開始する - 最初に
/search/repositories
を叩く - 上記のレスポンスを利用して
/repos/{owner}/{repositry}
を叩く - 通知された結果を表示する
直列で叩く(Publisher)
最初に/search/repositories
を叩き、レスポンスを利用して
/repos/{owner}/{repositry}
を叩いてみます。
正直最初のリクエストで欲しいデータは取得できますが学びということで。
// `/search/repositories` のリクエスト作成
let searchRepositories = SearchRepositoriesRequest(query: searchText).publisher.eraseToAnyPublisher()
self.requestCancellable = searchRepositories
// レスポンスはSearchRepositoriesResponse型なのでitems.firstをとりあえず取得
.compactMap { $0.items.first }
// 上記レスポンスを利用して GetRepositoyRequest を作成
.flatMap { (repository) -> AnyPublisher<GetRepositoyRequest.Response, Error> in
/* 処理を繋げるとコンパイラが型がネストして解釈して複雑な型になるため
* eraseToAnyPublisher()で型消去した
* AnyPublisher<GetRepositoyRequest.Response, Error> を返す
*/
GetRepositoyRequest(owner: repository.owner.login, repositry: repository.name).publisher.eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.sink { (completion) in
switch completion {
case .finished:
debugPrint("request finished")
case let .failure(error):
debugPrint("request failed : \(error)")
}
} receiveValue: { [weak self] repository in
// `/repos/{owner}/{repositry}` のレスポンスをContentViewに渡す
self?.items = [repository]
}
補足リンク eraseToAnyPublisher()での型消去について
Futureを使って直列で叩く
FutureはSubscribeされなくても
インスタンスが生成されたタイミングで即時実行されるので
とりあえずクロージャーで返すメソッドを作って処理を繋げてみました。
こちらも本来であれば再利用のためにAPIKitに生やすのが望ましいでしょうか。
// MARK: - Futureを使って直列で叩く
self.requestCancellable = fetchRepos(query: searchText)
.compactMap { $0.items.first }
.flatMap { [unowned self] repository in
self.fetchRepository(owner: repository.owner.login, repo: repository.name)
}.sink { (completion) in
switch completion {
case .finished:
debugPrint("request finished")
case let .failure(error):
debugPrint("request failed : \(error)")
}
} receiveValue: { [weak self] repository in
self?.items = [repository]
}
// MARK: - 各リクエストの生成処理
// ※Futureは即時実行される => インスタンスが生成されたタイミングでSubscribeをしなくても処理が走る
private func fetchRepos(query: String) -> Future<SearchRepositoriesRequest.Response, Error> {
return Future<SearchRepositoriesRequest.Response, Error> { promise in
Session.send(SearchRepositoriesRequest(query: query), callbackQueue: .main) { (result) in
switch result {
case .success(let res):
// 処理成功
promise(.success(res))
case .failure(let error):
// 処理失敗
promise(.failure(error))
}
}
}
}
// ※Futureは即時実行される => インスタンスが生成されたタイミングでSubscribeをしなくても処理が走る
private func fetchRepository(owner: String, repo: String) -> Future<GetRepositoyRequest.Response, Error> {
return Future<GetRepositoyRequest.Response, Error> { promise in
Session.send(GetRepositoyRequest(owner: owner, repositry: repo), callbackQueue: .main) { (result) in
switch result {
case .success(let res):
// 処理成功
promise(.success(res))
case .failure(let error):
// 処理失敗
promise(.failure(error))
}
}
}
}
ついでに並列でも叩いてみる
Publishers.Zipに渡せば並列で叩けるので
/search/repositories
と /search/users
を同じクエリで叩いてみます。
let searchRepositories = SearchRepositoriesRequest(query: searchText).publisher.eraseToAnyPublisher()
let searchUsers = SearchUsersRequest(query: searchText).publisher.eraseToAnyPublisher()
self.requestCancellable = Publishers.Zip(searchRepositories, searchUsers)
.receive(on: DispatchQueue.main)
.sink { _ in } receiveValue: { (repos, users) in
print(repos)
print(users)
}
ついでに補足比較: RxSwiftで直列で叩く場合
RxSwiftの場合はSessionにObservableを生やしてflatMapで繋ぐ感じでしょうか。
extension APIKit.Session {
func rx_sendRequest<T: Request>(request: T) -> Observable<T.Response> {
return Observable.create { observer in
let task = self.send(request) { result in
switch result {
case let .success(res):
observer.on(.next(res))
observer.on(.completed)
case let .failure(err):
observer.onError(err)
}
}
return Disposables.create {
task?.cancel()
}
}
}
}
// Observableで包んで返す
func searchRepositories(request: SearchRepositoriesRequest) -> Observable<SearchRepositoriesRequest.Response> {
return Session.rx_sendRequest(request: request)
}
func getRepository(request: GetRepositoryRequest) -> Observable<GetRepositoryRequest.Response> {
return Session.rx_sendRequest(request: request)
}
// もろもろ省略
let observable = searchRepositories(request: searchRepositoriesRequest)
.flatMap { [unowned self] repos -> Observable<GetRepositoryRequest.Response> in
let item = repos.items.first //とりあえず成功してアンラップできてるものとみなす
let getRepositoryRequest = getRepositoryRequest(owner: item.owner.login, repo: item.name)
return self.getRepository(request: getRepositoryRequest)
}.もろもろ続く
参考にさせて頂きました。ありがとうございます。
- [iOS 13] SwiftUI + Combine + APIKitでインクリメンタルサーチ
- [iOS]APIKit + Combine + SwiftUIでGitHubリポジトリ検索
- 【Swift】Combineで一つのPublisherの出力結果を共有するメソッドやクラスの違い(share, multicast, Future)
- Combine で非同期処理を直列で実行する(flatMap)
- Modern Networking in Swift 5 with URLSession, Combine and Codable
- Creating a search bar for SwiftUI