Combineの非同期処理をSwift Concurrencyのasync/awaitで書き換えてみた

OGP

こんにちは、FAANS部の中島 (@burita083) です。2021年10月に中途入社し、FAANSのiOSアプリの開発を行なっています。

FAANSの由来は「Fashion Advisors are Neighbors」で、「ショップスタッフの効率的な販売をサポートするショップスタッフ専用ツール」です。現在正式リリースに向けて、WEARと連携したコーディネート投稿機能やその成果を確認できる機能など開発中です。

はじめに

FAANS iOSでは非同期処理にCombineを利用しています。Combine自体は本記事では詳しく解説をしませんが、RxSwiftを利用したことがある方なら特に違和感なく使えるかと思います。全く馴染みがない場合だと覚えることも多く、難しいところもあるかと思いますので、Swift Concurrencyを利用する方が理解しやすいかもしれません。ただし、ViewとPresenterの値のバインディング処理にも利用していますので、FAANS iOSでは当面、Combineも利用していくと思われます。

今回、async/awaitで書き換えた理由として、主に2つの理由があります。

  • 非同期処理をシンプルに書けるようになるため

Combineのコードは、コールバックで受け取る必要があり、コールバックの中でさらに別のAPIを叩く場面もあります。async/awaitで手続型のように書けるので、シンプルな記述が可能です。本記事で実際のコード例を元に説明します。

  • Swiftのアップデートに追従しつつ、チームとして継続的に新しい技術に触れることで成長していきたいため

URLSession等、Apple標準のAPIでasync/awaitがすでに使われており、今後も様々な機能がアップデートされます。キャッチアップした内容を業務で積極的に活用できる環境づくりをチームで心がけています。

本記事ではCombineでの非同期通信の処理に対しasync/awaitで書き換えたユースケースを紹介し、実装のポイント等、説明します。

目次

FAANS iOSの構成

Combineを使用している箇所を中心に図示しました。Combineは非同期処理の他に、View/PresenterのBindingで利用しております。 FAANS iOSの構成図

async/await概要

本記事で登場するasync/awaitのキーワードは以下の通りです。

  • async
  • await
  • async let
  • withCheckedThrowingContinuation
  • Task
  • Task Group
  • AsyncSequence

Swift Concurrencyでは新たな概念が色々出てきます。学習する際、何のキーワードについての説明かをマッピングしていくと理解しやすいです。Swift Concurrency チートシートで、キーワード毎に整理されていますので、とても参考になります。

async/awaitの基本として、asyncキーワードの理解が大事ですので説明します。一般的にコールバック関数をasync/awaitで書き換えると次のようなコードになります。

コールバックを返すコード

func downloadData(from url: URL, completion: @escaping (Data) -> Void)

downloadData(from: url) { data in
    // コールバックでdata を使う処理
}

async/await書き換え後のコード

func downloadData(from url: URL) async -> Data

// コールバックで受け取ることなく、data変数に結果が格納され、手続型のように後続処理をかける
let data = await downloadData(from: url)

API Clientをasync/awaitで書き換え

FAANS iOSではAPIを実行するクラス、API ClientでCombineを利用しており、AnyPublisher型を返す関数があります。これをSubscribeすることでコールバックが返ってきますので、withCheckedThrowingContinuationを利用し、コールバック関数をラップします。また、開発の効率性を高めるためにすでに実装済みのコードを再利用している箇所があり、Combineによるリクエストのインタフェースが存在している状況です。

// CombineのみのAPI通信ではこの関数を利用
func responsePublisher<Response>(for requestBuilder: RequestBuilder<Response>) -> AnyPublisher<Response, APIClientError> {
    requestBuilder.executeWithIDToken()
        .mapError { .init($0) }
        .eraseToAnyPublisher()
}

// 上記のCombineのコードをwithCheckedThrowingContinuationでラップするだけで、async/awaitの書き換えが可能
func response<Response>(for requestBuilder: RequestBuilder<Response>) async throws -> Response {
    let canceller = Canceller()
    return try await withTaskCancellationHandler { // Cancel処理の詳細は省く。

        // withCheckedThrowingContinuation使用箇所
        return try await withCheckedThrowingContinuation { continuation in
            if Task.isCancelled {
                continuation.resume(throwing: CancellationError())
                return
            }
            // responsePublisherの結果をSubscribeして、continuation.resumeに渡す。
            // 確実に1回、continuation.resumeを実行する必要がある。
            canceller.cancellable = responsePublisher(for: requestBuilder)
                .handleEvents(receiveCancel: {
                    continuation.resume(throwing: CancellationError())
                })
                .sink { completion in
                    switch completion {
                    case .failure(let error):
                        // エラーの場合はthrowingの方を利用
                        continuation.resume(throwing: error)
                    case .finished:
                        break
                    }
                } receiveValue: { value in
                    // returningの方を利用し、結果を渡す
                    continuation.resume(returning: value)
                }
        }
    } onCancel: {
        canceller.cancel()
    }
}

// 利用例
// Combineのまま
self.apiClient.responsePublisher(for: MemberAPI.getMember())
    .sink { [weak self] (completion) in
        switch completion {
        case .failure(let error):
      // エラー処理
        case .finished:
            break
        }
    } receiveValue: {[weak self] member in
    // 結果をコールバックで受け取る
    }
    .store(in: &cancellables)
self.apiClient.responsePublisher(for: MemberAPI.getMember())

