AsyncSequence & AsyncStream Tutorial for iOS
Learn how to use Swift concurrency’s AsyncSequence and AsyncStream protocols to process asynchronous sequences. By Audrey Tam.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
AsyncSequence & AsyncStream Tutorial for iOS
20 mins
Calling an Asynchronous Method From a View
To call an asynchronous method from a SwiftUI view, you use the task(priority:_:)
view modifier.
In ContentView
, comment out the onAppear(perform:)
closure and add this code:
.task {
do {
try await model.readAsync()
} catch let error {
print(error.localizedDescription)
}
}
Open the Debug navigator, then build and run. When the gauges appear, select Memory and watch:
On my Mac, reading in the file took 3.7 seconds, and memory use was a steady 68MB. Quite a difference!
On each iteration of the for
loop, the lines
sequence reads more data from the URL. Because this happens in chunks, memory usage stays constant.
Getting Actors
It’s time to fill the actors
array so the app has something to display.
Add this method to ActorAPI
:
func getActors() async throws {
for try await line in url.lines {
let name = line.components(separatedBy: "\t")[1]
await MainActor.run {
actors.append(Actor(name: name))
}
}
}
Instead of counting lines, you extract the name from each line, use it to create an Actor
instance, then append this to actors
. Because actors
is a published property used by a SwiftUI view, modifying it must happen on the main queue.
Now, in ContentView
, in the task
closure, replace try await model.readAsync()
with this:
try await model.getActors()
Also, update the declaration of model
with one of the smaller data files, either data-100.tsv or data-1000.tsv:
@StateObject private var model = ActorAPI(filename: "data-100")
Build and run.
The list appears pretty quickly. Pull down the screen to see the search field and try out some searches. Use the simulator’s software keyboard (Command-K) to make it easier to uncapitalize the first letter of the search term.
Custom AsyncSequence
So far, you’ve been using the asynchronous sequence built into the URL API. You can also create your own custom AsyncSequence
, like an AsyncSequence
of Actor
values.
To define an AsyncSequence
over a dataset, you conform to its protocol and construct an AsyncIterator
that returns the next element of the sequence of data in the collection.
AsyncSequence of Actors
You need two structures — one conforms to AsyncSequence
and the other conforms to AsyncIteratorProtocol
.
In ActorAPI.swift, outside ActorAPI
, add these minimal structures:
struct ActorSequence: AsyncSequence {
// 1
typealias Element = Actor
typealias AsyncIterator = ActorIterator
// 2
func makeAsyncIterator() -> ActorIterator {
return ActorIterator()
}
}
struct ActorIterator: AsyncIteratorProtocol {
// 3
mutating func next() -> Actor? {
return nil
}
}
AsyncSequence
structure.
Here’s what each part of this code does:
- Your
AsyncSequence
generates anElement
sequence. In this case,ActorSequence
is a sequence ofActor
s.AsyncSequence
expects anAsyncIterator
, which youtypealias
toActorIterator
. - The
AsyncSequence
protocol requires amakeAsyncIterator()
method, which returns an instance ofActorIterator
. This method cannot contain any asynchronous or throwing code. Code like that goes intoActorIterator
. - The
AsyncIteratorProtocol
protocol requires anext()
method to return the next sequence element ornil
, to signal the end of the sequence.
Now, to fill in the structures, add these lines to ActorSequence
:
let filename: String
let url: URL
init(filename: String) {
self.filename = filename
self.url = Bundle.main.url(forResource: filename, withExtension: "tsv")!
}
This sequence needs an argument for the file name and a property to store the file’s URL. You set these in the initializer.
In makeAsyncIterator()
, you’ll iterate over url.lines
.
Add these lines to ActorIterator
:
let url: URL
var iterator: AsyncLineSequence<URL.AsyncBytes>.AsyncIterator
init(url: URL) {
self.url = url
iterator = url.lines.makeAsyncIterator()
}
You explicitly get hold of the asynchronous iterator of url.lines
so next()
can call the iterator’s next()
method.
Now, fix the ActorIterator()
call in makeAsyncIterator()
:
return ActorIterator(url: url)
Next, replace next()
with the following:
mutating func next() async -> Actor? {
do {
if let line = try await iterator.next(), !line.isEmpty {
let name = line.components(separatedBy: "\t")[1]
return Actor(name: name)
}
} catch let error {
print(error.localizedDescription)
}
return nil
}
You add the async
keyword to the signature because this method uses an asynchronous sequence iterator. Just for a change, you handle errors here instead of throwing them.
Now, in ActorAPI
, modify getActors()
to use this custom AsyncSequence
:
func getActors() async {
for await actor in ActorSequence(filename: filename) {
await MainActor.run {
actors.append(actor)
}
}
}
The next()
method of ActorIterator
handles any errors, so getActors()
doesn’t throw, and you don’t have to try await
the next element of ActorSequence
.
You iterate over ActorSequence(filename:)
, which returns Actor
values for you to append to actors
.
Finally, in ContentView
, replace the task
closure with this:
.task {
await model.getActors()
}
The code is much simpler, now that getActors()
doesn’t throw.
Build and run.
Everything works the same.
AsyncStream
The only downside of custom asynchronous sequences is the need to create and name structures, which adds to your app’s namespace. AsyncStream
lets you create asynchronous sequences “on the fly”.
Instead of using a typealias
, you just initialize your AsyncStream
with your element type, then create the sequence in its trailing closure.
There are actually two kinds of AsyncStream
. One has an unfolding
closure. Like AsyncIterator
, it supplies the next
element. It creates a sequence of values, one at a time, only when the task asks for one. Think of it as pull-based or demand-driven.
AsyncStream: Pull-based
First, you’ll create the pull-based AsyncStream
version of ActorAsyncSequence
.
Add this method to ActorAPI
:
// AsyncStream: pull-based
func pullActors() async {
// 1
var iterator = url.lines.makeAsyncIterator()
// 2
let actorStream = AsyncStream<Actor> {
// 3
do {
if let line = try await iterator.next(), !line.isEmpty {
let name = line.components(separatedBy: "\t")[1]
return Actor(name: name)
}
} catch let error {
print(error.localizedDescription)
}
return nil
}
// 4
for await actor in actorStream {
await MainActor.run {
actors.append(actor)
}
}
}
Here’s what you’re doing with this code:
- You still create an
AsyncIterator
forurl.lines
. - Then you create an
AsyncStream
, specifying theElement
typeActor
. - And copy the contents of the
next()
method ofActorIterator
into the closure. - Now,
actorStream
is an asynchronous sequence, exactly likeActorSequence
, so you loop over it just like you did ingetActors()
.
In ContentView
, call pullActors()
instead of getActors()
:
await model.pullActors()
Build and run, then check that it still works the same.