📖

SwiftUI ビューと@MainActor

2024/04/17に公開

開発者の数が増えるにつれて、Swift 6 の登場に備えて厳密な並行性チェックを有効にする人が増えています。受け取った警告やエラーの一部は SwiftUI ビューに関連しており、多くは開発者が@MainActorを正しく理解または使用していないことに起因しています。この記事では、@MainActorの意味と、SwiftUI ビュー内で@MainActorを適用するためのヒントと考慮事項について説明します。

この原文は私のブログ Fatbobman's Blog に掲載されています。Swift、SwiftUI、Core Data、SwiftData に関する最新のアップデートや優れた記事をお見逃しなく。Fatbobman's Swift Weekly に登録して、毎週の洞察と貴重なコンテンツを直接メールボックスにお届けします。

私の PasteButton が動かなくなった

最近、私の Discord コミュニティで、厳密な並行性チェックを有効にした後、PasteButtonに対してコンパイラが次のエラーを投げたと友人が報告しました。

同期的非分離コンテキストで main actor-isolated 初期化子'init(payloadType:onPast:)'を呼び出す

pasteButton-MainActor-error-2024-03-13

PasteButtonの宣言を調べた後、彼にビューコードをbodyの外に置いたかどうかを尋ねました。肯定的な回答を受け取った後、PasteButtonの変数宣言に@MainActorを追加するようアドバイスし、それで問題が解決しました。

@MainActor public struct PasteButton : View {
    @MainActor public init(supportedContentTypes: [UTType], payloadAction: @escaping ([NSItemProvider]) -> Void)

    @MainActor public init<T>(payloadType: T.Type, onPaste: @escaping ([T]) -> Void) where T : Transferable
    public typealias Body = some View
}

では、問題は最初どこにあったのでしょうか?@MainActorを追加することでなぜ解決したのでしょうか?

@MainActor とは何か

Swift の並行性モデルでは、actorは並行コードを安全かつ理解しやすく書くための方法を提供します。actorはクラスに似ていますが、並行環境でのデータ競合と同期問題を解決するために特別に設計されています。

actor Counter {
    private var value = 0
    
    func increment() {
        value += 1
    }
    
    func getValue() async -> Int {
        return value
    }
}

let counter = Counter()
Task {
  await counter.increment()
}

actorの魔法は、データ競合を防ぐためにアクセスをシリアライズする能力にあります。これにより、並行操作のための明確で安全な道が提供されます。しかし、この隔離は特定のactorインスタンスに局所化されます。Swift は、隔離をより広く拡張するためにGlobalActorの概念を導入しました。

GlobalActorを使用すると、異なるモジュール間でコードに注釈を付けて、これらの操作が同じシリアルキューで実行されることを保証できます。これにより、操作の原子性と一貫性が維持されます。

@globalActor actor MyActor: GlobalActor

 {
    static let shared = MyActor()
}

@MyActor
struct A {
  var name: String = "fat"
}

class B {
  var age: Int = 10
}

@MyActor
func printInfo() {
  let a = A()
  let b = B()
  print(a.name, b.age)
}

@MainActorは Swift によって定義された特別なGlobalActorです。その役割は、@MainActorで注釈されたすべてのコードが同じシリアルキューで実行され、これがすべてメインスレッド上で行われることを保証することです。

@globalActor actor MainActor : GlobalActor {
    static let shared: MainActor
}

@MainActorのこの簡潔で強力な機能は、Swift の並行性モデル内で以前DispatchQueue.main.asyncに依存していた操作を型安全かつ統合された方法で扱うことを可能にします。これはコードを単純化し、エラー率を低下させるだけでなく、コンパイラの保護を通じて、@MainActorでマークされたすべての操作がメインスレッド上で安全に実行されることを保証します。

View プロトコルと@MainActor

SwiftUI の世界では、ビューはアプリケーションの状態を画面に宣言的に表示する役割を果たします。これは、ビューがユーザーインターフェースの提示と直接関連しているため、すべてのコードがメインスレッドで実行されるという前提に自然とつながります。

しかし、View プロトコルを詳しく見ると、bodyプロパティのみが明示的に@MainActorでマークされているという詳細が明らかになります。これは、View プロトコルに適合する型が全てメインスレッドで実行されるとは保証されていないことを意味します。bodyを超えて、コンパイラは他のプロパティやメソッドがメインスレッドで実行されることを自動的に保証しません。

