Discovering URLSession Async API
In this lesson, you’ll discover the URLSession async/await APIs and implement
an image downloader with download progress.
URLSession Async/Await APIs
URLSession offers several APIs to retrieve and upload data from and to the internet.
These methods cover different scenarios and also provide different levels
of abstraction to be used in them.
You can use each one based on your current application and/or the feature
you’re trying to implement.
Retrieving data from the internet
You already encountered this first method in the previous lesson to retrieve data.
There are two different variations based on the input parameter:
- The first one accepts a
URLaddress. - The second one receives a
URLRequestfield.
func data(from url: URL) async throws -> (Data, URLResponse)
func data(for request: URLRequest) async throws -> (Data, URLResponse)
In both cases, the function might throw if an error occurs during the
transfer or return a tuple of Data containing the downloaded data and
a URLResponse with the server response.
As you did in the previous lesson, the typical way of using these methods is represented below:
let (data, response) = try await URLSession.shared.data(from: Self.newsURL)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.isOK /* 200 */ else {
throw NewsServiceError.serverResponseError
}
...
Here’s what’s happening in the code:
- First of all, you call the
URLSessionmethod withtry await, as this method is both async and can throw an exception. - If an error occurs during the transfer, an exception is thrown, and you should manage it in the response chain.
- If the
data(from:)returns, you first ensure there’s no error in the server’s response and then proceed to analyze the content of the returned data. - If the returned data is encoded with JSON, you can proceed to try the decoding process and return the result in the final format.
As you see, the code lets you manage the process very naturally, pausing execution when needed and throwing an error if something goes wrong.
Uploading data to the internet
The following two methods are equivalent to the previous ones, just for the upload direction:
func upload(for request: URLRequest, from data: Data) async throws -> (Data, URLResponse)
func data(for request: URLRequest, fromFile url: URL) async throws -> (Data, URLResponse)
In the first one, you explicitly pass the data to the method, while in the second, you provide a URL of a file containing the data to upload.
Note: The methods accept a URL request, so you’re forced to specify the HTTP method.
These methods are typically used to upload content when performing a POST
request, as shown below:
var request = URLRequest(url: Self.postURL)
request.httpMethod = "POST"
let (data, response) = try await URLSession.shared.upload(for: request, fromFile: fileURL)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.isCreated /* 201 */ else {
throw NewsServiceError.serverResponseError
}
...
- Remember to set the
request‘shttpMethodattribute toPOSTbecauseGET(the default value) doesn’t support upload. - When checking the network response in this case, you should verify that the server created the remote resource correctly (HTTP code 201).
- You can follow the approach you saw in the previous case for the rest of the processing.
Downloading files from the internet
This third method is dedicated to file downloading and has peculiarities in the content management that makes it different from the previous ones.
The methods’ signatures in this case are the following:
func download(from url: URL) async throws -> (URL, URLResponse)
func download(for request: URLRequest) async throws -> (URL, URLResponse)
func download(resumeFrom resumeData: Data) async throws -> (URL, URLResponse)
These three methods store the result of the download in a file instead
of in memory, as in the case of data(from:). The third is a particular form of the other two that you use with resumable
downloads and cancellable tasks. You’ll typically use these when you want to download an asset from the
internet and keep it locally. Think about a local cache mechanism to store images downloaded from a server:
let (location, response) = try await URLSession.shared.download(from: Self.downloadURL)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.isOK /* 200 */ else {
throw NewsServiceError.serverResponseError
}
try FileManager.default.moveItem(at: location, to: finalLocation)
- The session response processing follows the same pattern you saw above.
- In this case, however,
URLSessionsaves the result of the download in a file that you must move to its final location in the file system. - Contrary to the traditional download
URLSessionmethod, where the system deletes the file onceURLSession‘s calling function returns, here, you’re responsible for deleting the downloaded file.
Getting session updates for downloads
Last but not least, this family of APIs allows you to download data from a
server as in the first case, though they provide a sort of update
mechanism using an AsyncSequence:
func bytes(from url: URL) async throws -> (URLSession.AsyncBytes, URLResponse)
func bytes(for request: URLRequest) async throws -> (URLSession.AsyncBytes, URLResponse)
Use this method when you want to receive updates from the download task
while the transfer is underway.
Even if you haven’t met the AsyncSequence object yet, you can use it in
another structured concurrency construct called a for-await-in loop to
handle each received byte. You typically use this URLSession form when you want to be updated on
the download progress, for example, to present the user with a progress bar
showing the status of the download.
You’ll use these methods in the rest of the lesson to download the article’s image.
Adding the Article Image
Enough with the theory. :] Now, it’s time to get your hands dirty and implement some fancy stuff to enrich Apple News with a shining article preview image.
From the Materials repo, open the Starter folder under 02-taming-network-calls.
Build and run the project. Tap Load Latest News, and you’ll see that the new version of the app has a placeholder for an image describing each article.
If you prefer to continue with the project you developed in the previous lesson, take a moment to add the following files to it. If you started from the clean project in the Starter folder, these are the files you’ll work on in this lesson.
ImageService.swift manages the asynchronous downloading of the image:
import Foundation
import SwiftUI
import OSLog
@Observable
class ImageService {
var progress: Double = 0
var image: Image?
func downloadImage(url: URL?) async throws {
progress = 0
image = nil
// Simulate image download
try await Task.sleep(for: .seconds(Int.random(in: 2..<4)))
progress = 1
image = Image(systemName: "photo")
}
}
The method downloadImage(url:) is marked async throws, which means
it can pause its execution to wait for the completion of some long task,
such as the image download.
Currently, it just contains a delay to simulate the network latency, and
it always returns a placeholder image. Don’t worry, though — you’ll implement it during the rest of the lesson. :]
ArticleImageView.swift defines the view for the article image:
import SwiftUI
struct ArticleImageView: View {
let url: URL?
@State private var imageService = ImageService()
var body: some View {
innerView()
.padding()
.task {
try? await imageService.downloadImage(url: url)
}
}
@MainActor
@ViewBuilder
private func innerView() -> some View {
if let image = imageService.image {
image
.resizable()
.aspectRatio(contentMode: .fit)
.background(.clear)
.mask(RoundedRectangle(cornerRadius: 8))
} else {
if imageService.progress < 1 {
ProgressView()
} else {
Image(systemName: "photo")
}
}
}
}
It utilizes an instance of ImageService to manage the asynchronous
downloading of the image.
It also uses the ImageService property named progress to present the
ProgressView while the image is downloaded.
Downloading the Image
It’s time to put the theory into practice and implement the image downloading.
You’ll use URLSession’s method bytes(for:) to download the image and
get notified about its progress.
Open ImageService.swift, and replace the content of the function
downloadImage(url:) with the following text:
guard let url else {
Logger.main.error("URL is nil returning empty image")
return
}
progress = 0
image = nil
// 1. Here the execution pauses
let (bytes, response) = try await URLSession.shared.bytes(for: URLRequest(url: url))
guard let httpResponse = response as? HTTPURLResponse, httpResponse.isOK else {
Logger.main.error("Network response error")
return
}
let length = Int(httpResponse.expectedContentLength)
var data = Data(capacity: length)
// 2. The execution pauses and resumes several times (each byte received)
for try await byte in bytes {
data.append(byte)
}
progress = 1
image = Image(uiImage: UIImage(data: data) ?? UIImage())
As said above, URLSession.shared.bytes(for:) asynchronously fetches the
image data bytes from the provided URL.
This method returns an AsyncSequence and an HTTP response. AsyncSequence is also part of the new asynchronous primitives introduced with Swift 5.5. Without going into all its details, an AsyncSequence is a type that represents
a sequence of values produced asynchronously over time.
It allows you to work with an asynchronously generated stream of values,
making it useful for scenarios where data is received gradually or from
asynchronous sources, such as network requests or file I/O operations. It’s very similar to the primitives that the Combine framework provides,
though this is now part of the new concurrency framework.
To receive the data, you use the for try await loop: The async sequence
asynchronously produces elements one at a time, pausing the execution and
resuming it several times, allowing you to work with the elements as they become available. During each iteration of the loop, the byte loop variable represents the next element of the asynchronous sequence, a single byte of data received from the network.
The for try await doesn’t block the thread while waiting for the next
element of the sequence.
Instead, it suspends the execution of the loop until the next element becomes available. The execution continues in the loop until no more elements are available in the asynchronous sequence or until an error occurs.
This gives you an idea of what you can do with AsyncSequence.
Check the Kodeco site for more great resources on how to use it.
In this case, you wait for bytes from the network and append each received byte to the buffer you created with the initial length.
Build and run the app with this new addition: The app looks great with all these new images.
Adding a Download Progress Bar
As you can see, when the app downloads the image, it just shows a loading spinner. Wouldn’t it be nice to let the user know the download’s status?
You can use the information returned by AsyncSequence. Knowing that
length contains the image’s size, you can compute the partial progress
as the ratio between the received bytes and the length.
Open the file ImageService.swift, and add the following line in the
for loop after the data update:
progress = Double(data.count) / Double(length)
Then, update the UI to display this new information.
Open the file ArticleImageView.swift, and change the ProgressView
instantiation with the following:
ProgressView(value: imageService.progress)
When provided with a value (between 0 and 1), ProgressView displays
a progress bar that can be customized as desired.
Build and run the app, tap Load Latest News, and you’ll see that the app now shows a progress bar during the download.
Fixing the Download Progress
The download progress is a great addition that let the user understand what’s happening. Using async/await, you do the download on a background task, so you don’t block the main thread, allowing the user interface to remain fluid.
Nonetheless, have you noticed how the download process slowed down when
you added the progress update?
Think about what happens during the download to fully understand the root
cause of this slowness and a way to fix it. For each received byte, you update progress, which, in turn, updates
the UI by creating a new instance of ArticleImageView.
You’re updating the UI too often, and that slows down the download process. Most of the time, increasing a single byte doesn’t produce a tangible increase in the progress bar.
Try reducing the frequency of the updates to just when the percentage of the progress increments by 1.
Open the file ImageService.swift, and replace the for loop with the following content:
var bytesAccumulator = 0
let bytesForUpdate = length / 100
for try await byte in bytes {
data.append(byte)
bytesAccumulator += 1
if bytesAccumulator > bytesForUpdate {
progress = Double(data.count) / Double(length)
bytesAccumulator = 0
}
}
Here’s a detail of the code above:
-
bytesForUpdatecontains the number of bytes to accumulate before sending an update to the UI. -
bytesAccumulatoris an accumulator that counts the received bytes. Once the accumulator reachesbytesForUpdate, you:- Update the progress, which updates the UI.
- Reset the accumulator to
0for the next cycle.
With this change, you lowered the frequency of the UI updates so that this update doesn’t affect the download process.
Build and run the app, and check if the fix works.
Oh yes, that definitely worked: You can barely see the progress bar now!