AsyncSequence & AsyncStream Tutorial for iOS

Learn how to use Swift concurrency’s AsyncSequence and AsyncStream protocols to process asynchronous sequences. By Audrey Tam.

5 (6) · 4 Reviews

Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

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:

Asynchronous read memory use

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.

List of actors with search field

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
  }
}
Note: If you prefer, you can define the iterator structure inside the AsyncSequence structure.

Here’s what each part of this code does:

  1. Your AsyncSequence generates an Element sequence. In this case, ActorSequence is a sequence of Actors. AsyncSequence expects an AsyncIterator, which you typealias to ActorIterator.
  2. The AsyncSequence protocol requires a makeAsyncIterator() method, which returns an instance of ActorIterator. This method cannot contain any asynchronous or throwing code. Code like that goes into ActorIterator.
  3. The AsyncIteratorProtocol protocol requires a next() method to return the next sequence element or nil, 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.

List of actors matching search term

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:

  1. You still create an AsyncIterator for url.lines.
  2. Then you create an AsyncStream, specifying the Element type Actor.
  3. And copy the contents of the next() method of ActorIterator into the closure.
  4. Now, actorStream is an asynchronous sequence, exactly like ActorSequence, so you loop over it just like you did in getActors().

In ContentView, call pullActors() instead of getActors():

await model.pullActors()

Build and run, then check that it still works the same.

List of actors matching search term