// async/await書き換え後
do {
    // 結果がmemberに格納され、エラーの場合はcatchの方にいく
    // Combineではコールバックで結果を受け取る形になる
    let member = try await APIClient().response(for: MemberAPI.getMember())
catch {
    // エラー処理
}

responsePublisher関数は、引数のリクエストを実行し、AnyPublisher型を返します。これをwithCheckedThrowingContinuationのクロージャ内で利用し、subscribeした結果をcontinuation.resumeに渡します。

withCheckedThrowingContinuationではエラーを扱うので、エラー時の処理も忘れないようにしましょう。また、Cancel処理を行うためにwithTaskCancellationHandlerを利用していますが、詳細は本記事では省きます。

PresenterのCombineの利用箇所をasync/awaitで書き換え

次に実際にPresenterで使用するケースをみていきます。PresenterがAPI Clientを保持し、API通信を非同期で実行します。実際の業務でのユースケース毎に、Combineのコードをasync/awaitで書き換えて説明します。

Case 1: 固定数の複数のAPIリクエストを並行して実行

1つ目の例として固定数の複数のAPI、ここでは3個のAPIを利用し、結果を後続で利用する例を説明します。3個のAPIを並行して実行し、それぞれのリクエスト結果を待ち合わせ、全てのリクエストが完了した段階で後続処理に進みます。

Combineによる実装

Combineでは、Zipを利用することで、複数のAPIリクエスト処理の結果をまとめて受け取れます。また、リクエストの結果を利用してさらに別のAPIリクエストを行う場合も多いです。慣れていないと理解が少し難しいという欠点があるものの、Combineでやりたいことは特に問題なく実装できています。今回は、Publishers.Zip3を利用し、3個のAPIのリクエストを並行して実行する例を示します。

Combineを利用した実装 (実際のコードはもっと長いですが簡単のためにコメントで補足しております)

private func fetchItems() {
    let coordinateDetailZip = Publishers.Zip3(
        apiClient.responsePublisher(for: CoordinateAPI.getCoordinateById(coordinateId: String(self.coordinate.id)))
        apiClient.responsePublisher(for: CoordinateAPI.getCoordinatesSales(coordinateIds: String(self.coordinate.id), ecMallKey: .zozotown))
        apiClient.responsePublisher(for: CoordinateItemAPI.getCoordinateItems(coordinateId: String(self.coordinate.id)))
    )

    // リクエストの結果をコールバックで受け取る
    coordinateDetailZip
        .flatMap { [weak self] coordinateWrapper, coordinateSales, coordinateItems -> AnyPublisher<GetCoordinateReviewResponse, RepositoryError> in
        // 3つのリクエストの結果を利用し、必要な型を返す
        }
        .receive(on: DispatchQueue.main)
        .sink { [weak self] completion in
            switch completion {
            case .finished:
                break
            case .failure(let error):
            // エラー処理
                self?.alertMessage.value = error.localizedDescription
            }
        } receiveValue: { [weak self] coordinateReview in
            guard let self = self else { return }
        // flatMapの結果を利用
        }
        .store(in: &cancellables)
}

async/awaitで書き換え

Publishers.Zip3を利用して固定数(3個)のリクエストを行いましたが、async/awaitではどのように書けるのでしょうか。まずはコードで説明します。

// Presenterに実装されている同期関数
// PresenterはapiClientを保持
private func fetchItems() {
        // Task キーワードを利用することで、async/awaitの非同期関数を同期関数の中で呼び出せる
    Task { [weak self] in
        guard let self = self else { return }

        // async letで3つのAPIリクエストを手続き型のように記述
        // async letのタイミングでリクエストは実行されるが、処理の完了自体は待たず次の行へ
        async let coordinateResponse = self.apiClient
            .response(for: CoordinateAPI.getCoordinateById(coordinateId: String(self.coordinate.id)))
        async let coordinateSalesResponse = self.apiClient
            .response(for: CoordinateAPI.getCoordinatesSales(coordinateIds: String(self.coordinate.id), ecMallKey: .zozotown))
        async let coordinateItemsResponse = self.apiClient
            .response(for: CoordinateItemAPI.getCoordinateItems(coordinateId: String(self.coordinate.id)))
        do {
            // awaitキーワードで、処理の完了を待つ(リクエスト自体は3つ並行で実施済)
            let coordinateWrapper = try await coordinateResponse
            let coordinateSales = try await coordinateSalesResponse
            let coordinateItems = try await coordinateItemsResponse
            
            // 以下、3つの結果を利用。特にネスト等なく、手続き型のように書ける

        } catch { // エラー処理
            self.alertMessage.value = error.localizedDescription
        }
    }
}

async letを利用することで複数個のAPIリクエストを並行して実行できます。リクエストの結果はasync letの行では受け取らず、awaitの箇所で受け取ることになります。 つまり複数の非同期処理を並行に行いますが、結果を使いたいタイミングの時にリクエストが完了されていることを、await キーワードによって保証されています。

コールバックで完了を受け取る必要がなく、後続処理で結果を利用して別のAPIリクエストを実施するにしてもネストをさせることなく、手続き型の様に書けるのでコードが複雑になることを防げます。

また、並行処理ではなく、順番に実行したい場合の例も記載します。async letとの対比で理解しておくと良いですし、async/awaitの基本のコード例としては順番に実行する例も多いです。

private func fetchItems() {
    Task { [weak self] in
        guard let self = self else { return }
    
    do {
          // async letを使わずに、上から順に結果を受け取る。
          // await キーワードをそれぞれのリクエストで記載。
          // 1つのリクエストが終われば次のリクエスト、という形になる。
          // 当然全ての処理の完了は並行処理に比べて遅くなる。
            let coordinateResponse = try await self.apiClient
              .response(for: CoordinateAPI.getCoordinateById(coordinateId: String(self.coordinate.id)))
            let coordinateSalesResponse = try await self.apiClient
              .response(for: CoordinateAPI.getCoordinatesSales(coordinateIds: String(self.coordinate.id), ecMallKey: .zozotown))
            let coordinateItemsResponse = try await self.apiClient
              .response(for: CoordinateItemAPI.getCoordinateItems(coordinateId: String(self.coordinate.id)))

          // 結果を変数に格納
            let coordinateWrapper = coordinateResponse.coordinateWrapper
            let coordinateSales = coordinateSalesResponse.coordinateSalesAmounts
            let coordinateItems = coordinateItemsResponse.coordinateItems

          // 以下、3つの結果を利用。特にネスト等なく、手続き型のように書ける

        } catch { // エラー処理
            self.alertMessage.value = error.localizedDescription
        }
    }
}

Case 2: 数が可変の複数のリクエスト(1〜5個)

Case 1では固定数のリクエストの実装例を示しました。基本的にその対応で問題なさそうではありますが、Case 2で示す以下の仕様が出てきました。

仕様: 端末に保存されている写真の中から、1〜5枚までの写真をユーザーが選択し、選んだ順番に写真を投稿。

サーバーへのリクエストの手順は次の通りです。

  1. ユーザーが1〜5枚まで写真を選択
  2. 保存先のURLを1枚ごとにサーバーが発行(1〜5回リクエストを送る)
  3. 2で取得したURLのうち、1つ目のURLをメイン、それ以外をサブ(2〜5つ目のURLの配列)で分けて、それぞれリクエストパラメータとして利用する
  4. 生成したパラメータを元に投稿のリスエストを実施

ここでポイントとなるのが、ユーザーが任意の枚数の写真を選択できるという点で、Case 1のようにZip3等で固定の数のリクエストにはなりません。選択枚数が2枚の場合はZip、3枚の場合はZip3と場合分けをすることで、Case1のように固定数で書けそうですが、同じようなコードを何回も書くことになりそうです。

Combineでの書き方

PublishersにZipManyを追加し、任意の個数のリクエストの結果を配列で受け取るようにして1〜5個のどのパターンにも対応できるようにしました。ZipManyに関してはHow to zip more than 4 publishers)を参考に実装しました。

