Swift 6移行対応はこう乗り切った!cluster iOSアプリの事例

はじめに

こんにちは。クラスター株式会社でソフトウェアエンジニアをしているTAATです。

Swift 6がリリースされてもうすぐ1年、最近は移行対応の知見や事例も多く見かけるようになりました。clusterでも去年末からSwift 6移行対応の計画を立て始め、半年以上かけて移行作業を進めてきました。そして6月末、ついにSwift 6への完全移行を達成しました!

この記事では、clusterのiOSアプリにおけるSwift 6移行対応の実践例を紹介します。どのように計画を立て、どんな課題に直面し、どう解決したのか。そして移行によって得られたメリットについて、具体的な事例を交えながらお伝えします。

なぜSwift 6移行対応するのか?

Swift 6はXcode 16とともにリリースされたSwiftの最新メジャーバージョンで、Strict Concurrency Checkingによる完全なデータ競合の防止が特徴です。これにより、マルチスレッド環境でデータ競合を引き起こす可能性のあるコードはビルド時点でエラーとなり、実行時のクラッシュや予期しない動作を未然に防ぐことができます。

コードの安全性向上は開発効率の改善にも繋がり、プロダクトの品質向上が期待できます。一方で、既存コードの多くがビルドエラーとなる可能性があり、プロジェクト全体に影響が及ぶ大規模な移行作業となることが予想されました。

そのため、まずは情報収集と移行計画を立てるところから着手し、段階的な移行戦略を検討することにしました。

ちなみに、WWDC24のSwift 6へのアプリの移行というセッションで移行方法について紹介されているので、こちらも見ておくと良さそうです。

Strict Concurrency Checkingで並行処理の安全性を厳密にチェックさせる

Strict Concurrency CheckingはSwift 6で導入された厳密な並行処理の安全性チェックですが、Xcode 14からBuild Settingsでそのチェックレベルを設定できます。

  • Minimal: 明示的にSendableを採用している箇所で、Sendable制約とアクター隔離をチェック
  • Targeted: Minimalに加え、暗黙的にSendableが採用されている箇所でもSendable制約とアクター隔離をチェック
  • Complete: モジュール全体を通してSendable制約とアクター隔離をチェック

まずはこの設定を各ターゲットでCompleteにできるように、必要に応じてSendable準拠、UI関連の処理には@MainActorを付与するなどしてビルドエラーを解消しました。幸い、clusterではメインターゲットでも少しの修正でCompleteにすることができました。

Upcoming Featureで今後の機能を先取りする

Swift 6移行対応のゴールはSwift Language VersionをSwift 6にすることですが、いきなりSwift 5から変更すると大量のビルドエラーになってしまう可能性があるので、まずはSwift 5の状態で、Swift 6にするとどのようなエラーになるのかを把握して、段階的に対応していく必要があります。

Swift 5.8から「将来追加予定の言語改善への段階的な適用」を行うためのコンパイラフラグ-enable-upcoming-featureがSE-0362で追加されました。これを使ってSwift 6以降での変更で影響が大きい機能を先取りして、ビルドエラーにはならないが警告として出すことで、対象を明らかにして移行計画を立てやすくなります。

# 警告例
Sending 'self.repository' risks causing data races; this is an error in the Swift 6 language mode

Xcodeプロジェクトの場合、Build SettingsのSwift Compiler - Custom Flags > Other Swift Flagsに、-enable-upcoming-feature 機能名の形で追加することで有効にできます。また、Swift Packageの場合でも、swiftSettingsでenableUpcomingFeatureで指定できます。

clusterではこちらの記事を参考に、以下の機能を各ターゲットで有効にしていましたが、有効にした直後はビルドでの警告数は約1200増えてしまいました。

zenn.dev

PoCで対応方針を決める

Upcoming Featureフラグで警告を出したら、次に具体的な対応方針を決めていきます。Swift 6移行対応は影響範囲が非常に広く、途中で設計上の問題が発生すると大きな手戻りになってしまうので、まずはPoC (Proof of Concept)で対応方針に設計上の問題がないかを検証することにしました。

少し前の記事になりますが、クラスターでは技術設計の際にdesign docと呼ばれるドキュメントを書く文化があります。design docでは解決したい問題とその背景、実現方法の概要や詳細設計、代替案などを書いて、レビューを通してメンバー間で設計について合意を行います。

tech-blog.cluster.mu

clusterでは現状Coordinator + MVVMパターンを採用していますが、各レイヤー(ApiClient, Repository, ViewModelなど)でどのような対応をすれば警告が出なくなるかを検討し、サンプルプロジェクトを作って実現可能性について検証を行いました。なお、アーキテクチャについてはこちらの記事をご参照いただきたいですが、SwiftUI移行に伴いリアーキテクチャも検討したいです。

qiita.com

