Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

iOSアプリのSWIFT_STRICT_CONCURRENCYをcompleteにした

こんにちは。iOSアプリを開発している吉岡(rikusouda)です。

この記事は Gunosy Advent Calendar 2023 の 15 日目の記事です。昨日の記事はfujishiroさんの「tfaction を導入したら便利だった話」でした。

業務で開発している「auサービスToday」のiOSアプリでSwift Concurrencyを全面的に導入し、SWIFT_STRICT_CONCURRENCYをcompleteにすることができたのでその内容について紹介します。

概要

以前の記事でSwift Concurrencyを部分導入したことを紹介しました。

tech.gunosy.io

この時点ではAPI呼び出し部分に限って導入していました。

前回から今までに下記のようなことをしました。

  • いわゆるUseCaseやRepositoryをactor化
  • DispatchQueue.asyncによる非同期処理を廃止
  • nonisolated が要求されるDelegateメソッドの対応
  • Sendable対応

いわゆるUseCaseやRepositoryをactor化

auサービスTodayは、クリーンアーキテクチャで言われるようなUseCaseやRepositoryを使う設計をしています。これらのモジュールは画面表示に依存しない(UIKitやSwiftUIをimportしない)ので、MainActorにせずにactorにすることで手軽にメインスレッド以外で処理ができると考えました。

その結果下記のような問題が発生しました。

  • ViewModel、UseCase、Repository間ではSendableなデータ以外は受け渡しができない
    • すべてのデータをSendableに対応する必要がある
  • ViewModelからUseCaseを呼び出す部分をすべて非同期処理にする必要がある
    • 該当の処理をTaskにいれるか、asyncメソッドに変更する必要があり設計の調整が必要なこともある

この修正はかなり広範囲に渡るので、いったんはUseCaseやRepositoryはactorではなくMainActorにして、別スレッドの処理が必要なところだけ特別対応をする方法も効果的だと感じました。

DispatchQueue.asyncによる非同期処理を廃止

Swift Concurrencyが出るまでは、Main Thread以外で処理をする場合にはDispatchQueue.asyncを使う方法を主に用いていました。この方法はSwift ConcurrencyのActorの仕組みと相性が良くないように思ったので、全て置き換えました。

単発処理で完了待ちが必要なケース

別スレッドで時間のかかる処理を行ったあと、その完了を待ってから次の処理を行いたいケースです。

元の処理

func saveData(_ data: Int, completion: (Void) -> ()) {
    DispatchQueue.global().async {
        save(data)

        // 処理の完了を知らせるためにcompletionを呼び出す
        completion()
    }
}

対応後

func saveData(_ data: Int) async {
    // Task.valueをawaitすることで処理の完了を待つことができる
    await Task.detached {
        save(data)
    }.value
}

DispatchQueue.global.asyncをTask.detachedに置き換えることでSwift Concurrencyの仕組みで別スレッドでの実行をさせます。 また、Taskはawaitで完了待ちができるので、メソッド自体をasyncメソッドにすることでクロージャーで完了後の処理を書く必要もなくなりました。

asyncAfterで遅延実行するケース

対応前

func doSomething() {
    // asyncAfterで時間指定することで開始タイミングを遅らせる
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
        something()
    }
}

対応後

func doSomething(_ data: Int) {
   Task { @MainActor in
        // Task.sleepを使うことでスレッドをブロックせずに一定時間待つことができる
        try? await Task.sleep(nanoseconds: 100 * NSEC_PER_MSEC)
        something()
    }
}

Task.sleep(nanoseconds:) を使うことでスレッドをブロックせずに一定時間待つことができます。 またこの例だとメインスレッドで実行することを意図していたので、Taskの処理の先頭で @MainActor を書くことでメインスレッドで実行させています。

複数の処理を同じスレッドで処理させたいとき

同じDispatchQueueを使うことで、複数の処理を同じスレッドで実行したい場合があります

対応前

let dispatchQueue = DispatchQueue(label: "label")

func hoge() {
    dispatchQueue.async {
        // ここでなにかする
    }
}

func fuga() {
    dispatchQueue.async {
        // ここでなにかする
    }
}

func example() {
    hoge()
    fuga()
}

GlobalActorを定義し、そのActorを指定することでそれらは同じスレッドで動作するようになります。

