swiftswiftuiwidgetkitweatherkit

Multiple function calls, but single execution of async Task


I am playing around with Apple's new WeatherKit + WidgetKit. Unfortunately I can't seem to find a solution for the combination of the following three problems:

  1. WidgetKit requires to load all data in the getTimeline function (no dynamic updating of the UI)
  2. WidgetKit for some reason calls getTimeline twice or more when loading (Developer Forums)
  3. I want to keep my WeatherKit requests at a minimum

The first two problems above force me to fetch weather data in a function I have no control over (getTimeline).

My function to get weather already caches the Weather object and makes sure to only request new data if the cache is too old.

private func getWeather() async -> Weather? {
    // if cachedWeather is not older than 2 hours return it instead of fetching new data
    if let cachedWeather = self.cachedWeather,
        cachedWeather.currentWeather.date > Date().addingTimeInterval(-7200)  {
        return cachedWeather
    }

    return try? await Task { () -> Weather in
        let fetchedWeather = try await WeatherService.shared.weather(for: self.location)
        cachedWeather = fetchedWeather
        return fetchedWeather
    }.value
}

If I call getWeather() from within getTimeline, it might get called twice or more at roughly the same time though. As long as the first Task has not finished yet, the cachedWeather is still empty/outdated. This leads to multiple executions of the Task which in turn means multiple requests are sent to Apple.

In a normal SwiftUI view in an app, I'd work with something like an ObservableObject and only trigger a request in getWeather() if none is running already. The UI would be updated based on the ObservableObject. In WidgetKit this is not possible as mentioned above.

Question: Can someone help me figure out how to trigger the Task in getWeather() on the first call and if the task is already/still running when the second getWeather() call comes in, use the already running Task instead of triggering a new one?


Solution

  • If I'm understanding the question correctly, this is what an actor is for. Try this:

    import UIKit
    
    actor MyActor {
        var running = false
        func doYourTimeConsumingThing() async throws {
            guard !running else { print("oh no you don't"); return }
            running = true
            print("starting at", Date.now.timeIntervalSince1970)
            try await Task.sleep(nanoseconds: 5_000_000_000) // real task goes here
            print("finished at", Date.now.timeIntervalSince1970)
            running = false
        }
    }
    
    class ViewController: UIViewController {
        let actor = MyActor()
        var timer: Timer?
        override func viewDidLoad() {
            super.viewDidLoad()
            // Do any additional setup after loading the view.
            timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
                Task { [weak self] in
                    try? await self?.actor.doYourTimeConsumingThing()
                }
            }
        }
    }
    

    As you'll see, the timer tries to start the task every second, but if the task is running, the attempt is turned back; you can only start the task if it isn't already running. The actor makes all this perfectly safe and thread-coherent.


    With regard to your comment:

    The thing missing is, that if the timeConsumingThing is called while running, I still need the result eventually... Ideally a second call would just „subscribe“ to the same running async Task.

    I think we can emulate this by adding an actual publish-and-subscribe to the mix. First, let me separate out the actual task and make it return a result; this is supposed to be your WeatherKit interaction:

    func timeConsumingTaskWithResult() async throws -> Date {
        try await Task.sleep(nanoseconds: 5_000_000_000)
        return Date.now
    }
    

    Now I'll revise the actor slightly so that new callers are forced to wait for the next result to come back from the latest WeatherKit interaction:

    actor MyActor {
        var running = false
        @Published var latestResult: Date?
        func doYourTimeConsumingThing() async throws -> Date? {
            if !running {
                running = true
                latestResult = try await timeConsumingTaskWithResult()
                running = false
            }
            for await result in $latestResult.values {
                return result
            }
            fatalError("shut up please, compiler")
        }
    }
    

    Finally, the test bed is much as before, but now I'm getting a result for the call made on each firing of the timer, and I'll print it when I get it:

    class ViewController: UIViewController {
        let actor = MyActor()
        var timer: Timer?
        override func viewDidLoad() {
            super.viewDidLoad()
            timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
                Task { [weak self] in
                    print("calling at", Date.now)
                    if let result = try? await self?.actor.doYourTimeConsumingThing() {
                        print("RESULT!", result)
                    }
                }
            }
        }
    }
    

    That yields:

    calling at 2022-08-28 15:35:39 +0000
    calling at 2022-08-28 15:35:40 +0000
    calling at 2022-08-28 15:35:41 +0000
    calling at 2022-08-28 15:35:42 +0000
    calling at 2022-08-28 15:35:43 +0000
    calling at 2022-08-28 15:35:44 +0000
    RESULT! 2022-08-28 15:35:45 +0000
    calling at 2022-08-28 15:35:45 +0000
    calling at 2022-08-28 15:35:46 +0000
    RESULT! 2022-08-28 15:35:45 +0000
    calling at 2022-08-28 15:35:47 +0000
    RESULT! 2022-08-28 15:35:45 +0000
    calling at 2022-08-28 15:35:48 +0000
    RESULT! 2022-08-28 15:35:45 +0000
    calling at 2022-08-28 15:35:49 +0000
    RESULT! 2022-08-28 15:35:45 +0000
    calling at 2022-08-28 15:35:50 +0000
    RESULT! 2022-08-28 15:35:45 +0000
    RESULT! 2022-08-28 15:35:50 +0000
    calling at 2022-08-28 15:35:51 +0000
    calling at 2022-08-28 15:35:52 +0000
    RESULT! 2022-08-28 15:35:50 +0000
    calling at 2022-08-28 15:35:53 +0000
    RESULT! 2022-08-28 15:35:50 +0000
    calling at 2022-08-28 15:35:54 +0000
    RESULT! 2022-08-28 15:35:50 +0000
    calling at 2022-08-28 15:35:55 +0000
    RESULT! 2022-08-28 15:35:50 +0000
    calling at 2022-08-28 15:35:56 +0000
    RESULT! 2022-08-28 15:35:50 +0000
    RESULT! 2022-08-28 15:35:57 +0000
    calling at 2022-08-28 15:35:57 +0000
    calling at 2022-08-28 15:35:58 +0000
    RESULT! 2022-08-28 15:35:57 +0000
    calling at 2022-08-28 15:35:59 +0000
    RESULT! 2022-08-28 15:35:57 +0000
    calling at 2022-08-28 15:36:00 +0000
    RESULT! 2022-08-28 15:35:57 +0000
    calling at 2022-08-28 15:36:01 +0000
    RESULT! 2022-08-28 15:35:57 +0000
    calling at 2022-08-28 15:36:02 +0000
    RESULT! 2022-08-28 15:35:57 +0000
    RESULT! 2022-08-28 15:36:02 +0000
    calling at 2022-08-28 15:36:03 +0000
    

    As you can see, someone calls into our actor every second. Every caller eventually gets a result, and they are all the same result, 2022-08-28 15:35:45, because that is the time when the time-consuming task returned. From then on, the more recent callers all start getting 2022-08-28 15:35:50, because that is the time when the next time-consuming task returned. The call to the time consuming task, as in my earlier example, is gated so that it cannot be called until it has returned from its previous call.