PoCの結果をもとにdesign docを書いて、メンバーにレビューしてもらって、具体的な移行対応の方針を決めました。ただし、このドキュメントは全てのSwift 6関連の警告に対処できるものではなく、実際に移行対応を進めていく中で例外も出てくるので、その際は都度判断を行うことにしました。

レイヤーごとに移行を行う

各レイヤーの中でも、まずは修正しやすいNon-user visibleなApiClientから移行を始めました。ApiClientではOpenAPI Generatorで生成されたAPIを呼び出すラッパーのようになっていて、今まではクロージャーでレスポンスを返していたメソッドも多く、さらに認証用ヘッダーの取得処理のクロージャーの中にAPIリクエストがネストしていたので、Sendableについての警告が出ていました。

func getSomething(completion: @escaping ((_ result: ApiResponse<SomeResponse>) -> Void)) {
    getAuthorizationHeaderValue { authrorizationHeaderValue in
        Task {
            do {
                let response = try await SomeAPI.getSomething(
                    ...
                    authorization: authorizationHeaderValue,
                    ...
                )
                completion(handleSuccessResponse(response))
            } catch {
                completion(handleErrorResponse(error))
            }
        }
    }
}

func getAuthorizationHeaderValue(_ callback: @escaping ((_ authrorizationHeaderValue: String) -> Void)) {
    callback("\(token)")
}

これをクロージャーではなくasyncメソッドに変更して、さらに認証用ヘッダーの取得処理もasyncにすることで、処理がシンプルになり警告も出なくなりました。非同期処理でクロージャーを使っている場合は、どんどんasync/awaitに置き換えていきましょう。

func getSomething() async -> ApiResponse<SomeResponse> {
    let authorizationHeaderValue = await getAuthorizationHeaderValueAsync()
    do {
        let response = try await SomeAPI.getSomething(
            ...
            authorization: authorizationHeaderValue,
            ...
        )
        return handleSuccessResponse(response)
    } catch {
        return handleErrorResponse(error)
    }
}

medium.com

しかし、このApiClientはその名の通り全てのAPIが関連しているので、移行作業もかなりの物量になってしまいましたが、複数のメンバーで分担して短期間で集中的に対応を行いました。

次にApiClientを呼び出すUseCase, Repositoryレイヤーですが、移行前はfinal classで宣言されていて、@MainActorなViewModelがそのインスタンスを参照していたので、ここでもSendable関連の警告が出ていました。

protocol SomeRepositoryProtocol {
    func fetch() async throws -> SomeType
}

final class SomeRepository: SomeRepositoryProtocol {
    func fetch() async throws -> SomeType { ... }
}

UseCase, Repositoryレイヤーは、基本的に@MainActorである必要はなく、それ自身のアクター領域で処理を行えばいいので、今回はfinal classからactorに変更しました。しかし、現状Repositoryの中には、CombineでViewModelに値の変更を通知をしているプロパティもあり、actorにするとNon-sendable type 'CurrentValueSubject<SomeType, Never> ...のような警告が出てしまいました。

protocol SomeRepositoryProtocol: Actor {
    var publisher: AnyPublisher<SomeType, Never> { get }
    func fetch() async throws -> SomeType
}

actor SomeRepository: SomeRepositoryProtocol {
    private let subject = PassthroughSubject<SomeType, Never>()
    var publisher: AnyPublisher<SomeType, Never> {
        return subject.eraseToAnyPublisher()
    }
    
    func fetch() async throws -> SomeType {
        ...
        subject.send(...) // Non-sendable type 'CurrentValueSubject<SomeType, Never> ...
    }
}

AsyncStreamやAsyncChannelを使った方法への置き換えも検討しましたが、複数から購読された場合にどれかにしか通知されない挙動になっていたので断念しました。

zenn.dev

また、PublisherをAsyncStreamに変換するextensionを追加して、CombineのsubjectをAsyncStreamに変換して購読すれば複数購読もできそうですが、AnyCancellableを強制的にSendableに準拠させる必要が出てきてしまいます。

forums.swift.org

そこで、今回はPublisherに@MainActorを付与することで警告を解消させ、AsyncStreamやAsyncChannelへの移行は別途検討することにしました。

protocol SomeRepositoryProtocol: Actor {
    @MainActor var publisher: AnyPublisher<SomeType, Never> { get }
    
    func fetch() async throws -> SomeType
}

actor SomeRepository: SomeRepositoryProtocol {
    @MainActor private let subject = PassthroughSubject<SomeType, Never>()
    @MainActor var publisher: AnyPublisher<SomeType, Never> {
        return subject.eraseToAnyPublisher()
    }
    
    func fetch() async throws -> SomeType {
        ...
        subject.send(...)
    }
}

思わぬ落とし穴

Actor化によるモックへの影響

