Home > Enterprise >  Having an @EnvironmentObject in the view causes Tasks to be executed on the main thread
Having an @EnvironmentObject in the view causes Tasks to be executed on the main thread

Time:10-26

I ran into an issue where my async functions caused the UI to freeze, even tho I was calling them in a Task, and tried many other ways of doing it (GCD, Task.detached and the combination of the both). After testing it I figured out which part causes this behaviour, and I think it's a bug in Swift/SwiftUI.

Bug description

I wanted to calculate something in the background every x seconds, and then update the view, by updating a @Binding/@EnvironmentObject value. For this I used a timer, and listened to it's changes, by subscribing to it in a .onReceive modifier. The action of this modifer was just a Task with the async function in it (await foo()). This works like expected, so even if the foo function pauses for seconds, the UI won't freeze BUT if I add one @EnvironmentObject to the view the UI will be unresponsive for the duration of the foo function.

GIF of the behaviour with no EnvironmentVariable in the view:

Correct, expected behaviour

GIF of the behaviour with EnvironmentVariable in the view:

Incorrect, unresponsive behaviour

Minimal, Reproducible example

This is just a button and a scroll view to see the animations. When you press the button with the EnvironmentObject present in the code the UI freezes and stops responding to the gestures, but just by removing that one line the UI works like it should, remaining responsive and changing properties.

import SwiftUI

class Config : ObservableObject{
    @Published var color : Color = .blue
}

struct ContentView: View {
    //Just by removing this, the UI freeze stops
    @EnvironmentObject var config : Config
    
    @State var c1 : Color = .blue
    
    var body: some View {
        ScrollView(showsIndicators: false) {
            VStack {
                HStack {
                    Button {
                        Task {
                            c1 = .red
                            await asyncWait()
                            c1 = .green
                        }
                    } label: {
                        Text("Task, async")
                    }
                    .foregroundColor(c1)
                }
                ForEach(0..<20) {x in
                    HStack {
                        Text("Placeholder \(x)")
                        Spacer()
                    }
                    .padding()
                    .border(.blue)
                }
            }
            .padding()
        }
    }
    
    func asyncWait() async{
        let continueTime: Date = Calendar.current.date(byAdding: .second, value: 2, to: Date())!
        while (Date() < continueTime) {}
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Disclaimer

I am fairly new to using concurrency to the level I need for this project, so I might be missing something, but I couldn't find anything related to the searchwords "Task" and "EnvironmentObject".

Question

Is this really a bug? Or am I missing something?

CodePudding user response:

As far as I can tell, your code, with or without the @EnvrionmentObject, should always block the main thread. The fact that it doesn't without @EnvironmentObject may be a bug, but not the other way around.

In your example, you block the main thread -- you call out to an async function that runs on the context inherited from its parent. Its parent is a View, and runs on the main actor.

Usually in this situation, there's confusion about what actually runs something outside of the inherited context. You mentioned using Task.detached, but as long as your function was still marked async on the parent, with no other modifications, in would still end up running on the main actor.

To avoid inheriting the context of the parent, you could, for example, mark it as nonisolated:

nonisolated func asyncWait() async {
    let continueTime: Date = Calendar.current.date(byAdding: .second, value: 2, to: Date())!
    while (Date() < continueTime) {}
}

Or, you could move the function somewhere (like to an ObservableObject outside of the View) that does not explicitly run on the main actor like the View does.

Note that there's also a little bit of deception here because you've marked the function as async, but it doesn't actually do any async work -- it just blocks the context that it's running on.

  • Related