Rob
Rob

Reputation: 438112

Actor isolate @Observable type

Back in WWDC 2021’s Discover concurrency in SwiftUI, they recommend that you isolate the ObservableObject object to the main actor. E.g.:

struct ContentView: View {
    @StateObject var viewModel = ViewModel()

    var body: some View {
        Text("\(viewModel.count)")
        .task {
            try? await viewModel.start()
        }
    }
}

@MainActor 
class ViewModel: ObservableObject {
    var count = 0

    func start() async throws {
        while count < 10 {
            count += 1
            try await Task.sleep(for: .seconds(1))
        }
    }
}

But in iOS 17’s Observation framework (as introduced in WWDC 2023’s Discover Observation in SwiftUI), it appears that isolating to the main actor is no longer needed to prevent UI updates from triggering on a background thread. E.g., the following works with no warnings about initiating UI updates from the background:

struct ContentView: View {
    var viewModel = ViewModel()             // was `@StateObject var viewModel = ViewModel()`

    var body: some View {
        Text("\(viewModel.count)")
        .task {
            try? await viewModel.start()
        }
    }
}

@Observable class ViewModel {               // was `@MainActor class ViewModel: ObservableObject {…}`
    var count = 0                           // was `@Published`

    func start() async throws {
        while count < 10 {
            count += 1
            try await Task.sleep(for: .seconds(1))
        }
    }
}

It is not immediately apparent what the underlying mechanism is that obviates the main actor isolation, but it works.

But what if you want the ViewModel to be actor isolated for reasons other than not updating the UI from the background. E.g., perhaps I just want avoid races in this @Observable object? SE-0395 says that it does not (yet) support observable actor types:

Another area of focus for future enhancements is support for observable actor types. This would require specific handling for key paths that currently does not exist for actors.

But what about a class that is actor isolated to some global actor (such as the main actor)? It appears that I can isolate the view model to the main actor, but then I get an error in the View:

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

I can get around that error by isolating the View to the main actor, too. E.g., the following appears to work:

@MainActor
struct ContentView: View {
    var viewModel = ViewModel()

    var body: some View {
        Text("\(viewModel.count)")
        .task {
            try? await viewModel.start()
        }
    }
}

@MainActor
@Observable
class ViewModel {
    var count = 0

    func start() async throws {
        while count < 10 {
            count += 1
            try await Task.sleep(for: .seconds(1))
        }
    }
}

But it feels wrong to isolate the whole View to the main actor, when Apple obviously chose not to (for reasons that escape me). So, in short, how does one isolate the @Observable type to a global actor (such as the main actor)?

Upvotes: 18

Views: 3517

Answers (2)

Rob
Rob

Reputation: 438112

As noted by yo1995, this problem is now fixed in Xcode 16. The View protocol is now explicitly isolated to MainActor so no explicit @MainActor qualifier is needed anymore. Even with “Strict Concurrency Checking” build setting of “Complete”, there is no longer any warning.

So, in Xcode 15, I would manually add the @MainActor qualifier to the ContentView, but I would retire it when I migrated to Xcode 16.

Upvotes: 3

CouchDeveloper
CouchDeveloper

Reputation: 19154

I only have a workaround for this problem, and it may not be applicable in all situations.

But first, the issue:

Given a SwiftUI view which uses and initialises a Model:

struct ContentView: View {
    @State var viewModel = ViewModel()

    var body: some View {
        ...
    }
}

and the corresponding Model, which uses the @MainActor in order to synchronise its members:

@MainActor
@Observable
class ViewModel {
    var count: Int = 0
    
    func foo() async throws {
        // asynchronously mutates member `count` which 
        // needs to be synchronised. Here, through 
        // using `@MainActor`. That way, it's guaranteed 
        // that mutations on `count` happen solely on 
        // the main thread.
        ...
    }
}

When trying to compile we get an error in struct ContentenView:

    @State var viewModel = ViewModel() <== Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context

That is, the compiler wants to ensure that the initialiser of Model will be called on the main thread. While we intuitively assume, this will be the case anyway, it's a View after all, the compiler wants clear facts, though.

The reasons for this requirement is not that obvious. Usually, we have guaranteed thread-safety in other languages where the constructor is called on any thread, when accessing the members is made safe by other means.

For Swift, we can read more about this On Actors and Initialization, SE-0327, specifically: overly-restrictive-non-async-initializers

Associating the SwiftUI View on the main actor would be one solution, but today, may cause other issues.

Another solution may just declare the initialiser nonisolated - but be careful – it may break synchronisation. In this case it may work through explicitly declaring the initialiser as nonisolated with an empty body:

@MainActor
@Observable
class ViewModel {
    var count: Int = 0
    
    nonisolated init() {}
    
    func start() async throws {
        while count < 10 {
            count += 1
            try await Task.sleep(for: .seconds(1))
        }
    }
}

Note:

In order to use an empty nonisolated initialiser, all members must be initialised when declared. For example:

class ViewModel {
    var count: Int = 0
    ...

A nonisolated initialiser cannot initialise/set members. If we try, we get the error:

Main actor-isolated property 'count' can not be mutated from a non-isolated context

Caution

More complex initialisers declared nonisolated may be prone to data races! Please read the above links carefully.

This is a workaround for current issues. I hope, these things get more finished in the future.

Upvotes: 8

Related Questions