public protocol View {
    associatedtype Body : View
    @ViewBuilder @MainActor var body: Self.Body { get }
}

この洞察は、PasteButtonのように、他のコンポーネントとは異なり明示的に@MainActorでマークされている SwiftUI の公式コンポーネントの使用を理解する上で特に重要です。これは、PasteButton@MainActorでマークされたコンテキスト内で使用する必要があることを示しています。そうでない場合、コンパイラはエラーを報告し、同期的非分離コンテキストで main actor-isolated 初期化子を呼び出すことは許可されないことを示します:

struct PasteButtonDemo: View {
  var body: some View {
    VStack {
      Text("Hello")
      button
    }
  }
  
  var button: some View {
    PasteButton(payloadType: String.self) { str in // 同期的非分離コンテキストで main actor-isolated 初期化子'init(payloadType:onPaste:)'を呼び出す
      print(str)
    }
  }
}

この問題を解決するためには、単にbutton変数に@MainActorをマークすることで、コンパイルをスムーズに通過することができます。これにより、buttonが適切なコンテ

キスト内で初期化および使用されることを保証します:

@MainActor
var button: some View {
  PasteButton(payloadType: String.self) { str in
    print(str)
  }
}

ほとんどの SwiftUI コンポーネントは値型であり、Sendable プロトコルに適合していますが、@MainActorで明示的にマークされていないため、PasteButtonが直面する特定の問題は発生しません。

この変更は、SwiftUI ビューで@MainActorを使用する重要性を強調し、また、ビューに関連するすべてのコードがデフォルトでメインスレッド上で実行されるわけではないことを開発者に思い出させるものです。

@MainActor をビューに適用する

一部の読者は、PasteButtonDemoビュータイプ全体に直接@MainActorを注釈付けすることで問題を根本的に解決できるのではないかと疑問に思うかもしれません。

確かに、PasteButtonDemoビュー全体に@MainActorを注釈付けすると、問題が解決されます。@MainActorで注釈された場合、Swift コンパイラはビュー内のすべてのプロパティとメソッドがメインスレッドで実行されると想定します。これにより、buttonに別の注釈を付ける必要がなくなります。

@MainActor
struct PasteButtonDemo: View {
  var body: some View {
    ...
  }
  
  var button: some View {
    PasteButton(payloadType: String.self) { str in
      ...
    }
  }
}

このアプローチには他の利点もあります。たとえば、Observationフレームワークを使用してオブザーバブルオブジェクトを構築する場合、その状態更新がメインスレッドで発生することを確実にするために、オブザーバブルオブジェクト自体に@MainActorを注釈付けすることができます:

@MainActor
@Observable
class Model {
  var name = "fat"
  var age = 10
}

しかし、公式ドキュメントに推奨されているように、このオブザーバブルオブジェクトインスタンスをビュー内で@Stateとして宣言しようとすると、コンパイラ警告が発生します。これは Swift 6 ではエラーとみなされます。

struct DemoView: View {
  @State var model = Model() // 非分離コンテキストでの main actor-isolated デフォルト値;これは Swift 6 ではエラーです
  var body: some View {
    NameView(model: model)
  }
}

struct NameView: View {
  let model: Model
  var body: some View {
    Text(model.name)
  }
}

この問題は、デフォルトではビューの実装が@MainActorで注釈されていないために発生します。@MainActorで注釈された型を直接宣言することができません。DemoView@MainActorで注釈された場合、上記の問題は解決されます。

プロセスをさらに簡素化するために、@MainActorで注釈されたプロトコルを定義し、このプロトコルに適合する任意のビューが自動的にメインスレッド実行

環境を継承することもできます:

@MainActor
protocol MainActorView: View {}

このプロトコルを実装する任意のビューは、すべての操作がメインスレッドで実行されることを保証します:

struct AsyncDemoView: MainActorView {
  var body: some View {
    Text("abc")
      .task {
        await do something()
      }
  }
  
  func doSomething() async {
    print(Thread.isMainThread) // true
  }
}

ビュータイプに@MainActorを注釈付けすることは良い解決策のように思えますが、ビュー内で宣言されたすべての非同期メソッドがメインスレッドで実行される必要があります。これは常に望ましいわけではありません。たとえば:

@MainActor
struct AsyncDemoView: View {
  var body: some View {
    Text("abc")
      .task {
        await doSomething()
      }
  }
  
  func doSomething() async {
    print(Thread.isMainThread) // true
  }
}

