Fundamentally, all data which the UI needs should be isolated to the main actor.
Your model (including ObservableObjects) should be @MainActor-isolated, and using a nonisolated which just dispatches to the main actor introduces unnecessary delays which makes your code harder to reason about.
When the framework invokes your body property or you get callbacks from a Button or something, that all occurs on the main actor, and those operations need to be able to read or modify that data synchronously. When you do that, no awaits are required (in theory; the SDK still has gaps in its concurrency support - but that doesn't mean you should do the wrong thing! There are workarounds...).
I've encountered a lot of developers who have misconceptions about Swift's language-integrated concurrency features, and what they can bring to their UI applications. It won't magically make your code scale better on multi-core machines, and it does not mean you should "async all the things" or chuck a bunch of Tasks everywhere. What it does is it allows you use the same kinds of concurrency features that you're used to from GCD or other concurrency libraries, but with added confidence that you are not introducing low-level data races, and with better support for Swift's control flow (such as throwing errors).
For instance, most developers are aware that network requests should occur on some non-main thread, as they can take arbitrarily long. Using GCD, they might write something like this:
var modelData: [Model] = ...
func refresh() {
DispatchQueue.global().async {
modelData = downloadModelData()
}
}
class View {
// reads modelData
}
The problem here is subtle - the view (which lives on the main thread) accesses modelData synchronously, as it must to display its contents. But then, within refresh, we modify modelData from a different thread. Simultaneous reading and assignment to the same memory location is a low-level data race.
Previously, some of these errors could be caught at runtime by the "Main Thread Checker". With Swift Concurrency and @MainActor annotations, all of these races will be caught, and they'll be caught at compile-time instead. I would say that is like... probably 90% of the benefit that authors of UI code will get from Swift concurrency.
@MainActor
var modelData: [Model] = ...
func refresh() async {
modelData = await downloadModelData()
//╰─ error: main actor-isolated var 'modelData' can not be mutated from a non-isolated context
}
func refresh() async {
let newData = await downloadModelData()
await MainActor.run { modelData = newData }
// OK 👌
}
And then at the top level, where some user interaction prompts the refresh operation to be launched, that's where you want to introduce an unstructured task, because refreshing model data is something happens concurrently with the view's normal operation, and so it makes sense for it to be async from the perspective of the view.
Button {
Task.detached { await refresh() }
}
And that last sentence is important: things which are async from the view's perspective are things which make sense to occur concurrently with its normal operation. On the other hand, things like this:
Button {
Task {
await viewModel.buttonTapped()
}
}
suggest to me that the modelling is probably not quite correct. Reacting to a button tap is not something that happens concurrently with a view's normal operation -- it is the view's normal operation! It should be able to read and modify the view's state synchronously, which means buttonTapped should be synchronous and isolated to the main actor (or in the unlikely case that it touches no view-relevant state, it may be non-isolated).
To put it another way, if you're familiar with Obj-C, an async buttonTapped method is essentially equivalent to this:
func buttonTapped(completionHandler: @escaping () -> Void) {
DispatchQueue.global().async {
...
completionHandler()
}
}
Most applications don't do things like that for simple button handlers; it's too much asynchronicity and makes your code harder to reason about by introducing unnecessary delays in to your code.