Reputation: 2158
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
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
Reputation: 437552
You said:
I … learned that
Task
inheritsMainActor
context, so I putTask.detached
to make sure that it’s another thread thanmain
.
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 fromasync
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
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