skip to Main Content

I have the following view model:

@MainActor
final class SomeViewModel {
    func loadFromService() async {
        do { 
            let dto = try await networkService.theServiceIneedHere()
            //process dto
            //state is a @Published property and observed by a SwiftUI view to update the UI.
            state = .success(viewData)
        } catch {
            state = .failure(someError)
        }
    }
}

My question here is, if loadFromService is called from my SwiftUI view task method and my SwiftUI view is not marked as a @MainActor then is loadFromService() running on the MainActor’s thread? ie it is not isolated?

Is the following a better approach to the above code?

final class SomeViewModel {
    func loadFromService() async {
        do {
            let dto = try await networkService.theServiceIneedHere()
            await handleServiceSuccess(with:dto)
        } catch {
            await MainActor.run {
                state = .failure(someError)
            }
        }
    }
}

@MainActor
private func handleServiceSuccess(with: DTO) {
    //process dto, update state here
    state = .success(viewData)
}

In the second approach I feel, loadFromService is detached from Mainthread and only when we need to update the UI, we switch back to the main thread.

3

Answers


  1. The short answer for your question:

    No. Your networkService.theServiceIneedHere() method is not forced to run on the main thread unless you mark it as @MainActor explicitly.

    So in your first example, the function in network service is executed on any thread and state is updated on main. @MainActor of your viewModel affects the scope of viewModel not nested async functions called by this class.

    Regarding your View:

    SwiftUI View body getter is already marked as MainActor in its protocol. So every code block in your body is executed on main thread even if you use Task {} since Task inherits the calling actor.


    First approach would be my preferred way. Because then you don’t need to switch to the main thread before updating any UI parameter (most likely @Published properties).

    I’m not familiar with the new @Observable yet but this is at least my suggestion for @ObservableObject since you store your properties as @Published and update them in view model.

    Login or Signup to reply.
  2. tl;dr

    It is generally preferable to isolate the whole view model to the main actor, ensuring compile-time validation of our code and ensuring that all interaction with the view takes place in the correct context (namely, the main actor).

    That having been said, you can do whatever you want, as long as you make sure that all access and updates happen on the main actor. But we generally want to enjoy compile-time verification of our code with the least syntactic noise. Your first example achieves that. And there is nothing there that will block the main thread. Your second example technically works, but adds unnecessary syntactic noise, sacrifices compile-time checks, and all for no observable benefit.


    As you know, when updating a property for the view, the goal is to make sure that it can be accessed from (and publish updates to) the main thread. And, to ensure thread-safety, that means that we want all updates of that property to happen on the main thread, too.

    In GCD, we achieve this by ensuring that everywhere we accessed or updated state, we would have to make sure we did that from the main thread (often sprinkling our code with DispatchQueue.main.async {…} everywhere we did so). We would have to use run-time diagnostics, like TSAN, to ensure we did not accidentally neglect to properly synchronize our access. Cautious programmers would also insert run-time checks to make sure we were on the main queue, too, with a dispatchPrecondition(.onQueue(.main)).

    Swift concurrency reduces this burden by shifting away from this run-time “make sure I am on the correct queue” to an actor isolation pattern that provides compile-time validation for us, as well as ensuring that at run-time, things happen on the correct thread/actor.

    So we will isolate the property, state to the main actor. Note, that the emphasis is on the isolation of the property, not the method. It is far more important to ensure that the property is isolated to the main actor than the actor isolation of the methods. This way, the compiler simply will not let you update the property from the incorrect isolation context. This lets us enjoy compile-time validation of our code (ensuring the correct run-time behavior), rather than relying on manual run-time burdens.

    That having been said, we generally would isolate the methods that update this property to the main actor, too, resulting in less syntactic noise. Furthermore, given that the view model’s entire purpose is to interact with the view, we generally isolate the whole view model to the main actor, further reducing syntactic noise. But the key is that to enjoy compile-time validation of our code, make sure the property is isolated to the main actor.


    For what it is worth, WWDC 2021’s Swift concurrency: Update a sample app walks through this whole process, shifting from GCD to MainActor.run and finally even retiring that in favor of main actor isolation.

    Also, SE-0316 Using global actors on a type says:

    It is common for entire types (and even class hierarchies) to predominantly require execution on the main thread, and for asynchronous work to be a special case. In such cases, the type itself can be annotated with a global actor, and all of the methods, properties, and subscripts will implicitly be isolated to that global actor.

    A view model is a perfect use case for this pattern.

    Finally, regarding ObservableObject, Apple explicitly suggests that these be isolated to the main actor in Discover concurrency in SwiftUI.


    A few clarifications in your text. You say:

    My question here is, if loadFromService is called from my SwiftUI view task method and my SwiftUI view is not marked as a @MainActor then is loadFromService() running on the MainActor thread? ie it is not isolated?

    Just to be clear, if the whole view model is isolated to the main actor (which is a fine/recommended practice), then loadFromService is also isolated to the main actor. Unlike GCD, it doesn’t matter where you called it from; the governing factor is that the whole type is isolated to the main actor and therefore its methods (except those explicitly designated as nonisolated) are also isolated to the main actor.

    You also said:

    In the second approach I feel, loadFromService is detached from [the main thread] and only when we need to update the UI, we switch back to the main thread.

    Sure, but this is not generally needed. This method is not doing anything that would block the main thread. The only thing relevant that it is doing is calling the network service method (but the presence of the await keyword means that this method is suspended while the network service does its work, meaning the main thread is free to ensure that the UI continues to be responsive).

    Worse, this second approach may even be slower (though perhaps not observably so). This is because the non-isolated async function will switch from the view’s main thread to a generic executor (see SE-0338), and then when you reach the await, it will likely switch to another asynchronous context.

    The only thing you need to be careful of is to ensure that the view model methods do nothing that is slow and synchronous. But if you have an await, then that is asynchronous, and you are generally in good shape.

    Login or Signup to reply.
  3. In SwiftUI you shouldn’t have any view models at all because that is what the View struct is designed for. Don’t put your funcs in classes, put them somewhere else, like a struct. In general in Swift avoid classes and aim to use structs first, see the docs: https://developer.apple.com/documentation/swift/choosing-between-structures-and-classes

    If your async func is in the View struct it runs on main thread, if it’s in the View struct but is marked nonisolated it runs on background thread, but the best place is in another struct because then will run on background thread by default.

    An EnvironmentKey struct is a good place for a bunch of service async funcs that return things because then you can mock them for Previews, e.g.

    struct ContentView {
    
        @Environment(.service) var service
        @State var results: [Result] = []
    
        var body: some View {
           ...
           .task {
                results = await service.fetchResults()
           }
        }
    }
    
    struct Service {
    
        // runs on background thread
        func fetchResults() async -> [Results] {
            return ...
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search