Leave a rating/review
Make sure the course server is running and continue with your project from the previous episode or open the starter project for this episode.
Downloading chunks
You’ve coded the Silver download option, which fetches the complete file in one go and presents an onscreen preview.
In this episode, you’ll implement the Gold download option, which provides progressive UI updates as you download the file in chunks.
AsyncSequence
You’ll do this by reading the file as an asynchronous sequence of bytes from the server. This is similar to what you did in episodes 4 and 5, but now you’ll implement some helper methods to work with chunks, or batches, of bytes. This lets you update the progress bar one chunk at a time, as you’re receiving the file’s contents.
In SuperStorageModel, locate downloadWithProgress(file:).
return try await downloadWithProgress(fileName: file.name, name: file.name, size: file.size)
This public method calls the private version of downloadWithProgress, defined just below:
private func downloadWithProgress(fileName: String, name: String, size: Int, offset: Int? = nil) async throws -> Data {
guard let url = URL(string: "http://localhost:8080/files/download?\(fileName)") else {
throw "Could not create the URL."
}
await addDownload(name: name)
let result: (downloadStream: URLSession.AsyncBytes, response: URLResponse)
if let offset = offset {
// code for Cloud 9 plan
}
else {
result = try await URLSession.shared.bytes(from: url)
guard (result.response as? HTTPURLResponse)?.statusCode == 200 else {
throw "The server responded with an error."
}
}
// Add code here, replacing placeholder return statement
return Data()
}
This private method is where you’ll add more code.
Currently, it defines the endpoint and adds the filename to the published downloads array.
Then it defines a tuple to hold the result of an asynchronous URLSession method call.
Option-click AsyncBytes. This method returns a URLSession.AsyncBytes sequence, which gives you the bytes it receives from the URL request, asynchronously.
This closure is empty for now. You don’t need it for this episode.
offset is an optional Int parameter with default value nil.
The Silver download method doesn’t set offset and neither will the Gold method. Only the Cloud 9 plan uses the code in this closure. You’ll add this code in episode 16, when you implement the Cloud 9 plan.
For Silver and Gold download plans, you just need this standard URLSession code similar to what you’ve already written.
else {
result = try await URLSession.shared.bytes(from: url)
guard (result.response as? HTTPURLResponse)?.statusCode == 200 else {
throw "The server responded with an error."
}
}
You use url to make a standard request, and check for the usual success status code.
Option-click result: A successful response gets you an asynchronous byte sequence stored in result.downloadStream.
Now, above the dummy return statement, create an asynchronous iterator for the sequence:
var asyncDownloadIterator = result.downloadStream.makeAsyncIterator()
This asynchronous iterator gives you more control over the incoming bytes to update the progress bar.
Now, Xcode flags an error because the if closure doesn’t initialize result.
Just comment out the if-else lines for now:
// if let offset = offset {
// // Add code for Cloud 9 plan
// }
// else {
result = try await URLSession.shared.bytes(from: url)
guard (result.response as? HTTPURLResponse)?.statusCode == 200 else {
throw "The server responded with an error."
}
// }
ByteAccumulator
You won’t update the progress bar after every byte. Instead, you’ll process a batch of bytes at a time and update the progress bar after each batch.
The starter project includes a custom ByteAccumulator class.
In the project navigator, in the Model group, open ByteAccumulator.swift
Each chunk, or batch of bytes, is 20 bytes.
Click back to SuperStorageModel and downloadWithProgress to create a byte accumulator and prepare to use it:
let accumulator = ByteAccumulator(name: name, size: size)
while !stopDownloads, // you'll set this in the next episode
!accumulator.checkCompleted() { // accumulator can still collect more bytes
}
These two conditions give you the flexibility to run the loop until either the external flag stopDownloads is set, or the accumulator completes the download. With this design, you’re looking ahead to make it easy to cancel the download code by using an external flag.
Now, add more code inside the while closure:
while !accumulator.isBatchCompleted, // nested while runs until this batch is full
let byte = try await asyncDownloadIterator.next() { // or the byte sequence completes
accumulator.append(byte)
}
You have to await each byte downloaded and processed by your asynchronous iterator.
Updating the progress bar
After a batch completes, it’s time to update the download progress bar.
Add this in the outer while closure, after the inner while loop:
await updateDownload(name: name, progress: accumulator.progress)
Jump down to updateDownload to see where this progress value gets used.
It updates the current download’s progress value …
Right-click progress (in info.progress) and Find its call hierarchy. Select Downloads.getter.body
The Downloads view passes it to ProgressView.
Go back to SuperStorageModel updateDownload
Remember updateDownload belongs to MainActor, so it runs on the main thread, not on whatever threads the iterator and accumulator are running on.
This is a safe place to try out Task.detached. Apple documentation recommends against using Task.detached(...) because it negatively affects the concurrency model’s efficiency. But, just to see how it works, you’ll create the task so it doesn’t slow down the ongoing download task.
Get back to the last line you added in the downloadWithProgress while loop and wrap it in a detached Task:
Task.detached(priority: .medium) {
await self.updateDownload(name: name, progress: accumulator.progress)
// need self. in a closure
}
downloadWithProgress is a task started by a user action, so it runs with userInitiated priority. This detached Task doesn’t inherit its parent’s task storage, execution actor or priority.
Explicitly setting its priority to medium means there’s no chance it will slow down the download task.
But, now that this is a separate task, using accumulator.progress here could cause a data race.
So, before creating the detached task, capture accumulator.progress in a local constant:
let progress = accumulator.progress
Then pass this value to updateDownload:
let progress = accumulator.progress
Task.detached(priority: .medium) {
await self.updateDownload(name: name, progress: progress) // delete accumulator.
}
Now, add a print statement to keep track of downloads during development:
let progress = accumulator.progress
Task.detached(priority: .medium) {
await self.updateDownload(name: name, progress: progress)
}
🟩print(accumulator.description)
Returning accumulated result
And finally, replace the dummy return value:
return accumulator.data
While processing the file in batches of 20 bytes, the accumulator has saved every byte to its data property. When all the bytes have downloaded, you have a file.
And now to call your new method.
Go to DownloadView, and fill in the downloadWithUpdatesAction closure: Copy and paste the downloadSingleAction code …
isDownloadActive = true
Task {
do {
fileData = try await model.download🟩WithProgress🟥(file: file)
} catch { }
isDownloadActive = false
}
And change download(file:) to downloadWithProgress(file:). Build and run, select a file, then tap Gold.
Now the progress bar updates a little at a time.
And you see the progress printed in the console.
You’ve implemented the progress bar feature of the Gold plan. In the next episode, you’ll add more features, while learning how to cancel tasks.