tva
← コラム

Swift 6の並行処理:実践的な移行ガイド

Swift 5はデータ競合に対して寛容でした。コンパイラーは一部には警告を出し、他は見逃していたため、ある場所ではDispatchQueue.main.asyncを呼び、別の場所では忘れるという習慣がコードベースに定着し、問題が起こるまでは動いていました。Swift 6では前提が変わります。アクター分離とSendableへの準拠に関する警告はすべてエラーになり、データ競合の可能性を取り除くか、明示的に認めるまでビルドできません。

実際には、既存のSwiftUIアプリの多くに深刻なデータ競合が潜んでいるわけではありません。あるのは、実用上は安全でも、Swiftの並行処理モデルでは技術的に正しくないパターンの積み重ねです。移行は、実際のバグ修正というより、コードの安全性をコンパイラーが理解できる形にする作業です。有益な取り組みですが、エラーが何を示しているかを理解する必要があります。

厳格な並行性チェックを有効にする

Swift 6の言語モードへ移行する前に、エラーになる箇所を警告として確認すると役立ちます。Xcodeで対象ターゲットのSwiftコンパイラー設定SWIFT_STRICT_CONCURRENCYをcompleteにします。Swift 6が拒否する箇所をすべて表示しつつ、ビルドは失敗させません。言語バージョンを切り替える前に、警告を順に解消します。

ターゲットのBuild SettingsでSwift 6の言語モードを有効にするには、SWIFT_VERSIONを6に設定します。するとcomplete相当の厳格な並行性チェックが適用され、警告はすべてエラーになります。複数パッケージのプロジェクトでは、ターゲットごとに移行するのが適切です。UIへの依存が少ないパッケージから始め、純粋なロジックやネットワーク処理を先に、UIを最後にします。

MainActorの境界に関する問題

SwiftUIの移行で最もよく見られるエラーは、次のようなものです。

// Error: Main actor-isolated property 'items' can not be
// mutated from a nonisolated context
class DataStore: ObservableObject {
    @Published var items: [Item] = []

    func fetch() {
        Task {
            let result = await API.load()
            self.items = result  // ← error
        }
    }
}

@Publishedプロパティは、ObservableObjectに属していても自動的に@MainActorに分離されるわけではありませんが、SwiftUIはメインスレッドでそれを監視します。Task {}が未指定のエグゼキューターで実行され、itemsへの書き込みを試みると、Swift 6は競合の可能性を検出します。 このクラスを記載どおりにコンパイルすると、Swift 6コンパイラーはselfをキャプチャするTaskのクロージャーにデータ競合のリスクがあると報告します。コメントにあるメインアクターへの分離に関するメッセージは、items自体に@MainActorを付けた場合に表示されます。

最もすっきりした修正は、クラス全体に@MainActorを付けることです。

@MainActor
class DataStore: ObservableObject {
    @Published var items: [Item] = []

    func fetch() async {
        let result = await API.load()
        items = result  // ✓ — entire class is MainActor-isolated
    }
}

これでクラスの分離が明示されます。バックグラウンドのコンテキストからfetch()を呼ぶ側は、awaitを使う必要があります。UI更新を担うクラスを操作するならメインアクター上で行うべきなので、これは適切です。クラス全体に付けられない場合は、明示的にアクターを切り替える方法があります。

func fetch() {
    Task {
        let result = await API.load()
        await MainActor.run {
            self.items = result
        }
    }
}

どちらも有効です。クラスの主目的がSwiftUIビューの更新である場合は、クラスに@MainActorを付けるほうが簡潔です。適切に構成されたアプリのObservableObjectクラスでは、ほぼ常にこれが当てはまります。

PhotosPickerとデリゲートの分離

PHPickerViewControllerDelegateはSwift 6でエラーが発生しやすい箇所です。現在のSDKではデリゲートメソッド自体はメインアクターに分離されていますが、その中で使うアイテムプロバイダーの読み込みコールバックはバックグラウンドキューで実行されます。そのコールバック内のコードは通常UIを更新する必要があります。典型的なSwift 5のパターンは次のとおりです。

func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
    dismiss(animated: true)
    guard let provider = results.first?.itemProvider else { return }

    provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
        if let image = object as? UIImage {
            self?.imageView.image = image  // ← Swift 6 error
        }
    }
}

Swift 6では、loadObjectに渡すクロージャーは@Sendableです。つまり、selfをキャプチャするには、そのselfがSendableである必要があります。メインアクターに分離されたクラスは暗黙的にSendableとなるため、UIViewControllerはこの条件を満たします。実際のエラーは、@MainActorに分離されたプロパティであるimageView.imageにバックグラウンドのコンテキストからアクセスしていることです。

修正方法は、クロージャー内でメインアクターへ切り替えることです。

provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
    guard let image = object as? UIImage else { return }
    Task { @MainActor [weak self] in
        self?.imageView.image = image
    }
}

Task { @MainActor in }という構文は、メインアクターに明示的に分離されたタスクを作成します。imageのキャプチャは、UIImageがSendableに準拠しているため問題ありません。selfを[weak self]でキャプチャするのも、ビューコントローラーがメインアクターに分離されており、Sendableであるため問題ありません。weakは参照サイクルを防ぐだけで、Sendableではない型を安全にキャプチャできるようにするものではありません。

SwiftUIの新しいAPIであるPhotosPickerでは、Transferableを使うため、より簡潔になります。

