iosswiftconcurrencycontinuations

How to make async function using withCheckedContinuation either reentrant or prevent overwriting the continuation?


I have the following code:

class Locator : NSObject, ObservableObject
{
    private let locationManager: CLLocationManager
    private var authorizationContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?

    @Published var authorizationStatus: CLAuthorizationStatus
    @Published var location: CLLocation?
    @Published var error: Error?

    override init()
    {
        locationManager = CLLocationManager()
        authorizationStatus = locationManager.authorizationStatus

        super.init()

        locationManager.delegate = self
    }

    @MainActor func checkAuthorizationStatus() async -> CLAuthorizationStatus
    {
        let status = locationManager.authorizationStatus
        if status == .notDetermined
        {
            return await withCheckedContinuation
            { continuation in
                authorizationContinuation = continuation

                locationManager.requestWhenInUseAuthorization()
            }
        }
        else
        {
            authorizationStatus = status

            return status
        }
    }
}

extension Locator : CLLocationManagerDelegate
{
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager)
    {
        authorizationStatus = manager.authorizationStatus

        authorizationContinuation?.resume(returning: authorizationStatus)
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error)
    {
        print(error)
        
        self.error = error
        location = nil
    }
}

The function checkAuthorizationStatus() stores state in the form of authorizationContinuation.

If checkAuthorizationStatus() would be called a second time before authorizationContinuation?.resume(returning: authorizationStatus), this state will be overwritten and the first async call would never resume.

Is it possible that checkAuthorizationStatus() is called multiple times and that this state is overwritten? If so, can it be prevented, or is there some way around it?


Solution

  • In imperative codebase (e.g., UIKit), AsyncStream is the natural pattern to wrap the asynchronous sequence of authorization (and location) events. Perhaps something like this gist.

    But when dealing with a codebase following declarative patterns (i.e., observable objects), one can simply observe the @Published value, authorizationStatus and you are done. No need to await checkAuthorizationStatus (cutting the Gordian knot with respect to this saving/overwriting of the continuation).

    So, request authorization if not already determined, but you don't need to make this an asynchronous event, as your observed property will automatically propagate authorization status changes to your UI already.

    And, as discussed elsewhere, we would generally put the class with the location manager on the main actor (because, amongst other considerations, the documentation says CLLocationManager needs a runloop):

    @MainActor
    class ViewModel: NSObject, ObservableObject {
        let locationManager = CLLocationManager()
    
        @Published var error: Error?
        @Published var location: CLLocation?
        @Published var authorizationStatus: CLAuthorizationStatus = .notDetermined
    
        override init() {
            super.init()
    
            locationManager.delegate = self
            authorizationStatus = locationManager.authorizationStatus
        }
    
        func requestWhenInUseAuthorization() {
            locationManager.requestWhenInUseAuthorization()
        }
    
        func startUpdatingLocation() {
            locationManager.startUpdatingLocation()
        }
    }
    
    extension ViewModel: CLLocationManagerDelegate {
        func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
            self.error = error
        }
    
        func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
            self.location = locations.last
            if error != nil { self.error = nil }
        }
    
        func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
            self.authorizationStatus = manager.authorizationStatus
    
            if authorizationStatus == .notDetermined {
                requestWhenInUseAuthorization()
            }
        }
    }