Combineを利用した実装 (実際のコードはもっと長いですが簡単のために一部省略し、コメントで補足しております)

// ユーザーが選択した写真を格納した配列(inputCoordinate.selectedImages)から、アップロードURLを生成するリクエスト(AnyPublisher型)
let requests: [AnyPublisher<PresignedUrlResponse, Error>] = inputCoordinate.selectedImages.map { imageUploadUseCase.uploadedPresignedURLPublisher(jpegData: $0.jpegData, type: .image) }
if requests.isEmpty { return }

// ZipManyにrequestsを渡し、結果は配列で受け取る
Publishers.ZipMany(requests)
    .flatMap { [weak self] presignedURLResponse -> AnyPublisher<CoordinateResponse, Error> in
        guard let self = self else {
            return Empty(completeImmediately: true).eraseToAnyPublisher()
        }
    // presignedURLResponseには、リクエストの結果が配列で格納されているので、
    // 要素数に関係なく、配列の1番目をメイン、それ以外をサブに、filterできる。
    }
    .receive(on: DispatchQueue.main)
    .sink { [weak self] (completion) in
        self?.isLoading.value = false
        switch completion {
        case .failure(let error):
            // エラー処理
            self?.alertMessage.value = error.localizedDescription
        case .finished:
            break
        }
    } receiveValue: { [weak self] coordinate in
        // flatMapで変換した結果を利用
    }.store(in: &cancellables)

async/awaitで書き換え

async/awaitで書き換えましょう。Case 1で固定数の場合はasync letを複数個書くことで対処しましたが、可変数の場合はどのように実装したらいいでしょうか。具体的にコードを見てみましょう。

async/awaitで書き換えたコード (実際のコードはもっと長いですが簡単のために一部省略し、コメントで補足しております)

// Taskで囲むことで、同期関数のコードの中で利用可能になる
Task { [weak self] in
    // ユーザーが選択した写真が配列に格納されており、その配列の要素の数で初期化
    var images: [CoordinateImage] = Array(repeating: .init(url: ""), count: inputCoordinate.selectedImages.count)
    do {
        guard let self = self else { return }

        try await withThrowingTaskGroup(of: (Int, PresignedUrlResponse).self) { group in
            for image in inputCoordinate.selectedImages.enumerated() {
                group.addTask { // addTaskで並行に実行したい非同期処理をループ毎に登録
                    return (image.offset,
                            try await self.apiClient.response(for: ImageAPI.issuePresignedURL(presignedUrlRequest: .init(objectType: .image)))
                            )
                }
            }
            // for try await in でTask Groupで実施した結果を受け取る
            for try await (index, presignedURL) in group {
                let coordinateRequestImages = CoordinateImage(url: presignedURL.downloadUrl)
                    // 結果の順序を維持したいので、Task Groupの返り値のindexを、
                    // 要素数を指定して初期化した配列(images)のindexに代入
                    // appendにすると、結果が終わった順に格納されてしまう
                    images[index] = coordinateRequestImages
                }
            }

        // 後続処理でimagesを利用してリクエストを行う
        }
    } catch { // エラー処理
    }
}

