I have an ObservableObject
class and a SwiftUI view. When a button is tapped, I create a Task
and call populate
(an async function) from within it. I thought this would execute populate
on a background thread but instead the entire UI freezes. Here's my code:
class ViewModel: ObservableObject {
@Published var items = [String]()
func populate() async {
var items = [String]()
for i in 0 ..< 4_000_000 { /// this usually takes a couple seconds
items.append("\(i)")
}
self.items = items
}
}
struct ContentView: View {
@StateObject var model = ViewModel()
@State var rotation = CGFloat(0)
var body: some View {
Button {
Task {
await model.populate()
}
} label: {
Color.blue
.frame(width: 300, height: 80)
.overlay(
Text("\(model.items.count)")
.foregroundColor(.white)
)
.rotationEffect(.degrees(rotation))
}
.onAppear { /// should be a continuous rotation effect
withAnimation(.easeInOut(duration: 2).repeatForever()) {
rotation = 90
}
}
}
}
Result:

The button stops moving, then suddenly snaps back when populate
finishes.
Weirdly, if I move the Task
into populate
itself and get rid of the async
, the rotation animation doesn't stutter so I think the loop actually got executed in the background. However I now get a Publishing changes from background threads is not allowed
warning.
func populate() {
Task {
var items = [String]()
for i in 0 ..< 4_000_000 {
items.append("\(i)")
}
self.items = items /// Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
}
}
/// ...
Button {
model.populate()
}
Result:

How can I ensure my code gets executed on a background thread? I think this might have something to do with MainActor
but I'm not sure.
First, as a general observation, in WWDC 2021’s Discover concurrency in SwiftUI, they recommend that you isolate the
ObservableObject
object to the main actor.But the hitch in the UI is caused by the main actor being blocked by this slow process. So we must get this task off the main actor. There are a few possible approaches:
You can move the slow synchronous process to a “detached” task. While
Task {…}
starts a new top-level task “on behalf of the current actor”, a detached task is an “unstructured task that’s not part of the current actor”. So,detached
task will avoid blocking the current actor:Note, while this solves the blocking problem, unfortunately,
Task.detached {…}
(likeTask {…}
) is unstructured concurrency. So, this is probably not a pattern I would generally recommend (unless you wrap it in anwithTaskCancellationHandler
). But see more on cancelation under point 4, below.As of Swift 5.7, one can achieve the same behavior with an
async
function that isnonisolated
(see SE-0338). And this keeps us within the realm of structured concurrency, but still gets the work off the current actor:Or we can do this with a separate
actor
for the time-consuming process, which again gets the task off the view model’s actor:I would advise adding cancelation logic (in case the user wants to interrupt the calculation and start another) with
try Task.checkCancellation()
.Also, in Swift concurrency, we should never violate the contract to “ensure forward progress”, or, if you must, periodically
Task.yield
to ensure proper function of this concurrency system. As SE-0296 says:Now, the previously mentioned techniques (points 1-3, above) address your primary concern by prevent the blocking of the main actor. But the deeper observation here is that we really should avoid blocking any actors with “long running” work. But
Task.yield
addresses that problem.Anyway, considering the
nonisolated
example, we can handle cancelation and interleaving with:Or the
actor
approach would follow the same pattern:This periodic checking for cancelation and yielding is only needed when writing our own computationally intensive tasks. Most of Apple‘s
async
API (e.g.URLSession
, etc.), already handle these issues for us.Anyway, all of this discussion on cancelation begs the question of how one would go about canceling a prior task. Simply save the
Task
in a property of the actor-isolated view model and then cancel the prior one before starting the next. E.g.:Anyway, these patterns will allow the slow process to not block the main thread, resulting in an uninterrupted UI. Here I tapped on the button twice:
Needless to say, that is without the “cancel prior one” logic. With that logic, you can tap multiple times, all the prior once will be canceled, and you will see only one update, avoiding potentially over taxing the system with a bunch of redundant tasks. But the idea is the same, an smooth UI while performing complex tasks.
See WWDC 2021 videos Swift concurrency: Behind the scenes, Protect mutable state with Swift actors, and Swift concurrency: Update a sample app, all of which are useful when trying to grok the transition from GCD to Swift concurrency.