swiftswiftuiasync-await

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?


Solution

  • 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.