In a few of the tutorials here, we have used URLSession to download data. Rather than skip over it as something that is used as part of a project, I thought I best create a tutorial on how to use it so that you can grasp what it is and what it is doing, as well as what else can be done with it.
URLSessions are useful for various reasons which include communicating with an external server to download large datasets, sending information to a server, particularly a backend service or image upload service, and many more uses. URLSession is the built in way of coordinating network data transfer tasks.
In this tutorial we’re going to grab a sample of blog posts in JSON format, decode them into a custom struct to display in the view. It will be very similar to the tutorial linked above although this time we will dive deeper into the URLSession and take a look at what that does, how it can be adjusted, as well as look at some of the delegate methods that are available.
struct Post: Decodable, Identifiable {
let id: Int
let title: String
let body: String
}
First create a Post struct that conforms to Decodable and Identifiable. Decodable is a protocol that allows you to work with external representations, such as with JSON. The other side of that is Encodable for when you want to convert back to JSON. If you want both, you can just use Codable instead of specifying both.
Identifiable is used because the List view that we will use later requires items to be Identifiable. This is one way to meet that requirement.
func fetchPosts() async throws -> [Post] {
let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Post].self, from: data)
}
Next, we create a new method that is declared as async and that returns an array of Posts.
Line 2 creates a URL from the provided string.
Line 3 fetches the data from the server by using a URLSession where we pass in the URL to use.
Line 4 is where we decode the JSON to an array of Posts.
The “throws” keyword next to the function name means that the caller handles any errors and would set up the call in a do-catch statement. This means that the caller gets to see the errors and can handle them in an approriate way. Yesterday we didn’t use the throws keyword and instead, handled the do-catch within the fetchUsers() method. In those instances, you might consider an error enum that you publish in the view model that can be checked in view to show the necessary logic such as a button to retry the fetch if it fails.
I also note that we have only briefly touched on the URLSession so far. Lets get the view in place first and the project running and we can then dig deeper into URLSession.
struct ContentView: View {
@State private var posts: [Post] = []
var body: some View {
List(posts) { post in
VStack(alignment: .leading) {
Text(post.title).font(.headline)
Text(post.body).font(.subheadline).foregroundColor(.gray)
}
}
.task {
do {
posts = try await fetchPosts()
} catch {
print("Error fetching posts:", error)
}
}
}
func fetchPosts() async throws -> [Post] {
let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Post].self, from: data)
}
}
We approach this differently to the Users tutorial from yesterday in that our fetchPosts method is within the view instead of being in a ViewModel. My personal preference is to remove this type of logic from the view, but for tutorial purposes, we’ll keep it in the view. Note that lines 20-24 in the View are where the function above go.
We start by declaring a variable with the @State property wrapper. We call it posts and its an array of Posts. If you recall, @State property wrappers update the view whenever the contents of it is changed.
Next, we create a List of posts and put the results in a VStack for each iteration. The VStack contains the title and body with a small bit of styling on it.
Line 11 is where we start the task. Yesterday we used .onAppear and Task within. This time we’re using .task. There are a few differences between a Task in .onAppear and a .task. The latter is tied to the lifecycle of the view and is cancelled when the view disappears. It only allows one task to run at a time. For this tutorial, it doesn’t matter which we use.
Lines 12 – 17 contain the do-catch and on line 13 we “try” await fetchPosts(). If any of the try’s on lines 22 or 23 within fetchPosts throw an error, then the throws keyword in that method pass the error to the do-catch within the view and we can act accordingly, such as a retry button to be added.
The code is working now and you can test the app out on a device, simulator, or in the preview.
URLSession
Lets go back to the main purpose of this tutorial which is the URLSession. A URLSession is used to fetch data from a server, upload/download files, handling authentication, and managing background tasks. Here are some ways to work with it:
- With Async/Await (seen in the example above)
- With a completion handler
- With delegate methods
We have looked at using async/await already and have seen that we mark the method as async, and then put the URLSession request in a do-catch with a try. The do-catch maybe around the request and the decoding to JSON, or it might be around the method marked with “throws” so that the caller can receive any errors if present.
This way is good and works well.
URLSession dataTask with Completion Handler
With the next that uses a completion handler, we can create the task and use the completion handler to get the data response, or error, along with the data. The code for this would be as follows:
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
print("Error: \(error.localizedDescription)")
return
}
guard let data = data else {
print("No data received")
return
}
do {
let posts = try JSONSerialization.jsonObject(with: data, options: [])
print("JSON Response: \(posts)")
} catch {
print("Failed to decode JSON: \(error.localizedDescription)")
}
}
task.resume()
In this code we begin by creating a task with a completion handler which contains data, response, and error. If an error occura, we pick that up on line 2 and print it to the console, and then return.
On line 7, if we don’t receive data, we could also print to the console to see that no data was received. If we reach the first, or second check, we would need to do something more than print to the console. We do this just for demo purposes here.
Finally, if we get past the first two checks, we can use a do-catch and “try” decode into JSON. If it fails, an error is printed to the console, but if all good, it prints the JSON response to the console.
At the end, we call task.resume(). Why resume? There is no start. The idea behind it is that you can suspend a task and then resume it.
Lets take a look at how this might be used in the app.
struct ContentView: View {
@StateObject private var viewModel = PostViewModel()
var body: some View {
VStack {
if let error = viewModel.error {
Text(error)
.foregroundColor(.red)
.padding()
}
List(viewModel.posts) { post in
VStack(alignment: .leading) {
Text(post.title).font(.headline)
Text(post.body).font(.subheadline).foregroundColor(.gray)
}
}
}
.onAppear {
viewModel.fetchPosts()
}
}
}
We have made some changes to our view here. We declare a variable and initialise it as a PostViewModel(). This is wrapped with the @StateObject so that it can observe @Published properties of the ViewModel we will create in a few moments.
One of those properties will be an error which is a string that will contain an error if one occured on fetching the data. If error contains a string, then the view will show the error.
Finally, we modify the .task changing it to .onAppear and call fetchPosts on the viewModel. We do not need to await this because fetchPosts works with a completion handler and @Published properties will be updated as soon as the completion handler is called.
Lets look at the ViewModel now:
class PostViewModel: ObservableObject {
@Published var posts: [Post] = []
@Published var error: String?
func fetchPosts() {
let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
DispatchQueue.main.async {
self.error = "Error: \(error.localizedDescription)"
}
return
}
guard let data = data else {
DispatchQueue.main.async {
self.error = "No data received"
}
return
}
do {
let decodedPosts = try JSONDecoder().decode([Post].self, from: data)
DispatchQueue.main.async {
self.posts = decodedPosts
}
} catch {
DispatchQueue.main.async {
self.error = "Failed to decode JSON: \(error.localizedDescription)"
}
}
}
task.resume()
}
}
We have two @Published properties that I mentioned earlier. One contains an array of Posts, and the other might contain an error. It is marked as optional as there may be no error.
We next declare the fetchPosts() method and use the code from above. If there is an error when the completion handler is called, self.error is set and the @Published property is observed and the view updates to show the error.
A change you might notice in this section is that we use DispatchQueue.main.async when updating the error or posts. URLSession.dataTask runs on a background thread, so by dispatching to main, we can keep the view updated promptly. It also makes the behaviour predictable when using DispatchQueue.main.async.
At the end, we call task.resume() to kick things off.
The process with using a dataTask is that the view calls fetchPosts(), the task is setup and is resumed. When it completes, the completion handler is called and the @Published properties are updated as needed, and then the view observes them and reacts as needed.
URLSession Delegates
The URLSession has a number of delegates for various purposes. Some of these include:
- URLSessionDelegate
- URLSessionTaskDelegate
- URLSessionDataDelegate
- URLSessionDownloadDelegate
Just in these four there are far too many to go over in a single tutorial, so we will look at the URLSessionDownloadDelegate.
class ImageDownloadViewModel: NSObject, ObservableObject, URLSessionDownloadDelegate {
@Published var image: UIImage? = nil
@Published var error: String?
@Published var progress: Double = 0.0
@Published var isLoading: Bool = false
private var urlSession: URLSession!
private var downloadedURL: URL?
override init() {
super.init()
let config = URLSessionConfiguration.default
urlSession = URLSession(configuration: config, delegate: self, delegateQueue: .main)
}
func fetchImage() {
guard let url = URL(string: "https://fastly.picsum.photos/id/188/1024/1024.jpg?hmac=jue2NNQvg346qR956uHfSmMSVcQvFGIzzP2P9VfEtgA") else {
error = "Invalid URL"
print("self.error = Invalid URL")
return
}
isLoading = true
progress = 0.0
error = nil
downloadedURL = nil
let task = urlSession.downloadTask(with: url)
task.resume()
}
// MARK: - URLSessionDownloadDelegate
/// Called when the download is in progress, updating the download progress.
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
if totalBytesExpectedToWrite > 0 {
let currentProgress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
DispatchQueue.main.async {
self.progress = currentProgress
print("self.progress updated: \(String(format: "%.2f", self.progress * 100))%")
}
}
}
/// Called when the download finishes successfully.
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
DispatchQueue.main.async {
self.isLoading = false
self.progress = 1.0 // Mark as complete
}
// Attempt to create a UIImage from the downloaded file
if let data = try? Data(contentsOf: location),
let image = UIImage(data: data) {
DispatchQueue.main.async {
self.image = image
print("Image download complete and loaded.")
}
} else {
DispatchQueue.main.async {
self.error = "Failed to load image."
print("self.error = Failed to load image.")
}
}
}
/// Called if the download task completes with an error.
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
DispatchQueue.main.async {
if let error = error {
self.error = "Download error: \(error.localizedDescription)"
print("self.error = \(self.error!)")
}
}
}
}
We declare a class called ImageDownloadViewModel that conforms to NSObject, ObservableObject, and URLSessiondownloadDelegate.
We have four variables that use the @Published property wrapper. One stores the image that we will download, the next stores an error that is marked as optional. We have a progress variable that stores a double that will be used to track progress between 0 and 100%, and an isLoading bool that shows either a progress bar or an image.
We have a couple of private variables declared on lines 7 and 8.
Lines 10–14 contain our initializer, which is responsible for setting up a URLSessionConfiguration and creating the URLSession. This allows us to use the configuration and assign self as the delegate, ensuring that this class implements the delegate methods and handles the received data.
Line 16 is where we have our fetchImage method defined. We create a URL string. We then set out defaults of isLoading to true, our progress to 0.0, a nil error and downloadedURL as nil.
Line 28 we create the downloadTask by passing it the URL. We also use the urlSession that is a private variable that was setup in the init. We then call task.resume() to begin.
Over the next few lines we implement three delegate methods.
The first delegate we implement is on line 35 and is responsible for handling the progress of the download. The delegate method passes us information on the download such as how many bytes have been written, and how many bytes are expected to be written. By dividing those together, we get a percentage of completion that is cast as a double. We then dispach to main and update self.progress. This delegate method gets called throughout the download.
The next delegate on line 46 onwards is called when the download completes. The location that has been written to is local on the device. When this delegate is called, we have the contents of the download at a location on the disk and fetch that as Data. We then convert that data to a UIImage and again, dispatch to main so that the view reloads.
The final delegate method we use is on line 68 and is called if an error with the download occurs. When this happens, we dispatch to main and set self.error to the message.
Lets now look at the view to finish off this tutorial:
struct ContentView: View {
@StateObject private var viewModel = ImageDownloadViewModel()
var body: some View {
VStack {
if let error = viewModel.error {
Text(error)
.foregroundColor(.red)
.padding()
}
if viewModel.isLoading {
ProgressView(value: viewModel.progress)
.progressViewStyle(LinearProgressViewStyle())
.padding()
}
if let image = viewModel.image {
Image(uiImage: image)
.resizable()
.scaledToFit()
.padding()
}
Button(action: {
viewModel.fetchImage()
}) {
Text("Download Large Image")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.padding()
}
.onAppear {
// Optionally start downloading immediately
// viewModel.fetchImage()
}
}
}
Line 2 we create an @StateObject wrapped variable that is the viewModel we just created.
In our view we have a VStack that contains some if let statements, the first which shows an error if error contains a string. The next if let is the isLoading method. If this is true, then we add a ProgressView that uses the viewModel’s progress published property. The value is between 0.0 and 1.0 and shows the progress as the image is downloaded.
When the download completes, the Image view is shown and displays the image.
We have a Button view on line 25 onwards that has an action that calls the fetchImage method on the viewModel. Alternatively, if you want the image to download when the view appears, you can set that on line 38.
When you test the app now, you’ll see the progress bar briefly moving. To help you see the progress, it also prints to the console.
Any questions, please comment below.
Leave a Reply
You must be logged in to post a comment.