Modern Concurrency: Beyond the Basics

Oct 20 2022 Swift 5.5, iOS 15, Xcode 13.4

Part 1: AsyncStream & Continuations

3. Using AsyncStream to Count Down

Episode complete

Play next episode

Next
Save for later
About this episode
See forum comments
Cinema mode Mark complete Download course materials
Previous episode: 2. AsyncStream Next episode: 4. Using AsyncStream for Notifications

This video uses Xcode 14’s Task.sleep(until:clock:). If you use Xcode 13, replace this with Task.sleep(nanoseconds: 1_000_000_000).

Final Blabber app

For the rest of this part of the course, you’ll work on this messaging app. In the next two episodes, you’ll add a countdown timer and show notifications of users arriving and leaving. And in episodes 7 and 8, you’ll implement this Location button.

Run the course server

Most of the projects in this course interact with a server. It’s included in the course materials.

In Finder, open the course materials folder and find the CourseServer folder. CourseServer* is a Vapor server that you run in Terminal, so open Terminal next to your Finder window.

First, you need to change directory to CourseServer: In Terminal, type cd then drag the CourseServer folder from Finder into Terminal and press Return.

Type ls to list the files in CourseServer. This Package.swift file is what you’ll run to start the server.

Type swift run to run the Swift package. This takes a few minutes to fetch all the dependencies and build the server.

When you see this Server starting... message, check the connection by opening a browser window and loading localhost:8080/hello. Stop the server by pressing Control-C.

And restart it with swift run: Press the up-arrow to get swift run back then press Return. Restarting is quick because you already downloaded everything.

If you leave this server running, it might stop serving data correctly. If the app doesn’t work as you expect, stop and restart the course server.

Starter Blabber app

In the course materials, locate the Blabber starter project and open it.

In the project navigator, open the Views and Model groups.

Starter projects in this course have all the views, navigation and data model already set up, so you can concentrate on writing the concurrency code to make the app work.

The views you’ll be working on are outside the Views group, for quick access. Build and run the app.

You can login to see the chat screen with the status message “1 active users” and maybe a random message from Bottley the chat bot.

You can send messages, but it’s not much fun chatting with only Bottley.

Build and run on another simulator, login as your favorite author and make up a conversation.

Here’s a quick walk through of the code that’s implementing this basic chat app.

Starter code

In BlabberModel, the MainActor chat() method already has the usual URLSession code:

let (stream, response) = try await liveURLSession.bytes(from: url)

Like the LittleJohn stock ticker app in the preceding course, Blabber uses the liveURLSession custom session. It makes requests that never expire or time out so the app can keep receiving from the server indefinitely.

Scroll down a little to see some try await code:

try await withTaskCancellationHandler {
  print("End live updates")
  messages = []
} operation: {
  try await readMessages(stream: stream)
}

Once it establishes a connection, chat() calls readMessages(stream:) as the operation of the withTaskCancellationHandler method. This executes its operation and immediately invokes the handler if the task is canceled.

Parsing the server responses

Scroll down a little to readMessages(stream:):

readMessages(stream:) looks a lot like what you did in LittleJohn, fetching and showing stock prices, but there’s one difference: The asynchronous sequence lines are not all the same. You have to process the first line separate from the users’ chat messages.

When your app opens a connection to the server, the first line it receives is a status message like “activeUsers”: 4. The chat messages follow, on separate text lines.

So you handle the status message first, with this iterator,

var iterator = stream.lines.makeAsyncIterator()
guard let first = try await iterator.next() else {
  throw "No response from server"
}

You use an iterator when you know exactly how many lines you want to handle — in this case, just one.

iterator.next() is the first line, the status message.

The text line is converted to Data and then decoded to a ServerStatus message:

guard
  let data = first.data(using: .utf8),
    let status = try? JSONDecoder()
    .decode(ServerStatus.self, from: data) else {
      throw "Invalid response from server"
    }

Jump to the definition of ServerStatus: It’s a data model containing the single property activeUsers — this keeps track of how many users are in the chat at the moment.

Storing and using the chat information

There’s also a Message data model. It has a convenience initializer that only needs a message value.

Click back to BlabberModel. Scroll up. There’s this published array of messages.

Now, jump back to readMessages(). And this is where the first message gets stored:

messages.append(
  Message(
    message: "\(status.activeUsers) active users"
  )
)

This first status line in the server response uses the Message convenience initializer to create a system message.

After this, there’s an open-ended sequence of user messages, processed in a for loop, just like the stock prices in LittleJohn:

for try await line in stream.lines {
  if let data = line.data(using: .utf8),
    let update = try? JSONDecoder().decode(Message.self, from: data) {
    messages.append(update)
  }
}

If a line decodes as a Message, it’s added to the messages array. So the starter app has the basic chat facility. It’s time to add your first feature.

Creating an asynchronous timer with AsyncStream

In the simulator, the chat view has two buttons next to the Message field: You’ll implement the show-location button in episode 7, and you’ll implement this countdown button now.

In a chat app, a countdown feature adds an element of drama by counting down before showing the latest message. It will start at 3, count down to 1, then terminate with the user’s message.

You’ll implement this with a pull-based AsyncStream.

Open ChatView and scroll down to the second Button in the HStack.

Button(action: {
  Task {
    do {
      let countdownMessage = message
      message = ""
      try await model.countdown(to: countdownMessage)
    } catch {
      lastErrorMessage = error.localizedDescription
    }
  }
}, label: {
  Image(systemName: "timer")
    .font(.title)
    .foregroundColor(Color.gray)
})

This button’s action calls the model’s countdown(to:) method.

Jump to countdown(to:) in BlabberModel. Add some code:

var countdown = 3
let counter = AsyncStream<String> { 
  
}

You initialize your counter variable countdown to 3, then create a pull-based AsyncStream that produces String values.

In its asynchronous closure, you need to return the next sequence value. First, wait 1 second:

do {
  try await Task.sleep(until: .now + .seconds(1),
                       clock: .continuous)
} catch {
  return nil
}

Task.sleep(...) throws a CancellationError if the current task is canceled while it’s sleeping. Throwing an error is the quickest way to cleanly and safely exit the current execution without waiting the specified amount of time.

After the 1-second wait, return a message, depending on the countdown value:

switch countdown {
case (1...): return "\(countdown)..."
case 0: return "🎉 " + message
default: return nil
}

If countdown is 3, 2 or 1, return that value. If countdown is 0, return the user’s message. When countdown becomes -1, return nil to signal the end of the sequence.

Now, just before the switch, decrement countdown:

defer { countdown -= 1 }
🟥
switch countdown {
case (1...): return "\(countdown)..."
case 0: return "🎉 " + message
default: return nil
}

All the switch cases exit the closure, so you use a defer statement to squeeze in the decrement.

Add this code at the bottom of the method, outside the counter closure:

for await countdownMessage in counter {
  try await say(countdownMessage)
}

This for-await loop asks for the next element in the counter async sequence until it gets nil.

Build and run the app. Login, type a message, then tap the timer button. You get a 3-2-1 countdown and then your message appears.

Well done, you’ve created a timer with an unfolding, pull-based AsyncStream. In the next episode, you’ll create a buffered AsyncStream to monitor notifications.