Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Combineを中心とした処理をSwift Concurrencyへ (これまでも調べた調査と向き合い)

Combineを中心とした処理をSwift Concurrencyへ (これまでも調べた調査と向き合い)

3/13に開催されました「yumemi.grow Mobile #11」での登壇資料になります。

Combineをメインにした状態からasync/awaitへ置き換える事例を調査をまとめたものになります。

CombinePublisherが中心の構成から置き換える際のアプローチに対する考察や、独自Extensionの活用やAsyncStream等を活用した事例に触れた事で見えた事、そして、複雑そうに見える機能においてasync/awaitを利用する事で見通しが良くなった例の紹介等をピックアップしています。

Fumiya Sakai

March 13, 2024
Tweet

More Decks by Fumiya Sakai

Other Decks in Technology

Transcript

  1. 自己紹介 ・Fumiya Sakai ・Mobile Application Engineer アカウント: ・Twitter: https://twitter.com/fumiyasac ・Facebook:

    https://www.facebook.com/fumiya.sakai.37 ・Github: https://github.com/fumiyasac ・Qiita: https://qiita.com/fumiyasac@github 発表者: ・Born on September 21, 1984 これまでの歩み: Web Designer 2008 ~ 2010 Web Engineer 2012 ~ 2016 App Engineer 2017 ~ Now iOS / Android / sometimes Flutter
  2. Combineを利用した全体処理のイメージを整理(1) UIKitを利用する際はKickStarterのViewModelを参考にした形を想定 ViewController ViewModel UseCase・Repository Infrastructure ※UIKit CombinePublisherを利用して処理を接続している ※RxSwiftを利用時に近いイメージの状態 UIKitでViewModelの処理を連結する際のポイント

    : Input Output viewModel.inputs.fetchTrigger.send() ViewModel.outputs.articles.sink({ … }) Input Trigger Property: ① ViewModelクラスにはInput/OutputのProtocolを定義し、viewModel.inputs. … / viewModel.outputs. …の様な形を作る ② Input/Outputを作成する際には @Published private var xxx: XXX を仲介して変換する var fetchTrigger: PassthroughSubject<Void, Never> { get } @Published private var _articles: [Article] = [] Relay Point となる @Published: ※ 中継地点の変数を準備してOutputへ変換 ③ 下記の様なイメージで、Inputで定義したTriggerを実行 → Outputで定義した変数に値が流れる Output Trigger Property: var articles: AnyPublisher<[Article], Never> { get }
  3. UIKit&Combineを利用した際のコード事例 InputのfetchArticlesTriggerが実行されるとOutputの変数が更新される // MARK: - MainViewModelType var inputs: MainViewModelInputs {

    return self } var outputs: MainViewModelOutputs { return self } // MARK: - MainViewModelInputs let fetchArticlesTrigger = PassthroughSubject<Void, Never>() // MARK: - MainViewModelOutputs var articles: AnyPublisher<[Article], Never> { return $_articles.eraseToAnyPublisher() } // MARK: - @Published @Published private var _articles: [Article] = [] // MARK: - Initializer init(api: APIRequestManagerProtocol) { ・・・(途中省略)・・・ fetchArticlesTrigger .sink(receiveValue: { [weak self] in self?.fetchArticles() }) .store(in: &cancellables) } } private func fetchArticles() { api.getArticles() .receive(on: RunLoop.main) .sink( receiveCompletion: { completion in switch completion { case .failure(let error): print("error getArticles") } }, receiveValue: { [weak self] hashableObjects in print(hashableObjects) self?._articles = hashableObjects } ) .store(in: &cancellables) } APIからのデータ取得と内部変数への反映を行う Inputとリクエスト処理の結合 ※BehaviorRelayのような感じの結合 @Publishedの値を変換しOutPutを更新 ViewModel内部処理と処理の流れを紐解く:
  4. Combineを利用した全体処理のイメージを整理(2) SwiftUIで作成したComponentと双方向Bindingをする様な形を想定 View Components ViewModel UseCase・Repository Infrastructure ※SwiftUI CombinePublisherを利用して処理を接続している ※RxSwiftを利用時に近いイメージの状態

    Button活性・非活性状態を管理する: SwiftUIのView要素の特徴を踏まえたポイント : ① SwiftUIのVIew要素ではStateが含まれる形にそもそもなっている ② ViewModelはObservableObjectを継承し、View要素では @ObservedObject / @StateObject で連結する var sendButtonDisabled: Bool = true @Published var inputEmail: String = "" 入力用TextFieldと連結する: ※ Binding<String>で渡す必要があるため Input Output ViewModel内に定義したメソッド: viewModel.doSomething() ViewModel内の@Publishedで定義したProperty ③ 下記の様なイメージでViewModelに定義したPropertyと共に利用する 画面表示用データをStoreする: @Published private(set) var articles: [Article] = [] アクセス修飾子に注意
  5. 元々Combineベースの処理をどこから変換していく? UseCaseやRepositoryクラスやAPIリクエスト関連部分から変換するアプローチ View Components ViewModel UseCase・Repository Infrastructure 処理によってはCombineの補助が有効な場合がある 元々CombinePublisherを利用した処理を少しずつ置換していく CombinePublisherを上手に変換していくアイデア例

    : AnyPublisher → async/awaitは可能? ① AnyPublisherを変換するExtensionを定義しておきasync/awaitへの変換を可能な形とする ② .flatMap / .zip 等といった頻出Oprator処理についてもasync/awaitへ書き換えていく方針とする AsyncStream・AsyncSequenceを利用した処理への書き換え等… ※新規処理はasync/awaitで作るが、その中で既存処理が必要になった場合でも対応可能にしたい 他のアイデアとして考えられる手段
  6. AnyPublisherを変換するExtensionの事例紹介(1) Combineを利用する処理で頻繁に活用されるAnyPublisherを変換するExtension例 extension AnyPublisher { func async() async throws ->

    Output { try await withCheckedThrowingContinuation { continuation in var cancellable: AnyCancellable? var finishedWithoutValue = true cancellable = first() .sink { result in switch result { case .finished: if finishedWithoutValue { continuation.resume(throwing: AsyncError.finishedWithoutValue) } case let .failure(error): continuation.resume(throwing: error) } cancellable?.cancel() }, receiveValue: { value in finishedWithoutValue = false continuation.resume(with: .success(value)) } } } } 参考記事: From Combine to Async/Await https://medium.com/geekculture/from-combine-to-async-await-c08bf1d15b77 ※処理完了判定フラグ値 Error(1): 値を受け取らず処理終了となった場合 Error(2): ServerError等で処理エラーとなった場合 Success: 値を反映すると同時に処理完了フラグ変数を更新する func loadArticle() -> AnyPublisher<Article, Error> { URLSession.shared.dataTaskPublisher(for: articleUrl) .map { $0.data }.decode(type: Article.self, decoder: JSONDecoder()) .eraseToAnyPublisher() } try await loadArticle().async()で変換可能
  7. AnyPublisherを変換するExtensionの事例紹介(2) AnyPublisherに定義したasync()を利用する事でAPI非同期通信処理を変換する class APIRequestManager { func handleSessionTask<T: Decodable & Hashable>(_

    dataType: T.Type, request: URLRequest) -> Future<[T], APIError> { return Future { promise in let task = self.session.dataTask(with: request) { data, response, error in // MEMO: Response形式やStatusCodeを元にしたErrorHandlingを実施する // … (省略) … // MEMO: 取得したResponseを引数で指定した型の配列に変換して受け取る do { let hashableObjects = try JSONDecoder().decode([T].self, from: data) promise(.success(hashableObjects)) } catch { promise(.failure(APIError.error(error.localizedDescription))) } } task.resume() } } } API通信処理用に共通化したFuture<T,F>型を変換する: let result = try await APIRequestManager.shared.handleSessionTask([Article].self, request: request) .eraseToAnyPublisher().async() APIリクエスト関連処理をasync/awaitへ変換する この処理における変換手順 ① Future<T,F> ② AnyPublisher<T,F> ③ async/await 既存のCombine処理であったとして も .eraseToAnyPublisher() で変換 した後に .async() を利用する事で シンプルな処理から置換する。
  8. CombineのOperatorを利用した処理を置き換える場合(1) Combineでよくある処理例をasync/awaitへ置き換える簡単な事例(flatMap) 1. flatMapを利用して処理1→処理2→処理3の様に直列に処理する場合: func first() -> AnyPublisher<T2, Error> {

    return Future<T2, Error> { promise in promise(.success(T2())) } .eraseToAnyPublisher() } func second(_ value: T2) -> AnyPublisher<T3, Error> { return Future<T3, Error> { promise in promise(.success(T3())) } .eraseToAnyPublisher() } func third(_ value: T3) -> AnyPublisher<T4, Error> { return Future<T4, Error> { promise in promise(.success(T4())) } .eraseToAnyPublisher() } first() .flatMap { self.second($0) } .flatMap { self.third($0) } .sink(receiveCompletion: { result in // 完了時or失敗時のHandling処理 }, receiveValue: { t4 in // T4型の値を利用した処理 }) } ※ flatMap内処理で期待するAnyPublisher<T,F>型へ変更する必要があるので、複雑な処理になると記載が大変になる。 async/awaitに変換した場合: let firstResult = try await first() let secondResult = try await second(firstResult) let thirdResult = try await third(secondResult) // T4型の値を利用した処理 AnyPublisher<T,F>で作成している処理部分を async throws -> T で記述するイメージです。 ※ do ~ catch 内で下記の様な処理を実行 処理内の途中段階でErrorが発生した場合には catch内に記述した処理が実行されます。
  9. CombineのOperatorを利用した処理を置き換える場合(2) Combineでよくある処理例をasync/awaitへ置き換える簡単な事例(Zip) 2. Zipを利用して処理1・処理2・処理3全ての処理完了を待って処理する場合: func firstPublisher() -> AnyPublisher<T1, Error> {

    return Future<T1, Error> { promise in promise(.success(T1())) } .eraseToAnyPublisher() } func secondPublisher() -> AnyPublisher<T2, Error> { return Future<T2, Error> { promise in promise(.success(T2())) } .eraseToAnyPublisher() } func thirdPublisher() -> AnyPublisher<T3, Error> { return Future<T3, Error> { promise in promise(.success(T3())) } .eraseToAnyPublisher() } let zippedPublisher = Publishers.Zip3( firstPublisher(), secondPublisher(), thirdPublisher() ) .sink(receiveCompletion: { result in // 完了時or失敗時のHandling処理 }, receiveValue: { t1, t2, t3 in // T1,T2,T3型の値を利用した処理 }) ※ Zipを利用した並列処理は良く利用されるものではあるが、一度に結合できる処理数の制限があるので注意が必要。 async/awaitに変換した場合: async let firstResult = try await first() async let secondResult = try await second() async let thirdResult = try await third() // T1,T2,T3型の値を利用した処理 AnyPublisher<T,F>で作成している処理部分を async throws -> T で記述するイメージです。 ※ do ~ catch 内で下記の様な処理を実行 処理内の途中段階でErrorが発生した場合には catch内に記述した処理が実行されます。
  10. CombinePublisherをAsyncSequenceを使って変換する例 dataTaskPublisherを置き換えることによってasync/awaitに変換している 参考記事: Combine PublisherをSwift Concurrencyに変換する https://qiita.com/hyuga_amazia/items/2ce629916c2a088d1ecb extension Publisher where

    Failure == Never { var value: Output { get async { await self .first() .values .first { _ in true }! } } } extension Publisher { var value: Output { get async throws { try await self .first() .values .first { _ in true }! } } } AsyncStreamの最初の値を利用 func fetchArticlesWithError() async throws -> [Article] { try await URLSession.shared.dataTaskPublisher(for: URL(string: “URL文字列")!) .map(\.data) .decode(type: [Article].self, decoder: JSONDecoder()) .value } APIリクエスト関連処理をasync/awaitへ変換する この処理における変換手順 ① CombinePublisher ② AsyncSequence ③ async/await 参考記事: CombineをAsyncSequenceとして処理する https://qiita.com/hyuga_amazia/items/c25f6b2fbd6db951e4eb AsyncPublisher or AsyncThrowingPublisher
  11. CombineとAsyncStreamでの処理を比較してみる(1) AsyncStreamへの置き換えもアイデアの1つかもしれないので調査してみました 会員情報を更新する様な画面を考えてみる: Combineと比較すると、for-inやtry-catch等が利用できるので、より Swiftらしい書き方を利用可能になる。 Combine・AsyncStreamで記載する場合を見比べる データを更新する 会員情報を更新する 📩 Mail:

    [email protected] データを更新する 会員情報を更新する 📩 Mail: 😄 UserID: sampleNumber123 😄 UserID: 参考記事: swift-async-algorithms...? へえ…面白そうじゃん…? https://speakerdeck.com/k_koheyi/swift-async-algorithms-dot- dot-dot-hee-dot-dot-dot-mian-bai-souziyan-dot-dot-dot?slide=20 FileのDownload・Upload処理で活用する際には重宝しそうな印象。 AsyncStream・AsyncThrowingStreamを利用する事によって、直列処理 や並列処理も実現可能。 従来のCombinePublisherメインの処理も置き換え可能な場合もある
  12. CombineとAsyncStreamでの処理を比較してみる(2) Combine処理をAsyncStream処理への置換したコード事例を見てみる // 入力値の受け皿となる変数 let email = PassthroughSubject<String, Never>() let

    userID = PassthroughSubject<String, Never>() // 入力値を元にボタンの活性・非活性を決定する let cancellables = email.removeDuplicates() .combineLatest(userID.removeDuplicates()) .map { email, userID in !email.isEmpty && !userID.isEmpty } .sink( receiveCompletion: { _ in }, receiveValue: { [weak self] shouldActivate in // ボタンの状態を更新する処理 self?.handleUpdateButton(shouldActivate: shouldActivate) } ) // データを入力する email.send("[email protected]") userID.send("sampleNumber123") cancellables.cancel() // 入力値の受け皿となる変数 let email = AsyncChannel<String>() let userID = AsyncChannel<String>() // 入力値を元にボタンの活性・非活性を決定する let stream = combineLatest(email.removeDuplicates(), userID.removeDuplicates()) .map { email, userID in !email.isEmpty && !userID.isEmpty } // データを入力する Task { await email.send("[email protected]") await userID.send("sampleNumber123") email.finish() userID.finish() } // ボタンの状態を更新する処理 for try await shouldActivate in stream { handleUpdateButton(shouldActivate: shouldActivate) } 両方の入力状態を監視する 入力の受け皿となる変数 ※この処理例はかなり類似した事例
  13. CombineとAsyncStreamでの処理を比較してみる(3) 一覧表示処理と絞り込み検索をする処理に関しても考えてみます 会員情報を更新する様な画面を考えてみる: メンバーを検索する メンバーを検索する 🔍 参考記事: Using new Swift

    Async Algorithms package to close the gap on Combin https://johnoreilly.dev/posts/swift-async-algorithms-combine/ ※こちらはView要素と結合しているViewModel処理イメージになります 🔍 fumiya // SwiftUIの表示と双方向BindingをするためのProperty @Published var filteredMembers = [Member]() @Published var query: String = “" // Logicから表示データを取得する&入力された文字列を監視する let memberStream = membersRepository.getAll() .map { $0.sorted { $0.points > $1.points } } let queryStream = $query .debounce(for: 0.5, scheduler: DispatchQueue.main).values // 2つのStreamを繋ぎ合わせて文字列に合致するfilter処理を実施する for await (members, query) in combineLatest(memberStream, queryStream) { self.filteredMembers = members.filter { query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) } } AsyncStreamで処理を作成 処理としては、補助的な形で 活用することになるかも?
  14. async/awaitの処理を利用した事でとても良かった例 FirebaseStorageへ画像を複数枚まとめて投稿する機能を考えてみる 取得データ一覧表示 複数枚画像アップロード ① 投稿したい写真を選択 点線部分をタップすると、UIPickerViewController等で投稿対象の写真を選択する。 ② 選択されている写真を操作 右上の閉じるボタンを押下する。

    👉 取得したUIImageを表示 👉 最後以外の場合は詰めて表示 👉 対象の画像要素を削除する 表示エリアをUICollectionViewで構築する形にしておく。 👉 タップすると再度画像選択 👉 Drag & DropでSorting ③ 写真を投稿するボタン 写真が1枚も投稿していない場合にはボタンは非活性状態となる。 写真が最低1枚投稿している場合にはボタンは活性状態となる。
  15. putDataAsyncを使った複数枚画像アップロード処理(1) enum UploadImagesResult { case none case loading case success

    case failure } protocol PostShopImagesUseCase { func uploadShopImages(shopID: String, images: [UIImage]) async -> UploadImagesResult } final class PostShopImagesUseCaseImpl: PostShopImagesUseCase { // MARK: - Function func uploadShopImages(shopID: String, images: [UIImage]) async -> UploadImagesResult { return await uploadImagesToFireStorage(shopID: shopID, images: images) } // MARK: - Private Function private func uploadImagesToFireStorage(shopID: String, images: [UIImage]) async -> UploadImagesResult { // 👉 画像アップロード処理用の本丸部分は次のスライドになります。 } } async/await処理中はUI上では LoadingIndicator表示をする想定
  16. putDataAsyncを使った複数枚画像アップロード処理(2) var uploadSuccessCount: Int = 0 for (i, image) in

    images.enumerated() { // 👉 引数で受け取ったUIImageをData型に変換する(compressionQualityの値は仕様に応じて決定する) let data = image.jpegData(compressionQuality: 0.5)! // 👉 Metadata設定 let metadata = StorageMetadata() metadata.contentType = "image/jpeg" // 👉 アップロードする画像を配置する場所を設定する(配置するパス情報については仕様に応じて決定する) // Path例: user_shop_images/{shopID}/{userID}/{YYYYMMDD}/{YYYYMMDD_(1...5).jpg} let reference = Storage.storage().reference(forURL: "◎◎◎") // 👉 FirestorageへputDataAsyncを利用して画像ファイルアップロード処理を実行する // ※ for文のループ処理を実行している最中は、画面上はLoadingIndicatorが表示されている想定をして実装する様にする。 do { // MEMO: ファイルアップロード処理に成功した場合はこの場合はuploadSuccessCountのカウントをインクリメントしている。 _ = try await reference.putDataAsync(data, metadata: metadata) uploadSuccessCount += 1 } catch let error { // MEMO: 厳密にはファイルアップロード処理に失敗した画像があればログを送信する等をしておいた方がが望ましい。 print("File Upload Error: " + error.localizedDescription) } } // 👉 成功時or失敗時のハンドリング処理を実行する // ※ この処理ではアップロードに成功したものが1つでもあった場合には成功と見なしている。 return (uploadSuccessCount > 0) ? .success : .failure 成功 or 失敗の判定処理を実施する ※Upload対象の[UIImage]の順番を基準としています FirebaseStorageの処理実行
  17. 余談:FirebaseStorageの処理をCombineで書く場合 引用リポジトリ: CombineFirebase https://github.com/urban-health-labs/CombineFirebase extension StorageReference { func putData(_ uploadData:

    Data, metadata: StorageMetadata? = nil) -> AnyPublisher<StorageMetadata, Error> { var task: StorageUploadTask? return Future<StorageMetadata, Error> { [weak self] promise in task = self?.putData(uploadData, metadata: metadata) { metadata, error in guard let error = error else { if let metadata = metadata { promise(.success(metadata)) } return } promise(.failure(error)) } }.handleEvents(receiveCancel: { task?.cancel() }) .eraseToAnyPublisher() } } var cancelBag = Set<AnyCancellable>() let reference = Storage.storage() .reference(forURL: "\(your_firebase_storage_bucket)/images/example.jpg") let data: Data // 👉 Upload対象のUIImageをDataへ変換する処理を実施 let _ = (reference.putData(data) as AnyPublisher<StorageMetadata, Error>) .sink(receiveCompletion: { completion in switch completion { case .finished: print("🙆 画像Upload処理が完了しました") case .failure(let error): print("🙅 画像Upload処理が失敗しました") } }) { metadata in print("🙆 画像Upload処理が成功しました") } .store(in: &cancelBag) ※Combineでの処理例
  18. まとめ 正直まだまだ自分も掴み切れてはいないが前向きに取り組んでいけそう。 1.Combine・AsyncStream・AsyncSequence・async/await等の事例に少しずつ触れてみる: もしCombinePublisherを中心とした処理を置き換えていく場合は、様々な処理の選択肢はあるが、現在開発中のモバイルアプリ の事情に合わせた選択ができればベターではある。簡単な事例〜少し難しいものまでの事例を知る事が1歩目かもしれないです。 2. CombinePublisherを置き換えるExtension等の活用も良いアイデアの1つ: できる所から少しずつ元の処理イメージを保ちながら進めるために、Extension等を活用しながら少しずつ置換する方針で進めて いく方が、特に規模がある場合は良さそうと感じました。(※特にBusinessLogicやDomainLogic等は実施しやすそう) 最近はUI実装関連の事が全然できていなくてすみません…。でもこういう事を考えるのも好きです。

    3. async/awaitによる置き換えによって処理がシンプルにできた経験をヒントにしてみる: 今回ピックアップした、FirebaseStorageを利用した写真を複数枚アップロードする画面処理の事例はasync/awaitを利用する処 理における事例の様に、今まで面倒だと感じていた処理を便利にできる事例からヒントを得られる事もあると思います。