Taskについて

viewDidLoad等の同期関数からasync/awaitの関数を呼び出せないので、Task {}を利用して呼び出します。上記の例では、Presenterが保持する同期関数の中でTaskを利用し、async/awaitの関数を呼び出して結果を返します。Presenterを保持するViewControllerは変わらずそのPresenterの同期関数を呼び出すだけなので特に改修することはありません。Presenterが保持するasync/await対応の関数をViewController側で呼び出す場合は、ViewController側にTaskを書いて、その関数を呼び出す形になります。また、Presenter自体は同期的に扱う必要があり、@MainActorを指定する必要がありますが、詳細は省きます。

並行実行のTask Groupについて

ユーザーの選択した写真が格納された配列(inputCoordinate.selectedImages)は、1〜5個の要素を持ちます。もし並行に実行しないのであれば、for文の中でリクエスト処理を実行し、awaitでリクエスト毎に結果を待つことで手続き型のfor文のように書けます。

コードイメージ

for image in inputCoordinate.selectedImages { // 要素数は1〜5の可変で可能)
    let response = try await APIリクエスト(image)
    // responseを利用
}

しかしこの書き方ですと、ループ毎にリクエストの結果を待つことになり、時間がかかってしまいます。Combineと同様に並行でリクエストを実行し、結果を待ち合わせるためにはどうすれば良いでしょうか。

それにはTask Groupを利用します。Task GroupにはwithTaskGroupとエラー対応の可能なwithThrowingTaskGroupがあります。今回はエラー処理も実施していますので、withThrowingTaskGroupを使います。

Task Groupの実装手順は以下の通りです。

  1. withThrowingTaskGroupの引数に結果の型を定義
  2. クロージャの引数にタスクグループを受け取る(ここではgroup)
  3. forループ内でgroup.addTaskを実行し、1で指定した型の結果を返す(addTaskで並行に実行したい非同期処理をループ毎に登録するイメージ)
  4. AsyncSequenceのfor try await in groupで結果を受け取る

Task Groupのコード抜粋

// 引数にaddTaskで返す型を指定。ここでは配列のindexとリクエストの結果を指定。
try await withThrowingTaskGroup(of: (Int, PresignedUrlResponse).self) { group in
    for image in inputCoordinate.selectedImages.enumerated() {
        group.addTask { // addTaskで並行に実行したい非同期処理をループ毎に登録
            return (image.offset, // 配列のindexを順序の維持に利用するために返す
                    try await self.apiClient.response(for: ImageAPI.issuePresignedURL(wearConnectPresignedUrlRequest: .init(objectType: .image)))
                    )
        }
    }
}

ここで注意すべきことは、それぞれの非同期処理の完了タイミングが選択した写真の順にならないので、配列のindexも結果に含めます。

for try await in groupのコード抜粋

// AsyncSequenceのfor try await in でTask Groupで実行した結果を非同期的に受け取る
for try await (index, presignedURL) in group {
    let coordinateRequestImages = CoordinateImage(url: presignedURL.downloadUrl)
        // 結果の順序を維持したいので、Task Groupの返り値のindexを、
        // 要素数を指定して初期化した配列(images)のindexに代入
        // appendにすると、結果が終わった順に格納されてしまう
        images[index] = coordinateRequestImages
    }
}

以上のように、Task Groupを利用することで可変の要素数のリクエストを並行に実行できました。将来的に6つ以上のリクエストが必要になっても特に大きな変更をすることなく対応が可能です。

まとめ

今までCombineでの書き方に慣れていましたが、async/awaitのほうが少ない記述量で、直感的に書けると思いました。まだ書き換えが全ての箇所でできていないので、これからも実装していく中で、知見を貯めて発信できたらと思います。

さいごに

ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。カジュアル面談もお待ちしております。

corp.zozo.com

hrmos.co

カテゴリー