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.
CodePudding user response:
You can fix it by removing the class. You aren't using Combine so you don't need its ObservableObject
and SwiftUI is most efficient if you stick to value types. The button doesn't hang with this design:
extension String {
static func makeItems() async -> [String]{
var items = [String]()
for i in 0 ..< 4_000_000 { /// this usually takes a couple seconds
items.append("\(i)")
}
return items
}
}
struct AnimateContentView: View {
@State var rotation = CGFloat(0)
@State var items = [String]()
var body: some View {
Button {
Task {
items = await String.makeItems()
}
} label: {
Color.blue
.frame(width: 300, height: 80)
.overlay(
Text("\(items.count)")
.foregroundColor(.white)
)
.rotationEffect(.degrees(rotation))
}
.onAppear { /// should be a continuous rotation effect
withAnimation(.easeInOut(duration: 2).repeatForever()) {
rotation = 90
}
}
}
}
CodePudding user response:
First, you can't have it both ways; Either you perform your CPU intensive work on the main thread (and have a negative impact on the UI) or you perform the work on another thread, but you need to explicitly dispatch the UI update onto the main thread.
However, what you are really asking about is
(By using
Task
) I thought this would execute populate on a background thread but instead the entire UI freezes.
When you use a Task
you are using unstructured concurrency, and when you initialise your Task
via init(priority:operation) the task ... inherits the priority and actor context of the caller.
While the Task
is executed asynchronously, it does so using the actor context of the caller, which in the context of a View
body
is the main actor. This means that while your task is executed asynchronously, it still runs on the main thread and that thread is not available for UI updates while it is processing. So you are correct, this has everything to do with MainActor
.
When you move the Task
into populate
it is no longer being created in a MainActor
context and therefore does not execute on the main thread.
As you have discovered, you need to use this second approach to avoid the main thread. All you need to do to your code is add the dispatch back to the main queue:
func populate() {
Task {
var items = [String]()
for i in 0 ..< 4_000_000 {
items.append("\(i)")
}
DispatchQueue.main.async {
self.items = items
}
}
}
You could also use Task.detached()
in the body context to create a Task
that is not attached the MainActor
context.