Dmitry
Dmitry

Reputation: 2158

SwiftUI safe update state variables from task

Here is my view :

import SwiftUI

struct ContentView: View {
    private let weatherLoader = WeatherLoader()
    
    @State private var temperature = ""
    @State private var pressure = ""
    @State private var humidity = ""
    @State private var tickmark = ""
    @State private var refreshable = true
    
    var body: some View {
        GeometryReader { metrics in
            VStack(spacing: 0) {
                Grid(horizontalSpacing: 0, verticalSpacing: 0) {
                    GridRow {
                        Text("Температура")
                            .frame(width: metrics.size.width/2)
                        Text("\(temperature) °C")
                            .frame(width: metrics.size.width/2)
                    }.frame(height: metrics.size.height*0.8*0.25)
                    
                    GridRow {
                        Text("Давление")
                            .frame(width: metrics.size.width/2)
                        Text("\(pressure) мм рт ст")
                            .frame(width: metrics.size.width/2)
                    }.frame(height: metrics.size.height*0.8*0.25)

                    GridRow {
                        Text("Влажность")
                            .frame(width: metrics.size.width/2)
                        Text("\(humidity) %")
                            .frame(width: metrics.size.width/2)
                    }.frame(height: metrics.size.height*0.8*0.25)

                    GridRow {
                        Text("Дата обновления")
                            .frame(width: metrics.size.width/2)
                        Text("\(tickmark)")
                            .frame(width: metrics.size.width/2)
                    }.frame(height: metrics.size.height*0.8*0.25)
                }.frame(height: metrics.size.height*0.8)
                
                Button("Обновить") {
                    refreshable = false
                    print("handler : \(Thread.current)")
                    Task.detached {
                        print("task : \(Thread.current)")
                        let result = await weatherLoader.loadWeather()
                        await MainActor.run {
                            print("main actor: \(Thread.current)")
                            switch result {
                            case .success(let item):
                                temperature = item.temperature
                                pressure = item.pressure
                                humidity = item.humidity
                                tickmark = item.date
                            case .failure:
                                temperature = ""
                                pressure = ""
                                humidity = ""
                                tickmark = ""
                            }
                            
                            refreshable = true
                        }
                    }
                }
                .disabled(!refreshable)
                .padding()
            }
        }.frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .frame(width: 320, height: 240)
    }
}

The question is - what is the right way to update @State variables from async context? I see that there is no failure if I get rid of MainActor.run but when dealing with UIKit we must call this update from main thread. Does it differ here? I also learned that Task inherits MainActor context, so I put Task.detached to make sure that it's another thread than main. Could anyone make it clear for me?

Upvotes: 3

Views: 2975

Answers (3)

malhal
malhal

Reputation: 30575

In SwiftUI, we use async/await from .task, not from Button. We can also update @State vars fine inside it. Try this instead:

@Environment(\.weatherLoader) var weatherLoader
@State var loading = false
@State var result: Result<Item>? = nil
...

Button(loading ? "Cancel" : "Load") {
    loading.toggle()
}
.task(id: loading) {
    if !loading {
        return 
    }
    result = await weatherLoader.loadWeather()
    loading = false
}

.task will run when the value of loading changes and is cancelled if the view disappears. If you want to cancel the task and restart it every button press you could change refresh from a bool to a refreshCounter and increment it on the button press.

Hopefully weatherLoader is a struct because View structs shouldn't init objects, it's a memory leak.

Upvotes: 2

Rob
Rob

Reputation: 437552

You said:

I … learned that Task inherits MainActor context, so I put Task.detached to make sure that it’s another thread than main.

The end goal, to make sure you are updating your properties on the main actor (and thus the main thread) is correct. These must be updated from the main actor.

That having been said, you do not need to (nor do you want to) use a detached task. When the main actor hits an await, that particular path of execution is suspended, and the main actor is free to carry on doing other things until the loadWeather is done. The await doesn’t block the current thread, but rather frees it to go do other things. This eliminates all of the GCD silliness of “let me dispatch this to some background queue, and when that’s done, dispatch the update back to the main queue.”

So, consider:

Button("Обновить") {
    refreshable = false
    Task.detached {
        let result = await weatherLoader.loadWeather()
        await MainActor.run {
            switch result {
            case .success(let item):
                temperature = item.temperature
                …
            case .failure:
                temperature = ""
                …
            }
            
            refreshable = true
        }
    }
}

This should be simplified to:

Button("Обновить") {
    refreshable = false
    Task {
        let result = await weatherLoader.loadWeather()
        
        switch result {
        case .success(let item):
            temperature = item.temperature
            …
        case .failure:
            temperature = ""
            …
        }
        
        refreshable = true
    }
}

The only time you need to use a detached task is when you have some slow synchronous task that you need to get off the current actor.

But that’s not what is going on here. You have an asynchronous loadWeather API, which you await. So you can use Task {…}, which runs the task on the current actor (i.e., the main actor).


So, you ask:

The question is - what is the right way to update @State variables from async context?

The right solution is to mark these properties as @MainActor, and then the compiler will warn you if you ever try to update them from the wrong context.