@MainActorで注釈を付けない場合、必要に応じてプロパティやメソッドに注釈を付けることがより柔軟にできます:

struct AsyncDemoView: View {
  var body: some View {
    Text("abc")
      .task {
        await doSomething()
      }
  }
  
  func doSomething() async {
    print(Thread.isMainThread) // false
  }
}

したがって、ビュータイプに@MainActorを注釈付けするかどうかは、具体的なアプリケーションシナリオに依存します。

@StateObject の新たな使用法

Observation フレームワークが新しい標準となるにつれて、@StateObjectの従来の使用法は目立たなくなるかもしれません。しかし、それでも特別な機能を持っており、Observation 時代にも依然として役立つものです。以前に議論したように、@MainActorでマークされた@Observableオブジェクトは、ビュー全体も@MainActorでマークされていない限り、@Stateで直接宣言することはできません。しかし、@StateObjectを使用すると、この制限を巧妙に回避できます。

以下の例を考えてみましょう。@MainActorでマークされた観測可能なオブジェクトを、ビュー全体を@MainActorでマークすることなくビューに安全に導入することができます:

@MainActor
@Observable
class Model: ObservableObject {
  var name = "fat"
  var age = 10
}

struct StateObjectDemo: View {
  @StateObject var model = Model()
  var body: some View {
    VStack {
      NameView(model: model)
      AgeView(model: model)
      Button("update age"){
        model.age = Int.random(in: 0..<100)
      }
    }
  }
}

この方法の実現可能性は、@StateObjectの一意のローディングメカニズムに由来します。コンストラクタのクロージャがビューが実際にロードされるときにメインスレッドで呼び出されるためです。さらに、@StateObjectwrappedValue@MainActorで注釈されており、これにより@MainActorでマークされたObservableObjectプロトコルに適合するタイプを正しく初期化して使用することができます。

@frozen @property

Wrapper public struct StateObject<ObjectType>: SwiftUI.DynamicProperty where ObjectType: Combine.ObservableObject {
    @usableFromInline
    @frozen internal enum Storage {
        case initially(() -> ObjectType)
        case object(SwiftUI.ObservedObject<ObjectType>)
    }

    @usableFromInline
    internal var storage: SwiftUI.StateObject<ObjectType>.Storage
    @inlinable public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType) {
        storage = .initially(thunk)
    }

    @_Concurrency.MainActor(unsafe) public var wrappedValue: ObjectType {
        get
    }

    @_Concurrency.MainActor(unsafe) public var projectedValue: SwiftUI.ObservedObject<ObjectType>.Wrapper {
        get
    }

    public static func _makeProperty<V>(in buffer: inout SwiftUI._DynamicPropertyBuffer, container: SwiftUI._GraphValue<V>, fieldOffset: Swift.Int, inputs: inout SwiftUI._GraphInputs)
}

この方法の主な利点は、観測可能なオブジェクトのライフサイクルの安全性を保証すると同時に、その観測ロジックを Observation フレームワークに基づいて完全に維持することです。これにより、ビューの柔軟性を犠牲にすることなく、@MainActorでマークされた観測可能なタイプを柔軟に使用できる道が提供されます。Apple がメインスレッド上で@Stateを実行するためのより明確な解決策を提供するまでは、このアプローチが実用的かつ効果的な一時的な戦略として機能します。

SwiftUI の現行バージョン(Swift 6 以前)では、開発者がビュー内で@StateObjectを使用して状態を宣言すると、Swift コンパイラは暗黙的にビュー全体が@MainActorで注釈されていると推測します。この暗黙的な推論の挙動は、開発者の間で誤解を招くことが容易です。SE-401 提案の公式採用により、Swift 6 からこのような暗黙の推論は許可されなくなります。

結論

厳密な並行性チェックを有効にした後、多くの開発者が混乱や圧倒される感じを抱くかもしれません。一部の開発者はこれらのプロンプトに基づいてコードを変更し、エラーを排除するかもしれません。しかし、新しい並行性モデルをプロジェクトに導入する根本的な目的は、「コンパイラを騙す」ことを超えています。実際には、開発者は Swift の並行性モデルを深く理解し、よりマクロなレベルでコードを再評価することで、より高品質で安全な解決策を発見するべきです。このアプローチは、コードの品質と保守性を高めるだけでなく、開発者が Swift の並行プログラミングの世界をより自信を持って、より容易にナビゲートするのを助けます。

Discussion