I'm not doing concurrency right - need help

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 getWeather 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

Would you be able to present the code, minus the SwiftUI things, in a simple command line program? This will make it easier for others to better understand the code.

So, there are a lot of different ways to do this, but a key problem seems to be that you are initiating your data update in your ContentView. You should definitely move that elsewhere. Maybe into your StateManager?

I don't think the code snippets you provided are enough for us to provide much meaningful help, but presumably Weather is a View (or ViewBuilder), so it should just bind (observe) properties of your observable objects. The timer you run should just be updating those properties.

Hope this helps! Good luck!!

The reason this is good advice is that it'll help you separate out the parts of your code that need concurrency (updating your weather data) from the UI code which should not use it.

1 Like

The source code is on GitHub if you’d be OK working with that. I could make the projects public and give you links to both the app and the framework, and because you could just view the source code there that seems like a good use of your time. Do you think that’s a workable way to do this?

Barry
feldur@wasatchbroadband.com

I only look at your ContentView code. The direct reason for the many background tasks is due to the while true loop. Why did you add the loop? (Note that the timer is a repeated one.) If the purpose is to run an async func when receiving an asynchrous event I'd suggest to look into Task(id:priority:_:) (see 1, 2. I don't use Concurrency often, but I suppose this is the idiomatic way to do it.).

BTW, your current code's a bit hard to follow and maintain. IIUIC you're trying the achieve the following:

  • There is a weather data store
  • Initialize the data store when sensor is ready
  • Start a repeated timer. Call an async func to get weather data when the timer expires.
  • Update the ContentView when retrieving new weather data.

Almost all requirements (except the last one, which is supported by @Published) don't relate to UI and should be implemented in one (or multiple) observable objects.

1 Like

TYVM for the insight. I’ll see if I can follow through