@Observable macro conflicting with @MainActor

TLDR: Do updates within @Observable objects not need to be on the Main Thread or Main Actor?


Prior to iOS 17, it was common to run into the following warning when updating an @Published value inside an ObservableObject:

Publishing changes from background threads is not allowed; make sure to publish values from the main thread

The following code is an example of this:

actor Actor1 {
    func getTitle() async -> String {
        "Hello, world!"
    }
}

final class ObservableObject1: ObservableObject {
    
    let actor = Actor1()
    @Published var title = "Hello"
    
    func updateTitle() {
        Task {
            title = await actor.getTitle()
        }
    }
}

struct View1: View {

    @StateObject var object = ObservableObject1()
    
    var body: some View {
        Text(object.title)
            .onAppear {
                object.updateTitle()
            }
    }
}

One solution I've used for this was to mark the ObservableObject as @MainActor.

@MainActor final class ObservableObject1: ObservableObject {
}

When I update to the iOS 17 @Observed macro, the warning disappears without requiring @MainActor:

@Observable final class ObservableObject2 {
    
    let actor = Actor1()
    var title = "Hello"
    
    func updateTitle() {
        Task {
            title = await actor.getTitle()
        }
    }
}

struct View2: View {

    @State var object = ObservableObject2()
    
    var body: some View {
        Text(object.title)
            .onAppear {
                object.updateTitle()
            }
    }
}

However, if I put print(Thread.current) statements inside the Task in each of these examples, it seems that the @Observable implementation is updating without being on the Main Thread.

The following example...

@Observable final class ObservableObject2 {
    
    let actor = Actor1()
    var title = "Hello"
    
    func updateTitle() {
        print(Thread.current)
        Task {
            title = await actor.getTitle()
            print(Thread.current)
        }
    }
}

...will print...

<_NSMainThread: 0x600001710000>{number = 1, name = main}
<NSThread: 0x60000174c180>{number = 6, name = (null)}

... but marking the Task as...

Task { @MainActor in 
}

...will print...

<_NSMainThread: 0x600001708000>{number = 1, name = main}
<_NSMainThread: 0x600001708000>{number = 1, name = main}

Further, if I try to mark the @Observable as @MainActor, I run into the following error:

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

So my guess is that this is by design so that developers don't need to worry about thread safety as much, but is there an explanation for this?

  • Do objects using @Observable macro not need to be on the Main Thread?
  • Is something happening within the @Observable macro that is updating on the Main Thread?
  • Is the SwiftUI View getting updates from threads other then the Main Thread?

Thanks in advance,

Nick

14 Likes

Hi,

I'm also asking myself the same questions.
I'm migrating a swiftui project to ios17 with the new @observable.
The WWDC2023 video WWDC2023 Observable explicitly says "That is the only thing that is needed." I'm trying to delete all the @MainActor's in my code and so far no crashes.
I'm not completely finished so I'm not 100% sure if it is the good way to work.

Nope, @Observable is a bring-your-own-synchronization-type however. So if you need thread safety you should provide it.

The internals of the macro don't have any specific thread it has affinity to; however that of course does not change the affinity of the users of your type that is using the macro.

You may want to watch out for cases like this - it would mean that your type is being accessed from more than one actor isolation. That would mean that you would likely want some sort of synchronization to ensure that reads/writes are properly handled. The easiest way to do this is to mark the fields that are accessed by SwiftUI as isolated to the main actor (or the type itself).

The observed behavior of Task { @MainActor is more to do with Task than @Observable - that annotation says "please run the body of this task on the main actor". Consequently you are seeing the main thread being used in both the updateTitle immediate body as well as the nested task body. Which, since you don't offer any other synchronization, is likely the right thing to do here.

so what is the general use case here? something like the following? Does the order matter?

@Observable
@MainActor
class ViewModel {
//...
}
1 Like