I've built an architecture that just isn't using structured concurrency right, and I'm looking for insight how to clean it up.
My content view looks much like this; lots of application state code that won't be executed any more isn't shown. The focus of my question is the onReceive part, and specifically the Task. The getWeather() function called in the Task is something else I wrote, shown further down. I've stripped enough out that - I think - this is quickly readable, but I recognize it won't compile as-is. I'm hoping that answering my architecture-level question below won't require that level of detailed investigation.
struct ContentView: View {
@ObservedObject var weatherKitManager = WeatherKitManager()
var stateManager: StateManager! = nil
@ObservedObject var database = WeatherDatabase()
@State var now: Date = Date()
init() {
stateManager = StateManager(database: database, weatherKitManager: weatherKitManager)
}
let updateTimer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var home: HMHome? {homeManager?.homes.first}
var sensorReady: Bool {home != nil && (home?.accessories.first {$0.name == desiredSensor}) != nil}
var body: some View {
DisplayLocalData(weatherSensor: stateManager.weatherSensor!)
.onReceive(updateTimer) {_ in
now = Date()
if stateManager.sensorReady {stateManager.setupDatabase(database)}
Task {
while true {
_ = try? await weatherKitManager.getWeather()
sleep(1)
}
}
}
}
}
Within the WeatherKitManager
(it's part of a framework I wrote to simplify WeatherKit access), the getWeathe
r function looks like the following (the weather
var is delclared in WeatherKitManager
). The essential idea is to return the unchanged copy of weather
until the current forecast is out of date, then get a new copy of the object.
public func getWeather() async throws -> Weather {
let now = Date()
if now > nextPoll {
do {
weather = try await Task.detached(priority: .userInitiated) {
return try await WeatherService.shared.weather(for: self.location)
}.value
if let weather {nextPoll = weather.currentWeather.metadata.expirationDate}
} catch {
throw error
}
}
if let weather {return weather}
else {throw WeatherAccessError.unavailable}
}
Effectively, the ContentView
is polling. This seemed clumsy, but with the sleep(1)
call it didn't chew a lot of energy from the battery. It came to matter, though, when I decided to add a button that would bring up a dialog. Having done that, DisplayLocalData
looks like the following; ValueView
and BatteryView
are part of the app:
struct DisplayLocalData: View {
@ObservedObject var weatherSensor: WeatherSensor
@State private var isShowingSheet = false
var body: some View {
HStack {
ValueView(title: "Temperature", characteristic: weatherSensor.temperature, suffix: " °F", precision: 1)
ValueView(title: "Humidity", characteristic: weatherSensor.humidity, suffix: "%", precision: 0)
BatteryView(weatherSensor: weatherSensor)
Button("Graphs", action: {isShowingSheet.toggle()})
.buttonStyle(.bordered)
.foregroundColor(.primary)
.sheet(isPresented: $isShowingSheet) {
GraphsView(isShowingSheet: $isShowingSheet, weatherSensor: weatherSensor)
}
}
}
}
I made a simple version of GraphsView
, like this:
struct GraphsView: View {
@Binding var isShowingSheet: Bool
var weatherSensor: WeatherSensor
var body: some View {
Text(weatherSensor.sensor.name)
Button("Close", action: {isShowingSheet.toggle()})
.buttonStyle(.bordered)
}
}
The project compiles and executes in an iPhone 15 Pro simulator, and on My iMac (Designed for iPad), and it works as intended. When I touch the button, however, the cursor becomes a beach ball and sometimes never completes the operation; other times it does display/hide the sheet after a lengthy delay.
So my question is this: I suspect it's spawning a lot of tasks - perhaps one roughly every second due to the polling, What's the right way to have the view updated only when the weather
var is changed due to the operation of a detached task, and to have only one such task?
Thanks
Barry