clusterではmockoloを使って、UseCase, Repositoryのモックを生成して、ViewModelのテストで使っていますが、今回はfinal classからactorに変更したため、モックで生成されたhandlerを外部から設定しようとすると、アクター境界を超えるようになってしまったので、別途extensionでsetterを追加してアクター内部でhandlerを更新できるようにしました。

actor SomeRepositoryProtocolMock: SomeRepositoryProtocol {
    init() { }

    private(set) var someCallCount = 0
    var someArgValues = [UUID]()
    var someHandler: ((UUID?) async throws -> SomeType)?
}

extension SomeRepositoryProtocolMock {
    func setSomeHandler(_ handler: @Sendable @escaping (UUID?) async throws -> SomeType) {
        someHandler = handler
    }
}

Actor化によるテストコードへの影響

UseCase, Repositoryのactor化はXCTestにも影響が出ていて、上記のhandlerは解決できそうですが、callCountへの参照でもアクター境界を越えるようになってしまいました。無理にXCTestのメンテナンスを続けたいわけではないので、これを機にSwift Testingへの全面移行を決めました。

XCTestではtest_Aの場合_Bとなる()のように条件別にメソッドを分けていましたが、Swift Testingではパラメトライズトテストができるので、重複したコードがなくなって非常にシンプルで分かりやすい書き方になりました。

import XCTest

@MainActor
class SomeViewModelTests: XCTestCase {
    override func setUp() { ... }
    
    func test_fetch() async throws {
        await XCTContext.runActivityAsync(named: "条件1_期待値1") { _ in ... }
        await XCTContext.runActivityAsync(named: "条件2_期待値2") { _ in ... }
    }
}
import Testing

@MainActor
struct SomeViewModelTests {
    init() { ... }
    
    @Test(arguments: [
      (input1: ..., expected1: ...)
      (input2: ..., expected2: ...)
  ])
    func test_fetch(input: InputType, expected: ExpectedType) async throws { ... }
}

Protocol Bufferで生成されたコードの警告対応

clusterではProtocol Bufferを使ってサーバー(リアルタイム同期の部分)やUnity領域とデータをやり取りしていますが、swift-protobufで自動生成されたコードでSwift 6でエラーになるとの警告が複数出ていました。

自動生成されたコードに警告が出ると、都度手動で修正するわけにもいきません。別パッケージに切り出して@preconcurrency importする方法もありますが、今回はswift-protobufを最新バージョンまでアップデートすることで解消させ、ついでにパッケージ分離も行いました。

Swift Testingへの移行やswift-protobufのアップデート・リファクタリングなど、Swift 6への移行対応で、既存の構成を見直さざるを得ない箇所も出てくる可能性があるので、可能であれば負債の返済も検討・実施できると良いと思います。

QA

これだけ大規模な移行対応で、QAも大変なのでは?と思われるかもしれませんが、clusterではFeature Flagを導入していて、ApiClientの移行対応時は細かくFeature Flagを定義して、フラグが有効の場合のみ新しいApiClientの処理を呼ぶようにしていましたが、これにより、問題があればすぐにフラグを無効にすれば、QAやリリースへの影響を最小限に抑えることができます。

また、clusterでは毎週QAやリリースを行っており、UseCaesやRepositoryのActor化などフラグ分岐ができない対応については、実装時やレビューでの動作確認に加え、毎週のQAで細かく影響範囲の検証を行っていました。

幸い、Swift 6への移行対応全体で出た不具合は軽微な2件だけで、全体的に安定して移行することができました。

Swift 6移行対応を終えてみて

Swift 6移行対応では、コンパイラフラグを有効にして、増えた1000を超える警告を全部倒す必要がある膨大な作業でしたが、集中して対応できたことで、効率よくかつ安定して移行対応を完了させることができました。

Swift 6移行対応すると、より強力なデータ競合チェックが働いて、データ競合を未然に防ぐことができるようになるので、よくわからないスレッド違反が関連するクラッシュが減る可能性がある、との噂は聞いてましたが、その効果はびっくりするほどでした!

clusterではCoreDataを使っていますが、再現が難しいバックグラウンドスレッドからの参照が原因と思われるクラッシュがありましたが、Swift 6への移行完了後、CoreData関連のクラッシュが0件になり、全体的なクラッシュ数も明らかに減りました。なお、このクラッシュ数にはSwift以外にUntiy部分のものも含まれています。

おわりに

clusterでは半年以上かけてSwift 6への移行対応を完了しました。Swiftの進化は続いており、Swift 6.1もリリースされ、今後もConcurrency関連の改善は続いていきます。移行が遅くなればなるほど、追いつくのが大変になるでしょう。

Swift 6への移行対応は確かに大変な作業ですが、強力なデータ競合チェックによるアプリの安定性向上は大きなメリットになります。この記事が皆さんのSwift 6移行対応の一助となれば幸いです。

参考