iosswiftobservableobjectios17observation-framework

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)?


Solution

  • 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.