AsyncSequence and AsyncStream are mechanisms to model an asynchronous stream of values using Swift’s native structured concurrency. As a fan of functional reactive programming and functional composition but one who winces at the syntactic complexity of Combine or Rx then this has a lot of appeal.

This post is a brain dump that documents an afternoon spent noodling about with CLLocationManager and AsyncStream. Credit to Andy Ibanez who’s comprehensive post on concurrency inspired this effort.

The goal was to write code like:

let altitudes = locations()
    .filter { $0.verticalAccuracy < kCLLocationAccuracyNearestTenMeters }
    .map { $0.altitude }

for await altitude in altitudes {
    print(altitude)
}

This is achieved with the following function. The use of a function rather than e.g. a wrapper class is to eliminate shared state (within my code at least - who knows what Apple is doing?!) and reduce the potential for side-effects.

// CLLocationManager doesn't appear to function correctly off the main thread.
@MainActor
func locations() -> AsyncStream<CLLocation> {
    let locationManager = CLLocationManager()
    let locationHandler = LocationHandler()
    locationManager.delegate = locationHandler

    var shouldStart = false
    var continuation: AsyncStream<CLLocation>.Continuation?

    locationHandler.didChangeAuthorization = { locationManager in
        if locationManager.authorizationStatus == .denied {
            continuation?.finish()
        } else if shouldStart {
            shouldStart = false
            locationManager.startUpdatingLocation()
        }
    }

    locationHandler.didFailWithError = { locationManager, error in
        if locationManager.authorizationStatus != .notDetermined {
            continuation?.finish()
        }
    }

    locationHandler.didUpdateLocations = { locationManager, locations in
        locations.forEach { continuation?.yield($0) }
    }

    return AsyncStream<CLLocation> {
        continuation = $0

        $0.onTermination = { @Sendable _ in
            // Use this escaping closure to retain these objects whilst the stream is active.
            // This appears to break the Sendable constraint since these reference types are not isolated.
            _ = locationHandler
            _ = locationManager
        }

        if locationManager.authorizationStatus == .notDetermined {
            shouldStart = true
            locationManager.requestWhenInUseAuthorization()
        } else {
            locationManager.startUpdatingLocation()
        }
    }

}

// MARK: -

private class LocationHandler: NSObject, CLLocationManagerDelegate {

    var didChangeAuthorization: (CLLocationManager) -> Void = { _ in }
    var didFailWithError: (CLLocationManager, Error) -> Void = { _, _ in }
    var didUpdateLocations: (CLLocationManager, [CLLocation]) -> Void = { _, _ in }

    override init() {
    }

    deinit {
        debugPrint(type(of: self), #function)
    }

    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        didChangeAuthorization(manager)
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        didFailWithError(manager, error)
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        didUpdateLocations(manager, locations)
    }

}

The above really isn’t intended to be production code.

Suggestions for future work include:

  • Replacing the use of CLLocation with an immutable value type.
  • Devising a mechanism to throw errors; a rather severe limitation of AsyncStream when compared with Combine or Rx.
  • Investigate the behaviour of awaiting streams when the containing Task is cancelled.

Tags:

Categories:

Updated: