DEV Community

Gualtiero Frigerio
Gualtiero Frigerio

Posted on

Create your own AsyncSequence

One of the new features of async await I was really excited about is AsyncSequence, which provides a new way to receive data asynchronously in loops. It can be seen as an alternative to Combine, and I think both of them have reason to exist.
Before you continue reading, I suggest to get familiar with async await, and read my previous article on the topic. We’ll use the same GitHub repo where you’ll find all the code included here.

Let’s start with the definition: AsyncSequence is a protocol, and according to the documentation can provide asynchronous, sequential and iterated access to its elements.
The novelty is the asynchronous part, as Swift had a Sequence protocol since the beginning to provide access to elements.
Now that we have async await in Swift, we can iterate through a Sequence waiting for the single element to be ready.

Use an AsyncSequence

Before creating our custom AsyncSequence, I think it is better to understand how to use one of them.
Let’s see some examples. You’re likely familiar with URLSession, I bet you’ve used it somewhere to retrieve a JSON from a server, or to download something. As soon as async await was introduced, URLSession exposed the async version of its functions, and the one returning a sequence of bytes is perfect to see AsyncSequence in action.

class func getDataStream(atURL url: URL) async -> Data? {
    let request = URLRequest(url: url)
    var data: Data? = nil
    do {
        let (bytes, _) = try await URLSession.shared.bytes(for: request, delegate: nil)
        data = Data()
        for try await b in bytes {
            data?.append(b)
        }
    }
    catch (let error) {
        print("error while getting data \(error.localizedDescription)")
    }

    return data
}

// this is the definition of bytes

