24.
Downloading Data
Written by Audrey Tam
Most apps access the internet in some way, downloading data to display or keeping user-generated data synchronized across devices. TheMet app needs to create and send HTTP requests and process HTTP responses. Downloaded data is usually in JSON format, which your app needs to decode into its data model.
If your app downloads data from your own server, you might be able to ensure the JSON structure matches your app’s data model. But TheMet needs to work with the metmuseum.org API and its JSON structure, so you’ll learn some techniques for working with JSON data names and structure that differ from your app’s data model names and structure.
Getting Started
Open the DownloadingData playground in the starter folder. If the editor window is blank, show the Project navigator (Command-1) and select DownloadingData there.
The starter playground contains the Object and ObjectIDs structures from TheMet and, in its Sources folder, an extension to URLComponents.
Playgrounds are useful for exploring and working out code before moving it into your app. You can quickly inspect values produced by code and methods without needing to build a user interface or search through a lot of debug console messages.
URLSession
URLSession is Apple’s framework for HTTP messages. Apple’s documentation page includes this note:
Note: The
URLSessionAPI involves many different classes that work together in a fairly complex way which may not be obvious if you read the reference documentation by itself.
To send a request to a server, you need to perform several steps:
-
Specify a
URLSessionto coordinate data-transfer tasks. For the simple download tasks in TheMet, you’ll use the built-insharedsession, which provides a reasonable default behavior. -
Create a
URLfrom aStringlikehttps://metmuseum.org. This could fail — for example, if the string is empty. -
Create a
URLRequestfrom thisURL: This object contains the HTTP method and headers and other request properties. -
Send the
URLRequestin thesharedsession andawaitaDatainstance and aURLResponseobject. -
Decode the data into your app’s data model: Most REST APIs send JSON data. You’ll use a
JSONDecoderto decode this into your data model. The code is much simpler than what you did in Chapter 19, “Saving Files” because TheMet needs only a few properties from the metmuseum.org API, and its JSON names and structures are a good match for TheMet’s data model.
Note:
URLSessionand the broader topic of networking have their own video courses: Beginning Networking with URLSession and Advanced Networking with URLSession.
Asynchronous Methods
Most URLSession methods involve network communication, so you can’t predict how long they’ll take to complete. In the meantime, the system must continue to interact with the user. To make this possible, URLSession methods are asynchronous: They dispatch their work onto another queue and immediately return control to the main queue, so it can respond to user interface events. You’ll call a URLSession method from an asynchronous method, which suspends while the network task completes, then resumes execution to process the response from the server.
Note: Learn more about asynchronous methods and concurrency in our book Modern Concurrency in Swift and its companion video courses Modern Concurrency: Getting Started and Modern Concurrency: Beyond the Basics.
Creating a REST Request URL
A REST request URL often includes query parameters. Here’s one from the metmuseum.org’s API:
https://collectionapi.metmuseum.org/public/collection/v1/search?medium=Quilts|Silk|Bedcovers&q=quilt
This URL lists query parameter names and values after the ? separator. The query parameter medium=Quilts|Silk|Bedcovers matches any object whose medium is Quilts or Silk or Bedcovers. You can send the request just like this in Postman or Safari but, to send it from your app, the | character must be URL-encoded as %7C:
https://collectionapi.metmuseum.org/public/collection/v1/search?medium=Quilts%7CSilk%7CBedcovers&q=quilt
You certainly don’t want to do the URL-encoding yourself! Fortunately, you can hand this work over to URLComponents and URLQueryItem. You’ll use these in this playground to create a flexible approach to composing REST requests, so you can easily change query parameter values.
URLComponents
URLComponents enables you to construct a URL from its parts and, also, to access the parts of a URL. Components include scheme, host, port, path, query and queryItems. URL itself gives you access to URL components like lastPathComponent.
➤ Add this code to the playground, below the two structs:
let baseURLString = "https://collectionapi.metmuseum.org/public/collection/v1/"
var urlComponents = URLComponents(
string: baseURLString + "search")!
urlComponents.queryItems = [
URLQueryItem(name: "medium", value: "Quilts|Silk|Bedcovers"),
URLQueryItem(name: "q", value: "quilt")
]
urlComponents.url
urlComponents.url?.absoluteString
You set the URL string for the API’s base endpoint and add the search endpoint to create a URLComponents instance. Then, you create an array of URLQueryItem values. The URLQueryItem parameters are the parameter names and values in the sample request.
The last line displays the final URL string in the sidebar. The line above it displays the final URL. What’s the difference? Time to find out!
Note: In a playground, you can write an expression on its own line to display its value.
➤ Click the Execute Playground arrow on the last line number or at the bottom of the playground:
Note: Clicking the arrow next to a line of code runs the playground only up to that line.
If nothing appears in the sidebar, click the stop button, then click the run arrow again.
The sidebar displays values for some lines with buttons for Quick Look and Show Result.
➤ Click the Show Result button of the last code line to show the result below the code line. You can click the display window to resize it, if necessary.
"https://collectionapi.metmuseum.org/public/collection/v1/search?medium=Quilts%7CSilk%7CBedcovers&q=quilt"
Thanks to urlComponents, your query parameters are safely URL-encoded and appended to the base URL.
➤ In the sidebar, look at the url on the line above. Notice it’s not in quotation marks, because it’s not a String. In fact, it’s an Optional. Click its Quick Look button:
The playground tries its best to open the URL. Click anywhere to close the quick-look window.
You can create a URL from a String, if the String has all the right parts. Then, you can access these parts as properties of the URL instance: host, baseURL, path, lastPathComponent, query and so on.
If you try to create a URL from a String that wouldn’t work in a browser, the initializer returns nil. That’s why urlComponents.url is an Optional and there’s a url? in the last code line: If url is nil, it doesn’t have an absoluteString property.
➤ Click Hide Result to close the show-result window.
Note: You can also
print(urlComponents.url?.absoluteString ?? "")to see the printed value in the Debug area below. If you’re not able to see the Debug area, click the button in the lower right corner or press Shift-Command-C.
URLComponents Helper Method
URLQueryItem makes it easy to add a query parameter name and value to the request URL. The name and value arguments of URLQueryItem look like dictionary key and value items, so it’s easy to create a dictionary of parameter names and values, then transform this dictionary into a queryItems array. It’s especially easy when Alfian Losari has already done it. :] It’s in Sources/URLComponentsExtension.swift in this playground.
➤ Replace the urlComponents.queryItems definition with:
var baseParams = [
"hasImages": "true",
"q": ""
]
urlComponents.setQueryItems(with: baseParams)
You create a dictionary whose keys are query parameter names. The first parameter hasImages matches any object whose web page displays at least one image. You include q (search term) with the default value "", and this dictionary lets you easily change its value.
The setQueryItems(with:) helper method defined in the URLComponents extension creates a URLQueryItem for each dictionary item and sets the queryItems array of the URLComponents instance.
➤ To set a query term, add this code below urlComponents.url?.absoluteString:
baseParams["q"] = "rhino"
urlComponents.setQueryItems(with: baseParams)
urlComponents.url?.absoluteString
You’re adding the query parameter q=rhino to the request URL by setting baseParams["q"] = "rhino" then calling setQueryItems(with:).
➤ Execute the playground and show the absoluteString results:
Or the parameters might be ordered the other way around:
Because baseParams is a dictionary, you can’t control the order of its elements.
➤ Copy and paste your absoluteString into your browser to see what you get.
If q=rhino is at the end of your absoluteString, you’ll get:
{"total":5,"objectIDs":[640907,452174,241715,452648,679031]}
The response body contains JSON data that maps directly into your ObjectIDs structure. Only eight objects match q=rhino, and only five of these have images.
If q=rhino appears before hasImages=true, you’ll get:
{"total":123,"objectIDs":[551786,472562,317877,544740,729644,329077,464273,53660,437422,459028,459027,544320,200668,451725,854970,435848,824771,436102,192770,438821,310453,437261,460281,453385,452102,207157,237451,817962,549236,438779,53162,838076,464294,435864,436885,436884,852562,748565,39901,437368,776714,435897,436098,204587,439327,197461,197460,437061,437173,844492,383883,485416,437202,334030,811771,811772,377933,687615,436099,452032,76034,437059,436838,437868,430812,850659,347927,736196,437216,626692,435621,759529,822751,448959,452740,40080,436658,888663,436803,437384,435702,435844,892108,36225,764636,436105,39742,437585,228995,436529,499559,437878,60470,464132,452364,200840,228990,53238,452651,201718,436607,437508,435991,712539,464118,435997,451287,73651,488221,44759,437873,341703,437159,453895,733847,448280,39895,437936,775454,450761,450605,435678,894011]}
This behavior is … unexpected. And not correct: None of these objects matches q=rhino. It seems the metmuseum.org API has an undocumented requirement that the q parameter must be the final parameter. So, for this API, you’ll need to add the q parameter manually.
➤ In the baseParams initialization, delete q="" and the comma at the end of "hasImages": "true":
var baseParams = [
"hasImages": "true"
]
➤ Replace the last three lines with this code:
let queryTerm = "rhino wolf"
urlComponents.queryItems! += [URLQueryItem(name: "q", value: queryTerm)]
urlComponents.queryItems
urlComponents.url?.absoluteString
The queryItems property of urlComponents is an array of URLQueryItem name-value pairs, so you add the q parameter as a single-item array. Because it’s an array, the q parameter is always the final parameter, every time you run the playground. It’s actually an optional, but you know it isn’t nil because you already called urlComponents.setQueryItems(with:), so it’s safe to force-unwrap it.
➤ Execute the playground to check urlComponents.url?.absoluteString:
And URLComponents takes care of URL-encoding the space in "rhino wolf".
Now, you’re all set to send a request with this URL in the playground, then decode the data.
Sending the Request With URLSession
You’ve got your URL. Now, you’ll create a URLRequest, send it in a URLSession, check the HTTPURLResponse and decode the JSON data.
➤ Add this code:
let queryURL = urlComponents.url // 1
let request = URLRequest(url: queryURL!)
let session = URLSession.shared // 2
let decoder = JSONDecoder() // 3
Task { // 4
let (data, response) = try await session.data(for: request)
guard
let response = response as? HTTPURLResponse,
(200..<300).contains(response.statusCode)
else {
print(">>> response outside bounds")
return
}
let objectIDs = try decoder.decode(ObjectIDs.self, from: data) // 5
objectIDs.objectIDs
}
-
You create a
URLRequestwithqueryURL. In this playground, you knowqueryURLis a valid URL, so it’s safe to force-unwrap it. When this code is in a method, you would do this assignment in aguardstatement and exit if the value isnil. -
URLSessionmethods run in a session. The framework provides asharedsession with default configuration that you can use for simple requests. If you need a custom configuration, you can create your own session. For example, here’s how you’d create a session that waits 300 seconds for a network connection:
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
config.timeoutIntervalForResource = 300
let session = URLSession(configuration: config)
-
You create a
JSONDecoderto decode the JSON data you’re about to download. -
data(for:)is asynchronous. To run it in a playground, you create an asynchronousTaskto send the request, await thedataandresponse, then check that the status code is in the success range. -
Finally, you decode the data into an
ObjectIDsinstance, then display the array of object IDs.
➤ Execute the playground to see the IDs of the two matching objects:
Next, you’ll send requests for each of these objects but first, a brief discussion about decoding JSON.
Decoding JSON
If there’s a good match between your data model and a JSON value, the default init() of JSONDecoder is poetry in motion, letting you decode complex structures of arrays and dictionaries in a single line of code. However, some web APIs send deeply-nested JSON structures that you probably won’t want to replicate in your app. Then, you’ll need to use CodingKey enumerations and custom init(from:) methods.
Decoding What You Need
In Chapter 19, “Saving Files”, you saw how easy it is to encode and decode the Team structure as JSON because all its properties are Codable. You were saving and loading your app’s own data model, so item names and structure in JSON format exactly matched your Team structure.
JSON values sent by real-world APIs rarely match the way you want to name or structure your app’s data. Very often, your app doesn’t need all the JSON values. You saw in the previous chapter the enormous number of fields in an object’s record:
The Object structure in your app contains only six of these:
struct Object: Codable, Hashable {
let objectID: Int
let title: String
let creditLine: String
let objectURL: String
let isPublicDomain: Bool
let primaryImageSmall: String
}
This will work perfectly fine: JSONDecoder will decode only these values from the response data and ignore the rest.
Note: If you need Swift structures and coding keys for the complete JSON schema, paste a sample response body into quicktype and select the Swift and Struct options.
Decoding When JSON Name != Property Name
JSON values sent by real-world APIs might not match the way you want to name or structure your app’s data.
Many APIs use snake_case for JSON names, but Swift property names use camelCase. There’s an easy fix; you simply tell the decoder to do the translation:
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
This takes care of translating a JSON name like artistWikidata_URL to a Swift property name like artistWikidataURL.
If the JSON structure matches your app’s data model, but some of the names are different, you simply have to define a CodingKey enumeration to assign JSON item names to your data model properties. For example, Object has a primaryImageSmall property that matches the JSON item’s name. The value of this key is a URL string, so you might want to name your app’s property imageURL. Here’s how you’d do it:
enum CodingKeys: String, CodingKey {
case imageURL = "primaryImageSmall"
case objectID, title, creditLine, objectURL, isPublicDomain
}
Unfortunately, as soon as you create a CodingKey enumeration for one property name, you must include all the property names, even those that already match JSON item names.
Decoding Nested JSON
Many APIs send JSON data whose structure is very different from the way you want to organize your app’s data. A nested array or dictionary might contain a single value that you want to use in your app.
Fortunately, this isn’t the case with the metmuseum.org data you need for TheMet, although the Object record does contain a few arrays. Suppose you want to use the Wikidata_URL of one of the term items in the tags array:
"tags": [
{
"term": "Animals",
"AAT_URL": "http://vocab.getty.edu/page/aat/300249525",
"Wikidata_URL": "https://www.wikidata.org/wiki/Q729"
},
{
"term": "Poetry",
"AAT_URL": "http://vocab.getty.edu/page/aat/300055931",
"Wikidata_URL": "https://www.wikidata.org/wiki/Q482"
},
{
"term": "Men",
"AAT_URL": "http://vocab.getty.edu/page/aat/300025928",
"Wikidata_URL": "https://www.wikidata.org/wiki/Q8441"
},
{
"term": "Horses",
"AAT_URL": "http://vocab.getty.edu/page/aat/300250148",
"Wikidata_URL": "https://www.wikidata.org/wiki/Q726"
}
]
There are two approaches to decoding nested JSON:
- Define your data model to mirror the JSON value and see how nifty automatic JSON decoding can be.
- Flatten the JSON value into your data model: In exchange for more decoding work, you’ll get sensible data structures that are easier and more natural to work with.
To see how to do this and much more, check out our tutorial Encoding and Decoding in Swift.
Downloading Objects
Now, back to your playground code, where you sent a query request then decoded the response data into an ObjectIDs instance. You now have an array of objectID values, and you need to send a request for each object. You’ll store the downloaded objects in an array.
➤ Above the Task closure, add this line:
var objects: [Object] = []
You create an empty array of Object items.
➤ In the Task closure, add this code:
for objectID in objectIDs.objectIDs {
let objectURLString = baseURLString + "objects/\(objectID)" // 1
let objectURL = URL(string: objectURLString)
let objectRequest = URLRequest(url: objectURL!)
let (data, response) = try await session.data(for: objectRequest) // 2
guard
let response = response as? HTTPURLResponse,
(200..<300).contains(response.statusCode)
else {
print(">>> response outside bounds")
return
}
let object = try decoder.decode(Object.self, from: data) // 3
objects.append(object)
}
objects
You loop over the values in objectIDs.objectIDs. For each objectID, you run code that’s very similar to what you did for the query request:
- You construct the endpoint URL for this
objectIDand use it to create aURLRequest. - You send the request, await the data and response, then check the status code.
- You decode
datainto anObjectinstance, then append it to yourobjectsarray. To check the array, you display it.
➤ Execute the playground, show the result for objects and open an object:
Your JSON decoding is all working, and this is as much as you can test in a playground. You’re ready to copy and adapt all this code into your app to make everything work!
Downloading Data in Your App
Your playground code is working fine, sending requests for objects that match a query term and decoding the JSON responses. Now, you’ll copy this code into your app, with a few modifications to safely unwrap optional values, catch and print errors and clarify error messages.
➤ Open the project in this chapter’s starter folder: It’s the same as the final project from Chapter 22, plus URLComponentsExtension.swift. If you prefer to continue with your project, open the playground’s navigation panel and drag this file into your project from the playground’s Sources folder.
➤ In Project navigator, in the Preview Content group, delete MetStoreDevData.swift. You’ll initialize the objects array from URLSession response data.
➤ In TheMetStore.swift, replace init() with this code:
func fetchObjects(for queryTerm: String) async throws {
}
Next, you’ll use your playground code to implement some helper methods that fetchObjects(for:) will call. The helper methods will be asynchronous and might throw errors, so fetchObjects(for:) also has these keywords.
TheMetService
It’s good practice to keep your networking code separate from your data model, in a separate file. And, it’s common to use either “networking” or “service” in the filename.
➤ Create a new Swift file and name it TheMetService.swift. Below import Foundation, add this structure:
struct TheMetService {
let baseURLString = "https://collectionapi.metmuseum.org/public/collection/v1/"
let session = URLSession.shared
let decoder = JSONDecoder()
func getObjectIDs(from queryTerm: String) async throws -> ObjectIDs? {
// insert code here
return nil
}
func getObject(from objectID: Int) async throws -> Object? {
return nil
}
}
The first three lines are from your playground, and you’ll adapt playground code to implement the two methods. fetchObjects(queryTerm:) in TheMetStore will first call getObjectIDs(from:), then loop over the objectIDs array, calling getObject(from:) for each objectID.
Both methods are async because they’ll call session.data(for:), and both methods can rethrow errors thrown by session.data(for:). The JSONDecoder can also throw errors, but these errors are extremely useful for finding any JSON-decoding problems, so you’ll catch and print them right away. For now, both methods return nil, so the compiler doesn’t complain.
getObjectIDs(from:)
➤ In getObjectIDs(from:), insert this code above return nil:
let objectIDs: ObjectIDs? // 1
guard
var urlComponents = URLComponents(string: baseURLString + "search")
else { // 2
return nil
}
let baseParams = ["hasImages": "true"]
urlComponents.setQueryItems(with: baseParams)
urlComponents.queryItems! += [URLQueryItem(name: "q", value: queryTerm)]
guard let queryURL = urlComponents.url else { return nil }
let request = URLRequest(url: queryURL)
- You’ll decode
dataintoobjectIDs, then return this structure. - You create the
URLRequest, taking greater care to unwrap most of the optional values.
➤ Now, replace the final return nil with this code:
let (data, response) = try await session.data(for: request) // 1
guard
let response = response as? HTTPURLResponse,
(200..<300).contains(response.statusCode)
else {
print(">>> getObjectIDs response outside bounds")
return nil
}
do { // 2
objectIDs = try decoder.decode(ObjectIDs.self, from: data)
} catch {
print(error)
return nil
}
return objectIDs // 3
- This is the playground code that calls
data(for:), awaitsdataandresponse, then checks the status code. You addgetObjectIDsto the print message, so you know which method had the problem. BecausegetObjectIDsis an asynchronous method, it already runs in an asynchronous context, so you don’t need to embed this code in aTask. - The decoder can throw errors, so you call it in a do-catch statement. You print the raw
errorvalue, as this gives you more information about what went wrong. - If execution reaches this line, everything has worked without errors, and you return
ObjectIDs.
getObject(from:)
fetchObjects(queryTerm:) in TheMetStore will loop over the objectIDs array, calling getObject(from:) for each objectID.
➤ In getObject(from:), replace return nil with this code:
let object: Object? // 1
let objectURLString = baseURLString + "objects/\(objectID)" // 2
guard let objectURL = URL(string: objectURLString) else { return nil }
let objectRequest = URLRequest(url: objectURL)
let (data, response) = try await session.data(for: objectRequest) // 3
if let response = response as? HTTPURLResponse {
let statusCode = response.statusCode
if !(200..<300).contains(statusCode) {
print(">>> getObject response \(statusCode) outside bounds")
print(">>> \(objectURLString)")
return nil
}
}
do { // 4
object = try decoder.decode(Object.self, from: data)
} catch {
print(error)
return nil
}
return object // 5
-
You’ll decode
dataintoobject, then return this structure. -
You create the
URLRequest, taking greater care to unwrap the optional value. You don’t need to useURLComponentto constructobjectURLStringbecauseobjectIDis anInt, so there won’t be any characters that need to be URL-encoded. -
This is modified from the playground code that calls
data(for:), awaitsdataandresponse, then checks the status code. SomeobjectIDvalues return 404-not-found, so you print the actualstatusCodeand the problem URL string. -
The decoder can throw errors, so you call it in a do-catch statement.
-
If execution reaches this line, everything has worked without errors, and you return the resulting
Object.
Now, head back to TheMetStore to use these two methods.
Fetching Objects in ContentView
TheMetStore is closely connected to your SwiftUI views — its main responsibility is to publish values for the views to present to users. It uses TheMetService to do this.
fetchObjects(for:)
➤ In TheMetStore.swift, add these properties to TheMetStore:
let service = TheMetService()
let maxIndex: Int
You create an instance of TheMetService so you can call its methods. getObjectIDs(from:) could return a very large array, so you’ll use maxIndex to cap the number of calls to getObject(from:). This is a courtesy to The Metropolitan Museum, which asks “Please limit request rate to 80 requests per second.” You’ll also use this value in the next chapter to keep your widget testing manageable.
➤ Next, add this initializer:
init(_ maxIndex: Int = 30) {
self.maxIndex = maxIndex
}
You set 30 as the default maxIndex value. You don’t need to change the @StateObject line in ContentView unless you want a different maxIndex value.
➤ Now, add this code to fetchObjects(for:):
if let objectIDs = try await service.getObjectIDs(from: queryTerm) { // 1
for (index, objectID) in objectIDs.objectIDs.enumerated() // 2
where index < maxIndex {
if let object = try await service.getObject(from: objectID) {
objects.append(object)
}
}
}
- First, you call
getObjectIDs(from:)and wait for it to returnobjectIDs. - Then, you loop over
objectIDs.objectIDs— at mostmaxIndexof them — callinggetObject(from:)for eachobjectID. If it returns anObject, you append it to yourobjectsarray.
You’re nearly there! Head back to ContentView.swift to call fetchObjects(for:).
Sending the First Request
➤ In the body of ContentView, fold the VStack so you can see the closing brace of NavigationStack, then add this modifier to NavigationStack:
.task {
do {
try await store.fetchObjects(for: query)
} catch {}
}
When the app starts, ContentView appears and this task runs. Because it modifies NavigationStack, it runs only once, no matter how often you navigate to ObjectView and back to ContentView.
➤ Refresh Live Preview:
The app lists the three public-domain objects that match “rhino”. Tap Terracotta oil lamp to see a rhino tossing a lion with its horn — that explains why this object matches “rhino”.
Next, you need to call fetchObjects(for:) when the user enters a new query term.
Sending a New Request
Tapping the Search the Met button shows an alert where you can enter a new query term. Tapping Search or the return key should call fetchObjects(for:).
➤ Unfold the VStack and locate the alert modifier. Add this code inside the button’s action closure:
Task {
do {
store.objects = []
try await store.fetchObjects(for: query)
} catch {}
}
SwiftUI views run synchronously, so you embed the call to fetchObjects(for:) in a Task.
➤ Build and run the project in a simulator, then tap the search button and enter a term like “persimmon”:
You get a much longer list, including two non-public-domain objects that have images.
What if you decide to search for a different term while the app is still downloading objects for the current query term?
➤ Search for something that fetches a lot of objects, like “cat”. While these are still downloading, search for “rhino” — you know this query returns only three objects.
Although the alert button’s action resets objects to the empty array, the “cat” task continues to run, so cat objects swamp the three rhino objects.
➤ Stop the project in Xcode to stop the cat downloads.
Canceling the Running Task
You need to cancel the running task before starting the next task. To cancel a task, you must give it a name.
➤ Add this property to ContentView:
@State private var fetchObjectsTask: Task<Void, Error>?
You’ll store the alert button task in fetchObjectsTask and cancel it before you start the next task.
➤ In the alert button’s action code, replace Task { with:
fetchObjectsTask?.cancel()
fetchObjectsTask = Task {
If there’s a fetchObjectsTask, you cancel it. In any case, save the new Task to fetchObjectsTask.
➤ Build and run the project in a simulator, search for “cat”, then search for “rhino”:
That worked! But, what are these purple errors?
Publishing Changes on the Main Thread
“Publishing changes …”: Whenever you call fetchObjects(for:) to run a new search, store publishes updates to objects, which updates the list in ContentView.
➤ Open TheMetStore.swift:
The purple points to the line of code that appends object to the published property objects. ContentView subscribes to this property by creating an instance of TheMetStore. The asynchronous method fetchObjects(for:) changes objects, which changes ContentView.
“… from background threads”: What’s this about threads? Your app runs its code on threads — the main thread and one or more background threads. The user interface of every iOS app runs on the main thread. A SwiftUI app’s user interface consists of SwiftUI views, so these always run on the main thread. The main thread must always be responsive to the user, so asynchronous methods run on a background thread to avoid slowing down or blocking the main thread.
Here’s the problem: Any code that updates the user interface must run on the main thread. If an asynchronous method contains code that updates the user interface, it must somehow run that code on the main thread. By default, Xcode runs a Main Thread Checker, which you can see in the Edit Scheme… ➤ Diagnostics tab:
To run UI-update code on the main thread from an asynchronous function, the “old concurrency” way was to dispatch this code to the main queue. The new Swift concurrency way to do this is to run the code on MainActor.
➤ In fetchObjects(for:), replace objects.append(object) with this code:
await MainActor.run {
objects.append(object)
}
This is the only line of code that must run on the main thread, so you embed it in a MainActor.run closure. You use await to call it asynchronously, so the system can suspend and resume execution on the correct actor.
Note: You can annotate a method with
@MainActorto ensure it runs on the main thread or annotate a property to ensure it can only be updated from the main thread. Or you can annotate an entire class with@MainActor, if almost all its properties and methods need to be on the main thread, then mark any exceptions with thenonisolatedkeyword. Learn more about Swift concurrency from our book Modern Concurrency in Swift or video courses Modern Concurrency: Getting Started and Modern Concurrency: Beyond the Basics.
➤ Build and run the project in a simulator, then enter a search term:
Purple problem solved! Your app is working perfectly, but there’s one last thing…
Showing a Progress View
After entering a new query term, there’s a brief moment when the list is blank. Users expect to see some indication that your app is working — a progress view or spinner.
➤ In ContentView.swift, add this modifier to VStack:
.overlay {
if store.objects.isEmpty { ProgressView() }
}
➤ Refresh Live Preview, then enter a new search term:
A progress view spins until the first object appears.
Congratulations, you’ve built a working app for exploring the collections of The Metropolitan Museum of Art, New York. And, you’re now ready to apply everything you’ve learned about URLSession, URLComponents and JSONDecoder to your own apps.
Key Points
-
The
URLSessionAPI involves many different classes that work together in a fairly complex way. For simple download tasks, use the built-insharedsession. UseURLComponentsto create a URL-encodedURLfrom the endpoint string and query parameters, then create aURLRequestfrom thisURL. Send this request withdata(for:)andawaittheDatainstance andURLResponseobject. If the status code indicates success, use aJSONDecoderto decode the data into your data model. -
Create a separate structure to define your networking methods, then instantiate this in your data model.
-
Asynchronous methods run on background threads. Any code that updates the user interface must run on the main thread. One way to do this is to call
MainActor.run. -
Playgrounds are useful for working out code. You can quickly inspect values produced by methods and operations.