Notes: 01. Make IteratorProtocol async Using Combine
Swift 5.5 is pretty amazing. The concurrency features are going to make life a lot easier on all of us, going forward.
But you know what? That’s how I felt about Combine, too. And although Combine has proven itself to be an extremely valuable framework, I wonder—given that it’s only two years old, why is it that we didn’t get any new sessions on it at WWDC 2020 or 2021?
And to further my curiosity—we did get this: a session devoted to the new AsyncSequence protocol. It offers some features which seem suspiciously similar to Combine’s. Does that mean that Combine was a short-lived experiment, and AsyncSequence is going to take its place? There are blog posts popping up on that topic, but nobody outside of Apple knows the plans for the future of Combine.
What I can show you, though, is that for the time being, Combine offers some functionality that nothing in the Swift language or standard library does, as of yet.
I’ll demonstrate the overlap over the course of two demos. Then, I’ll get into some specifics about AsyncSequence’s limitations at the end of the next episode.
Here’s the main takeaway: AsyncSequence provides a new way to process values that are received asynchronously. It looks like using a standard Swift Sequence, but it acts much like a Combine Subscription.
For now, it’ll help to start thinking about AsyncSequence as a simple wrapper around an AsyncIterator.
Except for the asynchronism, this is just like the pair we already had in Swift: Sequence, and its associated Iterator. Ray Fix has an Advanced Swift course, on our site, on how those work together, if you’d like to brush up.
I’ll be making use of the original IteratorProtocol in this episode to bridge the gap towards AsyncIteratorProtocol, using Combine. If you’re not already familiar with Combine, I recommend learning from Josh Steele — he has two courses on the subject.
After you’ve checked the links to those courses, in the Author Notes, let’s start iterating!
As you can tell from the name of this stubbed-out type, it’s going to iterate over encoded models. Each one will be represented as Data.
// MARK: IteratorProtocol
extension EncodedModelIterator: IteratorProtocol {
func next() -> Data? {
<#code#>
}
}
To create the models, I’ll start off by creating an iterator for a partial range, starting at 1.
}
private var intIterator = (1...).makeIterator()
}
Then, I’m going to transform each integer into an encoded model.
func next() -> Data? {
try? intIterator.next().map {
try JSONEncoder().encode(<#T##value: Encodable##Encodable#>)
}
}
For simplicity, the model in this project is nothing more than a container for an Int.
try JSONEncoder().encode(Model(int: $0))
And if we wanted this type to vend this Data synchronously, using a for loop, all we’d need to do it to adopt the Sequence protocol.
extension EncodedModelIterator: Sequence, IteratorProtocol {
But then I wouldn’t be talking about anything new and exciting…
extension EncodedModelIterator: IteratorProtocol {
…so instead, let’s define a Combine Subject, to pass along Data instances.
}
private let subject = PassthroughSubject<Data, Error>()
private var intIterator = (1...).makeIterator()
We’ll also be able to send an error upon failure, to be more informative than just sending nil. IteratorProtocol‘s next method can’t throw errors, so we’ll still need to return nil from that…
func next() -> Data? {
do {
guard let data = ( try intIterator.next().map {
try JSONEncoder().encode(Model(int: $0))
} ) else {
return nil
}
return data
} catch {
return nil
}
}
…but we can properly route values and errors through the subject, now.
}
subject.send(data)
return data
} catch {
subject.send(completion: .failure(error))
return nil
}
And now, you can manually iterate, using the next() method. But let’s hook up another publisher to iteration, to get a bit of asynchronous action going. Like with a timer that publishes an event every 2/3 of a second.
func resume() {
Timer.publish(every: 2 / 3, on: .main, in: .default)
.autoconnect()
.sink { [unowned self] _ in _ = next() }
}
You could store a cancellable to keep that subscription alive, but just storing the cancel method itself will be enough for what needs to happen in the stop method.
private var intIterator = (1...).makeIterator()
private var cancel: (() -> Void)?
}
func resume() {
cancel = Timer.publish(every: 2 / 3, on: .main, in: .default)
.autoconnect()
.sink { [unowned self] _ in _ = next() }
.cancel
}
func stop() {
cancel?()
}
Then, to provide a type-erased interface for published model data, AnyPublisher is the way to go.
final class EncodedModelIterator {
var publisher: AnyPublisher<Data, Error> {
subject.eraseToAnyPublisher()
}
func resume() {
To get that hooked up to the UI in this app, the content view will need to store an EncodedModelIterator—I’m going to call it “syncIterator”, to contrast with what’s coming in the next episode, which will be async.
struct ContentView {
private let syncIterator = EncodedModelIterator()
}
One published model at a time will be stored as State variable.
struct ContentView {
private let syncIterator = EncodedModelIterator()
@State private var publishedModel: Model?
}
This IteratorView has a button for manually iterating—and its action can be the same as the Timer’s: call next, and disregard the result, which will get routed through the publisher.
Button {
_ = syncIterator.next()
} label: {
To check out one of those views, let’s instantiate using the two new properties.
VStack {
IteratorView(
title: "Combine",
syncIterator: syncIterator,
model: $publishedModel
)
}
Using the onReceive view modifier, we can decode the published models, and reassign accordingly.
)
.onReceive(
syncIterator.publisher
.decode(type: Model.self, decoder: JSONDecoder())
) {
publishedModel = $0
}
Except, of course, with the caveat that the Failure type of the publisher has to be Never. With type inference, we can map to optional models, and replace any error with nil.
.decode(type: Model.self, decoder: JSONDecoder())
.map { $0 }
.replaceError(with: nil)
) {
And with that, we can manually iterate or automatically iterate.
And both can be combined together. This is an asynchronous stream of events, but it is not yet an official AsyncSequence. Let’s give it that upgrade in the next episode.