Notes: 02. Implement a Custom AsyncSequence
Before continuing on, you’ll want to have a decent handle on Swift 5.5’s async/await features. Brian Moakley’s got you covered, with an introductory video, linked in the Author Notes.
Building upon the iterator that I created in the last episode, it’s time to make a full-fledged AsyncSequence.
final class AsyncEncodedModelSequence {
}
And although it could use a separate iterator type, as with synchronous Sequences, it’s fine for an AsyncSequence to be its own iterator, if it makes sense for your use case.
// MARK: - AsyncSequence, AsyncIteratorProtocol
extension AsyncEncodedModelSequence: AsyncIteratorProtocol {
}
Interestingly, unlike with Sequence, you’ll need to explicitly specify the element for AsyncIterator.
extension AsyncEncodedModelSequence: AsyncIteratorProtocol {
typealias Element = Data
}
And while IteratorProtocol’s next method can’t throw errors, with AyncIterators, you can if you want. But you don’t have to—you can leave off the throws keyword if you won’t ever return errors!
typealias Element = Data
func next() async -> Data? {
<#code#>
}
}
Here, however, I’ll need to support errors coming through. And to either return a value, or throw an error, asynchronously, you can make use of withCheckedThrowingContinuation.
func next() async throws -> Data? {
try await withCheckedThrowingContinuation { continuation in
}
}
You can store continuations for later use. Option-clicking and copying can help you out with that.
final class AsyncEncodedModelSequence {
private var continuation: CheckedContinuation<Data?, Error>?
}
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
}
Finally, to support looping with “for await”, that’s where adopting AsyncSequence comes in.
extension AsyncEncodedModelSequence: AsyncSequence, AsyncIteratorProtocol {
Except, as you see, that’s not quite enough, like it is for Sequence, as I demonstrated in the last episode.
But, you can write an extension for AsyncSequence that’s just like what’s in the standard library for Sequence: when you’ve got an AsyncSequence that serves as its own iterator, just return self from the makeAsyncIterator method, which is AsyncSequence’s only requirement.
}
}
public extension AsyncSequence where AsyncIterator == Self {
func makeAsyncIterator() -> Self { self }
}
Of course, if you’re going to be using this a lot, you should move it into a reusable framework, until Apple puts it into the standard library. Now, I’m going to further bridge the worlds of Combine and Swift Concurrency, by making use of the iterator from the last episode.
}
let syncIterator = EncodedModelIterator()
private var continuation: CheckedContinuation<Data?, Error>?
I’ll subscribe to its publisher, and store the result of that as an AnyCancellable.
}
let syncIterator = EncodedModelIterator()
private var cancellable = AnyCancellable { }
private var continuation: CheckedContinuation<Data?, Error>?
It won’t need to change, so I can set it up using sink in the initializer.
final class AsyncEncodedModelSequence {
init() {
cancellable = syncIterator.publisher
.sink(
receiveCompletion: <#T##((Subscribers.Completion<Error>) -> Void)##((Subscribers.Completion<Error>) -> Void)##(Subscribers.Completion<Error>) -> Void#>,
receiveValue: <#T##((Data) -> Void)##((Data) -> Void)##(Data) -> Void#>
)
}
let syncIterator = EncodedModelIterator()
If the publisher ever completes, that will mark the end of this sequence. Aside from being asynchronous, that might end just like a standard Sequence, with a nil return.
receiveCompletion: { [unowned self] completion in
switch completion {
case .finished:
continuation?.resume(returning: nil)
Or, because AsyncIterators’ next methods can throw, an AsyncSequence might end with an Error.
continuation?.resume(returning: nil)
case .failure(let error):
continuation?.resume(throwing: error)
}
},
When all goes according to plan, I’ll receive a value, and continue with it.
receiveValue: { [unowned self] value in
continuation?.resume(returning: value)
}
)
Now, with every possibility routed through a continuation…
…I’ll store a sequence, and a model to turn its iterations into, just like I did for the synchronous iterator.
struct ContentView {
private let syncIterator = EncodedModelIterator()
private let asyncSequence = AsyncEncodedModelSequence()
@State private var publishedModel: Model?
@State private var asyncModel: Model?
}
And I’ll use the asyncSequence’s syncIterator property, to resume, stop, and iterate.
publishedModel = $0
}
Divider()
.padding()
IteratorView(
title: "Async",
syncIterator: asyncSequence.syncIterator,
model: $asyncModel
)
}
But in order to provide continuations within the asyncSequence’s next method, we need to use a for loop. And the best place for that is probably the new task view modifier.
)
.task {
for try await model in asyncSequence {
}
}
}
Except that can’t work directly. Because asyncSequence’s next method is throwing, you do need to use for try await. But like the onReceive modifier, task doesn’t support throwing methods directly.
Instead, you can get around that by using a do-catch statement. And catch can be empty as a placeholder for more robust code.
.task {
do {
for try await model in asyncSequence {
}
} catch { }
}
Next, the model needs to be decoded. And while AsyncSequences don’t have decode methods, like publishers do, they do have most of the methods that synchronous Sequences do. So, you can do the equivalent operation using map.
for try await model in (
asyncSequence
.map { try JSONDecoder().decode(Model.self, from: $0) }
) {
asyncModel = model
}
And now, we’re getting the same results, with an AsyncSequence, and a Combine Publisher.
Of course, in this case, the AsyncSequence is Combine-powered as well, but it certainly doesn’t have to be. It was just convenient to implement the AsyncSequence this way, because 1. Timers don’t yet offer an AsyncSequence API, and 2. Swift’s concurrency system doesn’t yet have a handy event publishing type that matches what Combine’s Subjects can do.
So while there are some hurdles to overcome in terms of creating AsyncSequences, I think consuming them, in most cases, is going to be a little simpler than with Combine publishers.
For example, because for loops are involved, you can just break to stop iterating, instead of having to cancel or unsubscribe. It’s nice to have that right at the language level.
But this simplicity is only supported for one-to-one sequence-to-consumer relationships. Another lacking feature of AsyncSequence is Combine’s ability to have multiple subscribers process events from the same publisher.
To implement a one-to-many system, you can start with an AsyncSequence, but you’ll need to route it through something like an ObservableObject—which, just behind the scenes, is powered by Combine.
And then, there’s the question of buffering events, and managing back-pressure. AsyncSequence doesn’t offer any facility for either. If you need a refresher on how Combine handles this (rather magnificently in my opinion), check out the Intermediate Combine course from Josh Steele.
The nice thing about AsyncSequence being a protocol, however, is that types that adopt it can add specialized features which the protocol doesn’t require.
In review right now is a proposal for a new type —AsyncStream— which will adopt AsyncSequence, and, amongst other goodies that are in flux under review, provide for event buffering and back-pressure management.
Supposedly, AsyncStream will be ready sometime during this Xcode 13 beta cycle. So be sure to check back on raywenderlich.com later, for coverage.
In summary, AsyncSequence is something you’ll need to learn to use, and you may want to start exploring where it can replace the usage of Combine in your apps, or your frameworks.
But currently, it only offers a fraction of what Combine does, and it’s too soon to tell if you’ll be able to make a full migration to native concurrency in coming years.
Fortunately, as I hope I’ve demonstrated here, Combine plays quite nicely with Swift Concurrency. So mix and match to taste!
Thanks for exploring some of these exciting new possibilities with me. And please let us know in the discussion for this episode if you make any interesting related discoveries.