📑

Swift Concurrencyの今のところのベストプラクティス

2023/03/15に公開

async/awaitこと、Swift Concurrency。
遅ればせながら最近コードベースをConcurrency対応しております。
「ゆーて非同期処理にasync/awaitつけるだけっしょ? 余裕、余裕」ぐらいに思ってたんですが、実際やってみるとハマりまくりました。

一ヶ月ほど格闘したので、その中でたどり着いた自分なりのベストプラクティスについて書きます。
間違ってるところはたくさんあると思うので、もっといいやり方知ってる人がいたら、是非にコメント欲しいです。

想定読者

  • Swift Concurrencyの基礎知識はある
  • 雰囲気はわかっている
  • がっつり書いたことはない
  • これから使おうとしているが、実際どうしたらいいかわからない
  • 今まさに移行を進めているが、自分のやり方が正しいかわからない
  • なお、SwiftUIでのConcurrencyは扱いません。UIKitの話です

ドキュメント

Swift Concurrencyの正しい理解については、公式ドキュメントや各種解説を参照してください。

https://zenn.dev/st43/scraps/b39b6e69361100

↑僕が漁ったドキュメントのリンクは、スクラップに置いてあります。

ざっくり理解

async/awaitの本質は、「Suspension point(停止点)を示す」ということらしいです。

asyncは「停止させることができる関数」であることを示します。
awaitは「停止する可能性がある」ことを示すようです。
「可能性がある」(may suspend)というのは個人的には意外な表現でしたが、確かにawaitがついていても実際は同期的に実行されることもあります。
たとえば、以下の非同期メソッドをawaitで待った場合、

func method() async {
    guard isError {
        return
    }
    await asyncMethod()
}

isErrorがtrueで、早期退出に入った場合は同期的に終了します。
この関係はtrhowsとtryの関係に似ています。
「エラーを投げる可能性がある」ので、tryをつけて結果を待つけれど、エラーにならなければ、普通に実行されますね。

async関数を扱うパターン

非同期のメソッドを呼び出すとき、

  • 非同期処理の完了を待つ
  • 非同期処理の完了を待たない
  • (バックグラウンドスレッドとかで裏で動いていて欲しい)

の2パターンあると思います。
awaitをつけることで待つことになりますが、待たなくていいときはどうすればいいでしょう?

Concurrencyの便利技で、Taskを使うと、別Actorで処理が実行されます。
(正確には上位コンテキストを引き継ぐことがあります。詳しく知りたい方はこちらの注を読んでください)

Task {
    await function()
}

場合によってはUI処理が発生するからメインスレッド(メインActor)でやりたいときもあるでしょう。
(たとえばエラーが返ってきたときはアラートを出したい、など)
その場合は下記でメインスレッドで動作させられます。

Task { @MainActor in
    // …
}

また非同期処理を並列で実行させて、全部終わったら処理する、みたいな処理も簡単に書けます。

async let firstPhoto = downloadPhoto(named: photoNames[0])
async let secondPhoto = downloadPhoto(named: photoNames[1])
async let thirdPhoto = downloadPhoto(named: photoNames[2])

let photos = await [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

これ便利そうなんですが、僕はまだ実際に使ったことはありません。

以上の内容の詳しい説明は↓に譲ります。

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/

実践と方針

とりあえず箇条書きで方針を書きます。
アーキテクチャーはVIPER想定なので、PresenterとかInteractorとかは適宜読みかえてください。

  • ViewControllerは@MainActorついてるのでつける必要なし
    • async関数呼ぶときはTask { await presenter.method() }になる
    • viewDidLoad()はasync化できないので)
  • Presenterに@MainActorつける
    • protocolにつければ継承してるクラスにも引き継がれる
    • apiコールしてるメソッドはasync関数にして、awaitで待つ
  • InteractorにもConcurrency用のメソッドをつくる
  • Routerは移行期ならメソッドごとに@MainActorつける方が良さそう
  • テストクラスも@MainActorつける
  • awaitする必要があるテストケースはasync throwsをメソッドにつける

View層(ViewController)

UIViewControllerが@MainActorついてるので、基本的には全部メインスレッドで処理することになります。
View層は基本的にasync関数を使うことはあっても、自身が持つことはない、という方針がよさそうです。
というのは、ライフサイクルメソッド(viewDidLoad()など)がasync化できないので、他のレイヤーでやってるようなasync関数のチェーンがそこで切れることになります。