// In a @MainActor view model:
func handlePickerSelection(_ item: PhotosPickerItem?) async {
    guard let item else { return }
    if let data = try? await item.loadTransferable(type: Data.self) {
        selectedImage = UIImage(data: data)
    }
}

loadTransferableはasyncなので、awaitで呼び出します。代入は関数が分離されているアクター上で行われ、ビューモデルにその注釈があれば@MainActorになります。手動でアクターを切り替える必要はありません。

Sendable型と非Sendable型

アクター分離の境界を越えて安全に転送できる型はSendableです。大まかには、値型か、同期処理で状態を保護するクラスが該当します。Swift 6のコンパイラーは、値が分離境界を越えるすべての箇所でこれを検査します。

最もよく見られるエラーパターンは次のとおりです。

// Error: Capture of 'config' with non-sendable type 'RequestConfig'
// in a '@Sendable' closure
struct RequestConfig {
    var endpoint: String
    var headers: [String: String]
    var body: Data?
}

Task {
    let result = await API.fetch(config)  // config captured here
}

修正は通常単純で、値型にSendableへの準拠を追加します。

struct RequestConfig: Sendable {
    var endpoint: String
    var headers: [String: String]
    var body: Data?
}

メンバーがすべてSendableである構造体では、準拠の条件は容易に満たされます。String、Data、標準の値型はすべてこれに含まれます。コンパイラーは非公開の構造体については暗黙的に準拠を推論するため、実際にこのエラーが発生するのは、別のモジュールから使われるpublicな型です。明示的な: Sendable宣言はコンパイラーが検証します。クラスではより複雑です。コンパイラーがSendableへの準拠を検証できるのは、格納プロパティがすべてSendableな型のlet定数であるfinalクラスか、@MainActorなどのグローバルアクターに分離されたクラスだけです。独自の内部同期処理で可変状態を保護するクラスには@unchecked Sendableが必要です。コンパイラーはロック処理を検証できないためです。常にメインアクターからアクセスするObservableObjectには、自動的にSendableとなる@MainActorを優先し、@unchecked Sendableは最初の対処ではなく最後の手段とします。

SharedDataProviderとビューモデル間で共有する状態

規模の大きいSwiftUIアプリでは、環境を通じて複数のビューに共有データプロバイダーを注入するパターンがよく使われます。Swift 6では、メインアクターで実行されるビューのbodyからアクセスされるため、このオブジェクトのアクター分離を明示する必要があります。

@MainActor
class SharedDataProvider: ObservableObject {
    static let shared = SharedDataProvider()

    @Published var currentUser: User?
    @Published var sessionToken: String?

    func refreshSession() async {
        let token = await AuthClient.refresh()
        sessionToken = token
    }
}

ここでの@MainActorは実際に役割を果たします。currentUserとsessionTokenへのすべてのアクセスをメインアクターで直列化し、SwiftUIビューの更新を担うプロパティを適切に分離します。このクラスへ書き込むバックグラウンドタスクにはawaitが必要です。同時に書き込むのではなく、メインアクターが利用可能になるまで待つべきなので、これは適切です。

注意すべきなのは、static let sharedを@MainActorクラスに置くと、メインアクターからしかアクセスできないことです。バックグラウンドのコンテキストからSharedDataProvider.sharedにアクセスする経路があれば、Swift 6は拒否します。関数の呼び出し経路でインスタンスを明示的に渡すか(メインアクター外からそのプロパティにアクセスするには引き続きawaitが必要です)、アクセスをawait MainActor.run { ... }で囲んで修正します。

@unchecked Sendableという例外的な手段

Cライブラリをラップしたり、Objective-Cの型と橋渡ししたり、安全に管理されていてもコンパイラーからは分からない内部状態を持つなど、実際には完全なSendableにできないクラスもあります。その場合、@unchecked Sendableは「同期処理は自分で対応したので信頼してほしい」とコンパイラーに伝えます。

final class AudioPlayer: @unchecked Sendable {
    private let queue = DispatchQueue(label: "audio.player")
    private var _isPlaying = false

    var isPlaying: Bool {
        queue.sync { _isPlaying }
    }

    func play() {
        queue.async { self._isPlaying = true }
    }
}

これは移行作業を避ける近道ではありません。並行処理の意味を検討し、その責任を引き受けることを明示する宣言です。内部の同期処理が本当に必要なクラスに使い、安全性を保証する仕組みをコメントで説明します。

実践的な移行順序

まずSWIFT_STRICT_CONCURRENCY = completeを有効にし、すべての警告に対応してからSwift 6の言語モードへ切り替えます。依存グラフの末端、つまり他の社内モジュールに依存していないパッケージから始めます。それらを修正してSwift 6の言語モードに更新し、次の層へ進みます。これにより、下流のパッケージを修正するために、まだ警告モードにある上流のパッケージまで同時に変更しなければならない状況を防げます。

一般的なSwiftUIコードベースでは、移行作業の大半は、@MainActorをObservableObjectクラスに付けること、タスク境界を越えて渡す値型をSendableに準拠させること、デリゲートのコールバックで明示的にアクターを切り替えることです。差分では変更量が多く見えても、個々の変更は機械的です。コンパイラーが対応の必要な箇所をすべて示してくれます。それが厳格な並行性チェックの目的です。

関連記事

関連記事