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
The short answer for your question:
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:
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.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 withDispatchQueue.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 adispatchPrecondition(.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:
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:
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 asnonisolated
) are also isolated to the main actor.You also said:
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 theawait
, 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.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-classesIf your async
func
is in theView
struct it runs on main thread, if it’s in theView
struct but is markednonisolated
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.