基本的には非同期処理の最後の受け手はUIになると思うので、Viewはasync関数のユーザー側になると思います。

Task {
    await presenter.fetchSomething()
}

だいたいこんな感じで、presenterに非同期処理を依頼します。
非同期処理の完了後はdelegate経由で処理が続きます。

Presenter層

このレイヤーがちょっと議論ポイントになると思います。
クラス全体に@MainActorをつけるのがいいと今は考えています。

WWDCやshizさんの資料など、「ViewModelには@MainActorをつけるのが良い」という記述があります。
これを最初見たとき、僕はちょっと乱暴じゃないかと感じました。
疑似コードで言うと、↓これと同じじゃない? と思いました。

DispatchQueue.main.async {
    class SamplePresenter {}
}

ただ実際にコード書いてみると、考えが反転しました。
というのは、コードがTask { @MainActor in // … }だらけになったからです。
更に、MainActor化忘れてUI処理に行ったときは容赦なくメインスレッド違反でクラッシュするので、リスクにもなりました。

Concurrency化する前は、どのスレッドで実行されてるかは、明示的に書いているとき以外は曖昧でした。
UI処理は落ちるからメインスレッドにするとか、非同期処理はバックグラウンドスレッドにするとかぐらいの意識はありましたが、Presenter層は正直かなり曖昧です。

なのですが、全体を@MainActorにしてしまって、API呼び出しが存在する処理はasync関数にする方針が筋が良さそうです。
懸念としては今までメインスレッドじゃなかった処理をメインスレッドに入れてしまって、オーバーヘッドが出るぐらいなんですが、今のところそのオーバーヘッドは無視できる範囲です。
オーバーヘッド発生するとしたらAPI呼び出しのとこが大きいので、そこだけ切り出せてれば良いのかなと思います。

func method() async {
    // API呼び出し前のローディング表示など
    do {
        let response = try await sampleInteractor.fetchSomething()
        // 成功時
    } catch {
        // 失敗時
    }
    // ローディング表示の非表示など
}

Interactor層

API呼び出しのConcurrency化について書くとちょっと長くなるので、一言だけ書きます。
withCheckedThrowingContinuationを使って、いい感じにメソッドつくってください。

let url: URL

func apiCall(completionHandler: @escaping (String?, Error?) -> Void) {} 
func fetchSomething() async throws -> String {
    return try await withCheckedThrowingContinuation { continuation in
        apiCall() { response, error in
            if let response {
                continuation.resume(returning: response)
            } else if let error {
                continuation.resume(throwing: error)
            }
        }
    }
}

詳細はこちらの記事に譲ります。

https://blog.personal-factory.com/2022/01/23/how-to-use-async-await-since-swift5_5/

protocol切ってると@MainActorは継承される

親クラスに@MainActorついてるとそれも継承されるというのは、なんとなく自然ですが、protocolでも同じ仕組みになります。

@MainActor protocol Router {
    // …
}

// ↓@MainActor扱い
class SampleRouter: Router {
    // …
}

なので、普通はprotocolに付与するのがいいと思います。

Router層

Concurrency対応すると、基本的にRouterも@MainActorだらけになってくるので、数が少ないのであればRouter全体に@MainActorつけちゃっていいと思います。
ただ僕の場合だと、画面遷移メソッドが100個以上あって、軽率に全体を@MainActorにしちゃうと、Routerの呼び出し元もまた@MainActorが指定されてないとコンパイルエラーになるので、
Concurrency対応が完了したものから順に画面遷移メソッドを修正するようにしています。

テストコード

Swift Concurrencyのテストコードについてです。
テストコードは全然想像がつかなくて悩んでたところ、下記のWWDC21の動画で言及されていました。

https://developer.apple.com/videos/play/wwdc2021/10132

シンプルに、テストメソッドにasync throwsを付与して、awaitするだけでOKでした。
正常系のテストケースならthrowsも要らないですね。

あと僕はテストクラス全体に@MainActorをつけています。
Presenterに@MainActorつけたために、テストクラスにもつけないと使えなくなります。
これはさすがにオーバーヘッドちょっとあるような気もするんですが、他に良い手も思い浮かばないのでつけてます。

まとめ

この記事が何かのお役に立てば幸いです。
冒頭にも書きましたが、この内容は現在進行形で試行錯誤している内容なので、知見のある方はコメント頂けたら更に幸いです。

(了)

Discussion