func bytes(for request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (URLSession.AsyncBytes, URLResponse)


Enter fullscreen mode Exit fullscreen mode

As you can see, we can use a for loop awaiting each byte, then we can append it to a Data object.
Let’s have a look at the definition of bytes at the end of the code block. The function is async, so we have to await before getting the tuple back from it, and the first parameters is AsyncBytes. Turns out, AsyncBytes conform to the AsyncSequence protocol, that’s why we can iterate through its elements via a for loop.
You can use an Iterator as well, I don’t know if you’re familiar with it but this is the example

class func getDataIterator(atURL url: URL) async -> Data? {
    let request = URLRequest(url: url)
    var data: Data? = nil
    do {
        let (bytes, _) = try await URLSession.shared.bytes(for: request, delegate: nil)
        data = Data()
        var iterator = bytes.makeAsyncIterator()
        while let nextByte = try await iterator.next() {
            data?.append(nextByte)
        }
    }
    catch (let error) {
        print("error while getting data \(error.localizedDescription)")
    }

    return data
}
Enter fullscreen mode Exit fullscreen mode

the only difference is the couple of lines after we call bytes on URLSession. We have the iterator via makeAsyncIterator and then we can use a while loop by calling next() on the iterator. Similarly to what happens in the for loop, we have to try await for the call to iterator.next(), as we’re dealing with an async iterator, conforming to the AsyncIteratorProtocol
I gave you the Iterator example as we’ll have to implement an iterator while creating our own AsyncSequence. Turns out, the IteratorProtocol is tightly coupled with Sequence, as a Sequence provide access to its element by creating an Iterator. So even if you don’t use an Iterator directly, as in the example above, you still have to implement one for your custom Sequence (or AsyncSequence)

Create an AsyncSequence

Now let’s see how to actually create our own AsyncSequence.
The example I chose is a class to load pictures. You provide an array of urls (I’m using a custom struct for that purpose) and you can await for each image to be loaded.
I used it to populate a UICollectionView, so a new cell is added only when the image is already loaded into a UIImage, instead of loading all the cells and having the images loaded and then added to an existing cell.
This is the code of PicturesLoader

class PicturesLoader: AsyncSequence, AsyncIteratorProtocol {
    typealias Element = PictureWithImage

    init(withPictures pictures: [PictureWithImage]) {
        self.pictures = pictures
    }

    // being of AsyncSequence and AsyncIteratorProtocol
    func next() async throws -> Element? {
        await getNextPicture()
    }

    func makeAsyncIterator() -> PicturesLoader {
        self
    }
    // end of AsyncSequence and AsyncIteratorProtocol

    private var pictures: [PictureWithImage] = []

    private func getNextPicture() async -> PictureWithImage? {
        guard let nextPicture = pictures.popLast(),
              let url = URL(string: nextPicture.imageUrl) else { return nil }
        if let data = await RESTClient.getData(atURL: url) {
            let image = UIImage(data: data)
            var picture = nextPicture
            picture.image = image
            return picture
        }
        return nil
    }
}
Enter fullscreen mode Exit fullscreen mode

As you see, it conform to AsyncSequence and AsyncIteratorProtocol.

To conform to them, we need to add the next function that will return an Element, in this case the custom struct PictureWithImage but it could be anything like a String or custom type of yours.
Then we need the makeAsyncIterator, in this example we return self as this class conforms to AsyncSequence and AsyncIteratorProtocol. You may have a separate type like a struct conforming to AsyncIteratorProtocol and return that one instead of self.
Since the next function is async, we can await inside it.
Look at getNextPicture, that function is async as well, because is calling a RESTClient that will call an async version of URLSession to retrieve the data.
Of course you don’t have to await inside your async function, you may have all your values ready and return them. I wanted to show you a real asynchronous operation like a network call, but you may have an array of objects and return one at a time like a Sequence does.
How do we call our custom AsyncSequence? Just like we did for the URLSession example

let pictureLoader = PicturesLoader(withPictures: picturesWithImages)
Task {
    do {
        for try await picture in pictureLoader {
            loadedPictures.append(picture)
            await viewController?.collectionView.reloadData()
        }
    }
    catch {
        print("error while iterating on images")
    }
}
Enter fullscreen mode Exit fullscreen mode

here I chose to use the for loop, but I could have used the iterator as well.
In this example we’re awaiting for the next picture, and when it is ready we can append its value to an array and call reload data on the collection view, so the new cell will be created with an image on it.

Use AsyncStream

It isn’t always necessary to create your own AsyncSequence, as Swift introduced two types conforming to AsyncSequence to make our lives easier. You can use AsyncStream and its counterpart AsyncThrowingStream (if your calls may throw) and then loop through them via a for loop just as we did in the previous example.
According to the documentation, both types provide a convenient way to create an asynchronous sequence without manually implementing an iterator. Sounds great, let’s see an example

func getPicturesStream() -> AsyncStream<PictureWithImage> {
    AsyncStream { continuation in
        Task {
            for picture in pictures {
                if let url = URL(string: picture.imageUrl),
                   let data = await RESTClient.getData(atURL: url),
                   let image = UIImage(data: data) {
                    var pictureToReturn = picture
                    pictureToReturn.image = image
                    continuation.yield(pictureToReturn)
                }
            }
            continuation.finish()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

the implementation is similar to getNextPicture, it has a loop for the pictures, for each of them await for the RESTClient and then creates the UIImage and returns it. The difference is that we don’t actually return a value directly this time. The AsyncStream initialiser we are using is provided a Continuation, and as you see we call two functions on this type: yield and finish. Both resume the task awaiting for the next iteration, finish returns nil so the iteration ends, while yield has a value.
Let’s have a look at the call site

let picturesLoader = PicturesLoader(withPictures: picturesWithImages)
let stream = picturesLoader.getPicturesStream()
Task {
    for await picture in stream {
        loadedPictures.append(picture)
        await viewController?.collectionView.reloadData()
    }
}
Enter fullscreen mode Exit fullscreen mode

this is similar to the previous example, we get the stream and we can iterate via a for loop.
The difference is that we didn’t have to provide a custom implementation of AsyncSequence so there is less code to write and we could avoid creating a custom type.

Conclusion

This was a quick introduction to AsyncSequence, I wanted to show you how to create your own one and most importantly how to consume an existing one.
As I said at the beginning, this isn’t the only alternative we have to deal with asynchronous values being emitted over time. Combine is great for that, comes with operators to manipulate the data and has some utilities like discarding results and debouncing the publisher.
Different tools that can help you achieve different goals, I think it is important to know all of them in order to pick the right one for your project.

Happy coding 🙂

Original article

Oldest comments (0)