We’ve now learned about several ways to iterate over incoming values over time. We can create a custom AsyncSequence, but we’ve already learned that it’s easier to use an AsyncStream. End of this lesson!

Well, that would be easy. I’d love to give you a bit more guidance, since there are more options in Swift Concurrency. Being aware of these, allows you to pick what works best for your usecase.

When to use an AsyncSequence?

Honestly, I don’t expect many of you to ever write a custom asynchronous sequence. To me, the question is similar to asking you whether you’ve ever defined a custom Sequence in Swift. The protocol mostly exists for standard types like a TaskGroup or AsyncStream to have an interface to provide values over time. Yet, you do know how to build one now, so in case you find a scenario, you’re set up for success.

This does not mean you’ll never use an AsyncSequence. Your interaction with them will be on a higher level, indirect. In cases where you need to iterate over values using a for loop, like with a task group or asynchronous stream as we demonstrated before.

When to use an AsyncStream?

Creating a custom AsyncStream is something you’ll more likely to do. Many of the apps I’ve worked on had scenarios where values would be send over time. An example could be some kind of polling service that publishes new articles when they get published.

Interesting enough, many of the standard APIs conform to AsyncSequence rather than AsyncStream. For example, using the notification center to await for time zone changed notifications on macOS:

let stream = NotificationCenter.default.notifications(named: .NSSystemTimeZoneDidChange)
for await notification in stream {
    // handle notification
}

Or iterating over values from a Combine publisher:

let numbers: [Int] = [1, 2, 3, 4, 5]
let filtered = numbers.publisher
    .filter { $0 % 2 == 0 }

for await number in filtered.values {
    print("\(number)", terminator: " ")
}

These are both defined as AsyncSequence under the hood. In general, I look at AsyncSequence to be a lower-level solution with more fine-grained control over the implementation. They’re more flexible than an AsyncStream.

For bridging delegates, closure callbacks, or emitting events manually, an AsyncStream is often the best choice. Its APIs are easier to write and remember and often provide enough for common use-cases.

Real-world examples to better understand the concepts

There’s right or wrong in this story, you could even argue that both AsyncSequence and AsyncStream can be used in many of the scenarios. Yet, I’d love to provide a few common examples from real-world applications that could spark ideas and help you better understand the use cases.

Bridging delegates – Streaming location updates

The first example demonstrates how you could potentially convert delegate callbacks into an AsyncThrowingStream. Note that you can use CLLocationUpdate.liveUpdates() for this as well, but it’s still a great example for this lesson.

We start by defining a LocationMonitor as follows:

final class LocationMonitor: NSObject {
    let locationManager = CLLocationManager()
    private var continuation: AsyncThrowingStream<CLLocation, Error>.Continuation?
    let stream: AsyncThrowingStream<CLLocation, Error>

    override init() {
        var capturedContinuation: AsyncThrowingStream<CLLocation, Error>.Continuation?
        stream = AsyncThrowingStream { continuation in
            capturedContinuation = continuation
        }
        super.init()
        self.continuation = capturedContinuation
        locationManager.delegate = self
        locationManager.requestWhenInUseAuthorization()
        locationManager.startUpdatingLocation()
    }
}

We capture the continuation as we’re going to use that in our delegate callbacks:

extension LocationMonitor: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        for location in locations {
            continuation?.yield(location)
        }
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        continuation?.finish(throwing: error)
    }

    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        let status = manager.authorizationStatus
        if status == .denied || status == .restricted {
            /// No specific error object for denied/restricted
            continuation?.finish(throwing: nil)
        }
    }
}

Whenever a new location gets returned, we yield that using the continuation. If authorization fails or if the location manager fails with an error, we can finalize the continuation. Using this code would look as follows:

let locationMonitor = LocationMonitor()

do {
    for try await location in locationMonitor.stream {
        print("Got location: \(location.coordinate.latitude), \(location.coordinate.longitude)")
    }
    print("Finished monitoring locations")
} catch {
    print("Location stream finished with error: \(error.localizedDescription)")
}

Note that there’s room for improvement in this sample code, as we can’t use the stream anymore after termination. Yet, it does demonstrate how delegate callbacks could be used to stream values using an asynchronous stream.

Repeatedly calling an asynchronous function

Another example could be some kind of ping service. You might want to ping your backend service repeatedly every 5 seconds. We could do this by using the AsyncStream.init(unfolding:oncancel:) method:

struct PingService {

    func startPinging() -> AsyncStream<Bool> {
        AsyncStream<Bool> {
            try? await Task.sleep(for: .seconds(5))
            return await ping()
        } onCancel: {
            print("Cancelled pinging!")
        }
    }

    func ping() async -> Bool {
        /// Imagine this method pings the backend using a URLRequest.
        print("Pinging...")

        return true
    }
}

We could then start using the ping service as follows:

let pingService = PingService()

for await pingResult in pingService.startPinging() {
    print("Pinging result is: \(pingResult)")
}

Summary

In the majority of use cases, you’ll likely make use of an AsyncStream. It comes with an easier-to-grasp API and often fits scenarios where you want to yield values over time. AsyncSequence is a lower-level alternative that you might need when you need more fine-grained control.

That’s it for this module, up next is an assessment to validate your learnings.