Slide 1

Slide 1 text

Combineを中心とした処理をSwift Concurrencyへ (これまでも調べた調査と向き合い) yumemi.grow Mobile #11 2024/03/13 Fumiya Sakai

Slide 2

Slide 2 text

自己紹介 ・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

Slide 3

Slide 3 text

iOSのUI実装本を執筆しています! 書籍に掲載したサンプルのバージョンアップや続編等に現在着手中です。 少しの工夫で実現できるTIPS集やライブラリ表現の活用集をはじめとした、iOSア プリ開発の中でも特にUI実装やUIKitを利用した画面の中で特徴を与える様な表現 という題材に焦点を当てた書籍となっております。 現在は電子書籍版のみとなります。 こちらは全て¥1,000となっております。 https://just1factory.booth.pm/ 概要: https://book-tech.com/ 価格: 📖 Booth 📖 Book Tech

Slide 4

Slide 4 text

UI実装であると嬉しいレシピブックの最新情報 UI実装であると嬉しいレシピブックVol.3として昨年10月に商業化しました! Still WIP これまでの同人誌として頒布したものに加えて、Vol.1及びVol.2に頒布したものの 中で書籍に載せきれなかったものや表現や動きが特徴的でユーザーにもほんの少し 遊び心を与える様なUI実装を紹介したものをVol.3としています。 概要: これからの構想: こちらで購入可能です: Amazon / Google Play / Apple Books / KINOKUNIYA / Rakuten BOOKS etc.. 🏊 iOS: SwiftUIを利用したUI実装や動画関連の実装 🏊 Android: Jetpack Composeの基本やその他気になるUI表現の考察

Slide 5

Slide 5 text

今回のスライドにつきまして Combineをメインにした状態からasync/awaitへ置き換える事例を調査しています これまで自分がしてきた調査事例の紹介になりますが、お付き合い頂けると嬉しいです。 1. CombinePublisherが中心の構成から置き換える際のアプローチ: 強引にエイヤ!と置き換えてしまいたい所ではあるものの、プロジェクト内で段階的な置換を想定した場合の予測を立てる際の ヒントやイメージをある程度自分でも持っておきたいと動機がありました。 2. 独自Extensionの活用やAsyncStream等を活用した事例にも触れてみる: 調査を進めていく過程の中でCombineを変換するExtensionやAsyncStream等での置き換えを調べたり、簡単なコードを記載した事 例に触れていました。そこで実際に調査した実装例にも触れて個人的に興味深かったものについてご紹介します。 3. 複雑そうな機能においてasync/awaitを利用する事で見通しが良くなった例の紹介: 以前に新機能の開発をした際にasync/awaitを利用した処理を活用する事で、かなり難易度が高そうな処理が意外とシンプルに実 現できた事例についても簡単にご紹介できればと思います。

Slide 6

Slide 6 text

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 { get } @Published private var _articles: [Article] = [] Relay Point となる @Published: ※ 中継地点の変数を準備してOutputへ変換 ③ 下記の様なイメージで、Inputで定義したTriggerを実行 → Outputで定義した変数に値が流れる Output Trigger Property: var articles: AnyPublisher<[Article], Never> { get }

Slide 7

Slide 7 text

UIKit&Combineを利用した際のコード事例 InputのfetchArticlesTriggerが実行されるとOutputの変数が更新される // MARK: - MainViewModelType var inputs: MainViewModelInputs { return self } var outputs: MainViewModelOutputs { return self } // MARK: - MainViewModelInputs let fetchArticlesTrigger = PassthroughSubject() // 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内部処理と処理の流れを紐解く:

Slide 8

Slide 8 text

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で渡す必要があるため Input Output ViewModel内に定義したメソッド: viewModel.doSomething() ViewModel内の@Publishedで定義したProperty ③ 下記の様なイメージでViewModelに定義したPropertyと共に利用する 画面表示用データをStoreする: @Published private(set) var articles: [Article] = [] アクセス修飾子に注意

Slide 9

Slide 9 text

元々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で作るが、その中で既存処理が必要になった場合でも対応可能にしたい 他のアイデアとして考えられる手段

Slide 10

Slide 10 text

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 { URLSession.shared.dataTaskPublisher(for: articleUrl) .map { $0.data }.decode(type: Article.self, decoder: JSONDecoder()) .eraseToAnyPublisher() } try await loadArticle().async()で変換可能

Slide 11

Slide 11 text

AnyPublisherを変換するExtensionの事例紹介(2) AnyPublisherに定義したasync()を利用する事でAPI非同期通信処理を変換する class APIRequestManager { func handleSessionTask(_ 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型を変換する: let result = try await APIRequestManager.shared.handleSessionTask([Article].self, request: request) .eraseToAnyPublisher().async() APIリクエスト関連処理をasync/awaitへ変換する この処理における変換手順 ① Future ② AnyPublisher ③ async/await 既存のCombine処理であったとして も .eraseToAnyPublisher() で変換 した後に .async() を利用する事で シンプルな処理から置換する。

Slide 12

Slide 12 text

CombineのOperatorを利用した処理を置き換える場合(1) Combineでよくある処理例をasync/awaitへ置き換える簡単な事例(flatMap) 1. flatMapを利用して処理1→処理2→処理3の様に直列に処理する場合: func first() -> AnyPublisher { return Future { promise in promise(.success(T2())) } .eraseToAnyPublisher() } func second(_ value: T2) -> AnyPublisher { return Future { promise in promise(.success(T3())) } .eraseToAnyPublisher() } func third(_ value: T3) -> AnyPublisher { return Future { 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型へ変更する必要があるので、複雑な処理になると記載が大変になる。 async/awaitに変換した場合: let firstResult = try await first() let secondResult = try await second(firstResult) let thirdResult = try await third(secondResult) // T4型の値を利用した処理 AnyPublisherで作成している処理部分を async throws -> T で記述するイメージです。 ※ do ~ catch 内で下記の様な処理を実行 処理内の途中段階でErrorが発生した場合には catch内に記述した処理が実行されます。

Slide 13

Slide 13 text

CombineのOperatorを利用した処理を置き換える場合(2) Combineでよくある処理例をasync/awaitへ置き換える簡単な事例(Zip) 2. Zipを利用して処理1・処理2・処理3全ての処理完了を待って処理する場合: func firstPublisher() -> AnyPublisher { return Future { promise in promise(.success(T1())) } .eraseToAnyPublisher() } func secondPublisher() -> AnyPublisher { return Future { promise in promise(.success(T2())) } .eraseToAnyPublisher() } func thirdPublisher() -> AnyPublisher { return Future { 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で作成している処理部分を async throws -> T で記述するイメージです。 ※ do ~ catch 内で下記の様な処理を実行 処理内の途中段階でErrorが発生した場合には catch内に記述した処理が実行されます。

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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メインの処理も置き換え可能な場合もある

Slide 16

Slide 16 text

CombineとAsyncStreamでの処理を比較してみる(2) Combine処理をAsyncStream処理への置換したコード事例を見てみる // 入力値の受け皿となる変数 let email = PassthroughSubject() let userID = PassthroughSubject() // 入力値を元にボタンの活性・非活性を決定する 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() let userID = AsyncChannel() // 入力値を元にボタンの活性・非活性を決定する 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) } 両方の入力状態を監視する 入力の受け皿となる変数 ※この処理例はかなり類似した事例

Slide 17

Slide 17 text

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で処理を作成 処理としては、補助的な形で 活用することになるかも?

Slide 18

Slide 18 text

async/awaitの処理を利用した事でとても良かった例 FirebaseStorageへ画像を複数枚まとめて投稿する機能を考えてみる 取得データ一覧表示 複数枚画像アップロード ① 投稿したい写真を選択 点線部分をタップすると、UIPickerViewController等で投稿対象の写真を選択する。 ② 選択されている写真を操作 右上の閉じるボタンを押下する。 👉 取得したUIImageを表示 👉 最後以外の場合は詰めて表示 👉 対象の画像要素を削除する 表示エリアをUICollectionViewで構築する形にしておく。 👉 タップすると再度画像選択 👉 Drag & DropでSorting ③ 写真を投稿するボタン 写真が1枚も投稿していない場合にはボタンは非活性状態となる。 写真が最低1枚投稿している場合にはボタンは活性状態となる。

Slide 19

Slide 19 text

1回の処理にて複数枚の画像をアップロードする想定 当初はCombineでの実装を考えたが、async/await想定のメソッドが存在した 補足: UI実装に関して 順番を担保しながらファイルをアップロードする点が厄介だった: SwiftUIでも出来そうに思えましたが、この機能を作成当時はUICollectionViewを利用する方針を選択しました。 この処理におけるポイント indexの順番を担保した状態で選択した画像をアップロードしたい BusinessLogic処理方針 ① 現在選択されているUIImageの配列を元にfor文でループを実行する ② for文内でFirebaseStorageで提供しているputDataAsyncを利用して画像をアップロードする Firebase Document: https://firebase.google.com/docs/reference/swift/firebasestorage/api/ reference/Classes/StorageReference#putdataasync_:metadata:

Slide 20

Slide 20 text

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表示をする想定

Slide 21

Slide 21 text

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の処理実行

Slide 22

Slide 22 text

余談:FirebaseStorageの処理をCombineで書く場合 引用リポジトリ: CombineFirebase https://github.com/urban-health-labs/CombineFirebase extension StorageReference { func putData(_ uploadData: Data, metadata: StorageMetadata? = nil) -> AnyPublisher { var task: StorageUploadTask? return Future { [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() 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) .sink(receiveCompletion: { completion in switch completion { case .finished: print("🙆 画像Upload処理が完了しました") case .failure(let error): print("🙅 画像Upload処理が失敗しました") } }) { metadata in print("🙆 画像Upload処理が成功しました") } .store(in: &cancelBag) ※Combineでの処理例

Slide 23

Slide 23 text

過去に自分がまとめたQiita記事 今回の登壇資料における補足事項としてお役に立てば幸いです UICollectionViewCompositionalLayout & DiffableDataSourceを利 用したUIとCombineを利用したMVVMパターンでのAPI通信関連処理と の組み合わせた実装の紹介とまとめ: https://qiita.com/fumiyasac@github/items/12165641c6569fde52ba Firebase Realtime Database & FireStorage で async/awaitを利用 した簡単な処理実装例の紹介: https://qiita.com/fumiyasac@github/items/22f539f554981da6afb0

Slide 24

Slide 24 text

まとめ 正直まだまだ自分も掴み切れてはいないが前向きに取り組んでいけそう。 1.Combine・AsyncStream・AsyncSequence・async/await等の事例に少しずつ触れてみる: もしCombinePublisherを中心とした処理を置き換えていく場合は、様々な処理の選択肢はあるが、現在開発中のモバイルアプリ の事情に合わせた選択ができればベターではある。簡単な事例〜少し難しいものまでの事例を知る事が1歩目かもしれないです。 2. CombinePublisherを置き換えるExtension等の活用も良いアイデアの1つ: できる所から少しずつ元の処理イメージを保ちながら進めるために、Extension等を活用しながら少しずつ置換する方針で進めて いく方が、特に規模がある場合は良さそうと感じました。(※特にBusinessLogicやDomainLogic等は実施しやすそう) 最近はUI実装関連の事が全然できていなくてすみません…。でもこういう事を考えるのも好きです。 3. async/awaitによる置き換えによって処理がシンプルにできた経験をヒントにしてみる: 今回ピックアップした、FirebaseStorageを利用した写真を複数枚アップロードする画面処理の事例はasync/awaitを利用する処 理における事例の様に、今まで面倒だと感じていた処理を便利にできる事例からヒントを得られる事もあると思います。

Slide 25

Slide 25 text

Thank you for listening !