対応後

// 独自のGlobalActorを定義
@globalActor
internal enum SampleActor {
    internal actor SetupActor {}

    internal static let shared = SetupActor()
}

@SampleActor
func hoge() {
    // ここでなにかする
}

@SampleActor
func fuga() {
    // ここでなにかする
}

func example() {
    // SampleActorを指定するとawaitなし(スレッド切り替えなし)で呼び出せる
    Task { @SampleActor in
        hoge()
        fuga()
    }

    // 通常のTaskからの呼び出しはawait(スレッド切り替え)が必要
    Task {
        await hoge()
        await fuga()
    }
}

nonisolated が要求されるDelegateメソッドの対応

2024/02/28追記: ハンドラ呼び出し系のDelegateメソッドをasync版のメソッドに置き換えるとiOS 15以下の環境でクラッシュが発生していたため切り戻しをしました。詳細はわかりませんがもし置き換えをする場合は念の為クラッシュを注視したほうが良いかもしれません。

WKNavigationDelegateのように、通常はViewControllerなどのMainActor部分に実装する事が多いけど、Delegate側でMainActor指定がなくnonisolatedでメソッドを実装する必要がある場合があります。

対応前

internal func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    self.isFinished = true
}

internal func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
    if self.isHoge {
        decisionHandler(.allow)
    } else {
        decisionHandler(.cancel)
    }
}

対応後

// メソッドにnonisolatedをつける
nonisolated internal func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    // selfがMainActorやactorの場合、nonisolatedメソッドからはselfへのアクセスが制限される
    // このメソッドはViewControllerに実装することが多く、内部のプロパティアクセスにはMainActorである必要があるので
    // Task { @MainActor in で内部の処理を実行する
    Task { @MainActor in
        self.isFinished = true
    }
}

// ハンドラ呼び出し系のメソッドはasync版が用意されているのでそれに置き換える

nonisolated internal func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy
    // selfがViewControllerでMainActorのためTask経由で値を取り出す
    let isHoge = await Task { @MainActor in self.isHoge }.value
    if isHoge {
        return .allow
    } else {
       return .cancel
    }
}

Sendable対応

actor指定のあるモジュールのメソッドをまたいで渡されるデータはすべてSendableでなければなりません。

Sendableにできないデータを受け渡したいケース

例として「Objective-Cのメソッドに、AnyのようなSendable対応出来ないデータを受け渡したい」ケースを考えてみます。 実際にはAnyをSendable扱いに出来ないのでコンパイルエラーとなってしまいますが、単にメソッドへパラメータを渡すだけで値の書き換えが無い(データ競合が発生しない)場合は、Sendableとして扱ってしまっても動作に問題ないはずです。

func callLegacyMethod() async {
    let params: [String: Any] = [
        "size": 48,
        "width": 2
    ]

    // ここでparamsがSendableではないのでコンパイラに怒られる
    await useCase.setParams(params)
}

そのようなケースでは@unchecked Sendableなstructに入れて受け渡しすることで、コンパイラにはSendableとして見せることができます。

// このような箱に入れることでコンパイラにはSendableとして扱わせることができます
internal struct UncheckedSendableContainer<T>: @unchecked Sendable {
    internal let value: T
}

func callLegacyMethod() async {
    let params: [String: Any] = [
        "size": 48,
        "text": "This type is String."
    ]
    let paramsContainer = UncheckedSendableContainer(value: params)
    await useCase.setParams(paramsContainer)
}

@unchecked Sendableは、動作が正しいことをプログラマが責任を持つ(コンパイラがチェックしてくれなくなる)ので使う場合には問題がないことをよく確認してください。

AnyPublisherを受け渡したいケース

アプリの中でなにかの状態をAnyPublisherとして監視したいケースがあります。 例えばauサービスTodayでは「既読記事のID一覧」を監視可能にしておき、それが更新されたときに記事リストのタイトルをグレー色で表示するようになっています。

そのためにRepositoryがAnyPublisherを公開して、各画面でそれを監視できるような仕組みにしていました。

しかしAnyPublisherがSendableではないのでそのままだとコンパイラに怒られてしまいます。

対応前

///////// Repository
internal protocol ArticleHistoryRepository: Actor {
    var readArticleIDs: AnyPublisher<Int64, Never> { get }
}