Or, rather than marking the individual properties as @MainActor, I personally pull all of this logic out of the view, and put it on a view model which is isolated to the main actor. E.g.:

@MainActor
class ViewModel: ObservableObject {
    @Published var temperature = ""
    @Published var pressure = ""
    @Published var humidity = ""
    @Published var tickmark = ""
    @Published var refreshable = true

    private let weatherLoader = WeatherLoader()

    func update() async {
        refreshable = false

        let result = await weatherLoader.loadWeather()

        switch result {
        case .success(let item):
            temperature = item.temperature
            pressure = item.pressure
            humidity = item.humidity
            tickmark = item.date
        case .failure:
            temperature = ""
            pressure = ""
            humidity = ""
            tickmark = ""
        }

        refreshable = true
    }
}

struct ContentView: View {
    @StateObject var viewModel = ViewModel()

    var body: some View {
        GeometryReader { metrics in
            VStack(spacing: 0) {
                Grid(horizontalSpacing: 0, verticalSpacing: 0) {
                    GridRow {
                        Text("Температура")
                            .frame(width: metrics.size.width/2)
                        Text("\(viewModel.temperature) °C")
                            .frame(width: metrics.size.width/2)
                    }.frame(height: metrics.size.height*0.8*0.25)

                    GridRow {
                        Text("Давление")
                            .frame(width: metrics.size.width/2)
                        Text("\(viewModel.pressure) мм рт ст")
                            .frame(width: metrics.size.width/2)
                    }.frame(height: metrics.size.height*0.8*0.25)

                    GridRow {
                        Text("Влажность")
                            .frame(width: metrics.size.width/2)
                        Text("\(viewModel.humidity) %")
                            .frame(width: metrics.size.width/2)
                    }.frame(height: metrics.size.height*0.8*0.25)

                    GridRow {
                        Text("Дата обновления")
                            .frame(width: metrics.size.width/2)
                        Text("\(viewModel.tickmark)")
                            .frame(width: metrics.size.width/2)
                    }.frame(height: metrics.size.height*0.8*0.25)
                }.frame(height: metrics.size.height*0.8)

                Button("Обновить") {
                    Task { await viewModel.update() }
                }
                .disabled(!viewModel.refreshable)
                .padding()
            }
        }.frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

Frankly, the integration with some third-party web service does not belong in the view anyway.

You then ask:

I see that there is no failure if I get rid of MainActor.run but when dealing with UIKit we must call this update from main thread. Does it differ here?

No, it is the same here. It must update on the main thread. If you use the main actor, that will ensure you use the main thread. You do not need MainActor.run. (And nine times out of ten, the use of MainActor.run is a mistake, and is offer better achieved by getting properties and methods on the correct actor in the first place.)

Upvotes: 0

Ashley Mills
Ashley Mills

Reputation: 53111

If you run a task using

Task { @MainActor in
    //
}

then the code within the Task itself will run on the main queue, but any async calls it makes can run on any queue.

Adding an implementation of WeatherLoader as follows:

class WeatherLoader {
    
    func loadWeather() async throws -> Item {
        print("load : \(Thread.current)")
        try await Task.sleep(nanoseconds: 1_000_000_000)
        return Item()
    }
}

and then calling like:

print("handler : \(Thread.current)")
Task { @MainActor in
    print("task : \(Thread.current)")
    do {
        let item = try await weatherLoader.loadWeather()
        print("result : \(Thread.current)")
        temperature = item.temperature
        pressure = item.pressure
        humidity = item.humidity
        tickmark = item.date
    } catch {
        temperature = ""
        pressure = ""
        humidity = ""
        tickmark = ""
    }
        
    refreshable = true
}

you'll see something like

handler : <_NSMainThread: 0x6000000f02c0>{number = 1, name = main}
task : <_NSMainThread: 0x6000000f02c0>{number = 1, name = main}
load : <NSThread: 0x6000000a1e00>{number = 6, name = (null)}
result : <_NSMainThread: 0x6000000f02c0>{number = 1, name = main}

As you can see, handler and task run on the main queue, load runs on some other, and then result is back on the main queue. As the code within the Task itself is guaranteed to run on the main queue, it's safe to update State variables from there.

As you mention above, @MainActor in isn't actually required in this case, Task(priority:operation:) inherits the priority and actor context of the caller.

However, running a task using

Task.detached(priority: .background) {
    //
}

gives an output like:

handler : <_NSMainThread: 0x600003a28780>{number = 1, name = main}
task : <NSThread: 0x600003a6fe80>{number = 8, name = (null)}
load : <NSThread: 0x600003a6fe80>{number = 8, name = (null)}
result : <NSThread: 0x600003a7d100>{number = 6, name = (null)}

handler runs on the main queue,then task and load run on some other, and then interestingly result is on an entirely different one again after loadWeather() returns.

After all that, in answer your question "why do not I see a crash if I use .detach for Task and get rid of MainActor.run in my code?", presumably this is because your ContentView is a value type, and therefore thread safe. If you move your @State properties to @Published in WeatherLoader, then you will get background thread warnings.

Upvotes: 5

Related Questions