🏎️

@Observableと@MainActorについて

2024/01/23に公開

ネット上で@Observableと@MainActorに関する質問が何個かあったので調べてみた。

https://forums.developer.apple.com/forums/thread/731822
https://forums.swift.org/t/observable-macro-conflicting-with-mainactor/67309

問題

例えば次のようなObservableObjectのViewModelとSwiftUIのViewのコードがある。もちろん正常にビルドできて正しく動作するコードである。

@MainActor
class ViewModel: ObservableObject {
    @Published var count = 0

    func increase() {
        Task {
            self.count += 1
        }
    }
}

struct CounterView: View {
    @ObservedObject var viewModel = ViewModel()

    var body: some View {
        Text("\(viewModel.count)")
        Button {
            viewModel.increase()
        } label: {
            Text("Increase")
        }
    }
}

このコードを Discover Observation in SwiftUI - WWDC23 で説明された方法で@Observableへマイグレーションするとこうなる。

+@Observable
@MainActor
-class ViewModel: ObservableObject {
+class ViewModel {
-    @Published var count = 0
+    var count = 0

...
}

struct CounterView: View {
-    @ObservedObject var viewModel = ViewModel()
+    var viewModel = ViewModel()

...
}

そしてビルドをすると次のようなエラーが表示される。

Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context

問題はMainActorであるViewModelのinit()がMainActorの外で呼ばれることである。

原因

まずSwiftUIのViewの定義を確認する。

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

確かにbodyは@MainActorであるがViewは@MainActorではないためCounterViewのプロパティvar viewModel = ViewModel()はMainActorの外に存在することがわかった。

そうなると既存のObservableObjectを使うコードではなぜ同じエラーが出なかったのか気になる。

調べてみると SE-0316: Global ActorsのGlobal actor inference にその答えがあった。

A struct or class containing a wrapped instance property with a global actor-qualified wrappedValue infers actor isolation from that property wrapper:

@propertyWrapper
struct UIUpdating<Wrapped> {
 @MainActor var wrappedValue: Wrapped
}

struct CounterView { // infers @MainActor from use of @UIUpdating
 @UIUpdating var intValue: Int = 0
}

プロパティラッパーのwrappedValueがMainActorの場合、そのプロパティラッパーを使うCounterViewも自動的にMainActorになるのが今のSwiftの仕様だ。

@ObservedObjectプロパティラッパーの定義を見ると同じくwrappedValueがMainActorであるのがわかる。

@propertyWrapper @frozen public struct ObservedObject<ObjectType> : DynamicProperty where ObjectType : ObservableObject {
    @MainActor public var wrappedValue: ObjectType
    @MainActor public var projectedValue: ObservedObject<ObjectType>.Wrapper { get }
}

原因は@ObservedObjectで自動的にMainActorだったCounterViewが@ObservedObjectを外すとMainActorではなくなったからだ。

解決

原因がわかったので一番簡単な解決方法は明示的にCounterViewをMainActorにすることだ。

+@MainActor
struct CounterView: View {
...
}

WWDC23 #SwiftUI Q&A には以下のような内容がある。

Does the new @Observable wrapper automatically apply @MainActor - we've been using MainActor on our ViewModel classes to ensure @Published vars are updated on main.

@Observable does not require @MainActor, though it's still best practice to always update UI-related models on the main actor.

Updating @Observable model properties on non-main actors that are used by SwiftUI views will trigger SwiftUI view updates as expected, however those updates may not work correctly with animations and/or may not guarantee atomicity for a set of model updates relative to a single view update. So there are cases where background updates to @Observable models will work just fine, but for "view models" or models heavily used directly by SwiftUI views, having those aligned to the main actor is a best practice.

How to run an @Observable class on the MainActor?

@Observable @MainActor final class SomeModel {}
struct ContentView: View {
  let model = SomeModel() // Doesn't work
  var body: some View {...}
}

Since you annotated your model @MainActor, you'll need the same annotation on the parent scope per Swift concurrency rules.

@MainActor // <====
struct ContentView: View {
 var model = SomeModel() // should work now!
}

Appleのエンジニアも基本Modelが@MainActorの場合、Viewも@MainActorにすることを勧めている。

気になる点

- その1

@Observableマクロを展開すると以下のようなコードが現れる。

internal nonisolated func access<Member>(
    keyPath: KeyPath<ViewModel , Member>
) {
  _$observationRegistrar.access(self, keyPath: keyPath)
}

internal nonisolated func withMutation<Member, MutationResult>(
  keyPath: KeyPath<ViewModel , Member>,
  _ mutation: () throws -> MutationResult
) rethrows -> MutationResult {
  try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}

accessとwithMutationメソッドがnonisolatedなのだ。

また@Observableマクロのコードを見てみるとactorはまだ@Observableマクロに対応されてないことがわかる。

https://github.com/apple/swift/blob/release/5.9/lib/Macros/Sources/ObservationMacros/ObservableMacro.swift#L216-L219

まあ変な使い方をしない限り問題はないと思うがちょっと気になる。

- その2

SE-0316: Global ActorsのGlobal actor inference によるプロパティラッパーのwrappedValueがGlobal Actorの場合、それを使うClassやStructも自動で同じGlobal ActorになるのがSwiftの仕様だが、実は SE-0401: Remove Actor Isolation Inference caused by Property Wrappers の実装で既になくなることが決まっている。

Swift 5.9で実装済みだが互換性のためSwift6から有効になり、今すぐ使いたい場合はcompiler flag -enable-upcoming-feature DisableOutwardActorInference を使う必要がある。

Swift6もやはりちょっと気になる。

Discussion