internal actor ArticleHistoryRepositoryImpl: ArticleHistoryRepository {
    private let readArticleIDsSubject = PassthroughSubject<Int64, Never>()

    internal var addedArticleID: AnyPublisher<Int64, Never> {
        readArticleIDsSubject.eraseToAnyPublisher()
    }
}

///////// UseCase
internal protocol ArticlesUseCase: Actor {
    var readArticleIDs: AnyPublisher<Int64, Never> { get async }
}

internal actor ArticleSearchArticlesUseCaseImpl: ArticleSearchArticlesUseCase {
    internal var addedArticleID: AnyPublisher<Int64, Never> {
        get async {
            // Actor境界をまたいでAnyPublisherにアクセスしているので怒られる
            await articleHistoryRepository.addedArticleID
        }
    }
}

///////// ViewModel
internal func subscribe() {
    Task {
        await useCase.readArticleIDs
            .sink { [weak self] readArticleIDs in
                guard let self else { return }
                Task {
                    // 必要な処理
                }
            }
            .store(in: &subscriptions)
    }
}

readArticleIDsのプロパティはViewModel(MainActor)で監視するのでプロパティをMainActorとすることでActor境界をまたがないようにして対応しました。

対応後

///////// Repository
internal protocol ArticleHistoryRepository: Actor {
    @MainActor var readArticleIDs: AnyPublisher<Int64, Never> { get }
}

internal actor ArticleHistoryRepositoryImpl: ArticleHistoryRepository {
    private let readArticleIDsSubject = PassthroughSubject<Int64, Never>()

    @MainActor internal var addedArticleID: AnyPublisher<Int64, Never> {
        readArticleIDsSubject.eraseToAnyPublisher()
    }
}

///////// UseCase
internal protocol ArticlesUseCase: Actor {
    @MainActor var readArticleIDs: AnyPublisher<Int64, Never> { get }
}

internal actor ArticleSearchArticlesUseCaseImpl: ArticleSearchArticlesUseCase {
    @MainActor internal var readArticleIDs: AnyPublisher<Int64, Never> {
        articleHistoryRepository.readArticleIDs
    }
}

///////// ViewModel
internal func subscribe() {
   useCase.readArticleIDs
        .sink { [weak self] readArticleIDs in
            guard let self else { return }
            Task {
                // 必要な処理
            }
        }
        .store(in: &subscriptions)
}

AsyncPublisherを使わなかった理由

iOS 15以降ではPublisher.valuesを使うことでawait可能な形式に変換ができます。

class Sample {
    var currentValues: [Int] = []
    var task: Task?

    deinit {
        // for await in を使うTaskは基本的に明示的なキャンセルが必要(忘れやすい)
        task?.cancel()
    }

    func subscribe() {
        task = Task {
            for await value in publisher.values {
                // selfが循環参照になるのでこのままだとキャンセルされない(ついついやりがち)
                self.currentValue.append(value)
            }
        }
    }
}

この形式だと下記の注意点があり、プログラマが注意を払わなければ簡単にselfの開放漏れが起こります。

  • for await inを使うTask内でselfにアクセスしない
  • deinitなどで明示的にTaskをcancelする必要がある

これを守るように強制するのはなかなか難しいと思いましたので、AnyPublisherを監視する方式としました。

まとめ

auサービスTodayのiOSアプリはSwift Concurrency対応を全面的に行い、SWIFT_STRICT_CONCURRENCYをcompleteにすることができました。 その過程で行ってきた変更について紹介しました。

この対応から下記の教訓が得られました。

  • UseCaseなどでMainActor以外のactorを使うと全面的にSendable対応が必要となり対応項目が多くなる
    • まずはすべてのモジュールをMainActorにするようにして、UseCaseやRepositoryはSendable対応しながら段階的にactor化するような対応が良いと思いました
  • AsyncSequenceをfor await inする場合はselfの開放漏れが発生しないようにプログラマに注意力が求められる

対応は大変でしたが、マルチスレッドにまつわる問題の多くをコンパイラが検出してくれるようになったのはかなり心強く思います。

明日はyamayuさんの「サードパーティ Cookie を使わない広告効果計測 ~Privacy Sandbox の Attribution Reporting API について~」についてです!お楽しみに!