Async/await is here!
Five (5!!) years ago, I wrote about what async/await might look like in Swift:
async func getCurrentUsersFollowers() throws -> [User] {
let user = try await APIClient.getCurrentUser()
let followers = try await APIClient.getFollowers(for: user)
return followers
}
I put the async
keyword in the wrong place (it actually goes next to the throws
), but otherwise, pretty close to the final feature!
Today, I want to look at adopting some of the new async/await features. I have an app that’s already on iOS 15, so it’s a great testbed for these goodies. One of the parts of this app requires a download progress bar.
Normally, the easiest way to build a progress bar is to observe the progress
property on the URLSessionDataTask
object that you get back when setting up a request:
let task = self.dataTask(with: urlRequest) { (data, response, error) in
...
}
self.observation = task.progress.observe(\.fractionCompleted) { progress, change in
...
}
task.resume()
Unfortunately, the await
-able methods on URLSession
don’t return a task anymore, since they just return the thing you want, in an async fashion:
func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)
One would think the URLSessionTaskDelegate
would have some affordance that calls you back when new bytes come in, but if that exists, I couldn’t find it.
However, iOS 15 brings a new API that can be used for this purpose — a byte-by-byte asynchronous for loop that can do something every time a new byte comes in from the network — called AsyncBytes
.
Using it is a little strange, so I wanted to detail my experience using it. The first thing I had to do was kick off the request.
let (asyncBytes, urlResponse) = try await URLSession.shared.bytes(for: URLRequest(url: url))
This returns two things in a tuple: the asynchronously iterable AsyncBytes
and a URLResponse
. The URLRepsonse
is kind of like the header for the HTTP request. I can get things like the mimeType
, a suggestedFilename
, and, since my goal is to keep track of progress, the expectedContentLength
.
let length = Int(urlResponse.expectedContentLength)
Next, before I could await any bytes, I need to set up a place to put them. Because I now know how many bytes I’m expecting, I can even reserve some capacity in my new buffer so that it doesn’t have too many resizes.
let length = Int(urlResponse.expectedContentLength)
var data = Data()
data.reserveCapacity(length)
Now, with all that set up, I can await bytes, and store them as they come in:
for try await byte in asyncBytes {
data.append(byte)
}
This for loop is a little different from any for loop you’ve ever written before. asyncBytes
produces bytes, and calls the for loop’s scope every time it has something to give it. When it’s out of bytes, the for loop is over and execution continues past the for loop.
One question that this API raises: why does it call your block with every byte? The (very) old NSURLConnectionDelegate
would give you updates with chunks of data, so why the change? I too was a little confused about this, but at the end of the day, any API you call with a chunk of data is just going to have a byte-by-byte for loop inside it, iterating over the bytes and copying them somewhere or manipulating them somehow.
Now that I have the basic structure of my data downloader, I can add support for progress. It’s just a for loop, so I can calculate the percent downloaded for each cycle of the for loop and assign it to a property.
for try await byte in asyncBytes {
data.append(byte)
self.downloadProgress = Double(data.count) / Double(length)
}
In this case, self.downloadProgress
is some SwiftUI @State
, and it turns out assigning a new value to that property slows the download by 500%. Not great.
I think this example highlights something important about this new API. The file I was trying to download was about 20MB. That means my for loop is going to spin 20 million times. Because of that, it’s extremely sensitive to any slow tasks that take place in the loop. If you do something that is normally pretty fast — let’s say 1 microsecond — 20 million of those will take 20 seconds. This loop is tight.
My next instinct was to read the data every tick, but only write to it every time the percentage changed.
for try await byte in asyncBytes {
data.append(byte)
let currentProgress = Double(data.count) / Double(length)
if Int(self.downloadPercentage * 100) != Int(currentProgress * 100) {
self.downloadPercentage = currentProgress
}
}
This, sadly, also moved at a crawl. Even just reading some @State
from SwiftUI is too slow for this loop.
The last thing I did, which did work, is to keep a local variable for the progress, and then only update the progress in SwiftUI when the fastRunningProgress
had advanced by a percent.
var fastRunningProgress: Double = 0
for try await byte in asyncBytes {
data.append(byte)
let currentProgress = Double(data.count) / Double(length)
if Int(fastRunningProgress * 100) != Int(currentProgress * 100) {
self.downloadPercentage = currentProgress
fastRunningProgress = currentProgress
}
}
Not the most beautiful code I’ve ever written, but gets the job done!
AsyncBytes
is probably the premier new AsyncSequence in iOS 15. While it does feel like a very low-level API that you generally wouldn’t choose, it yields such fine-grained control that it’s flexible enough to handle whatever trickles in over the network. It’s also great to get a little experience with AsyncSequences early. Because of its byte-by-byte interface, it’s very straightforward to write things like progress indicators. Great stuff!