SwiftUI and Structured Concurrency [FREE]
source link: https://www.raywenderlich.com/31056103-swiftui-and-structured-concurrency
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
SwiftUI and Structured Concurrency
Learn how to manage concurrency into your SwiftUI iOS app.
Version
Swift 5.5 delivered an exciting frontier to explore: Swift Concurrency. If you’ve had any experience writing asynchronous code or using asynchronous APIs, you’re familiar with how complicated it can be. Debugging asynchronous code can make you feel like you’re on another world, perhaps even Mars!
The new Swift Concurrency API promises a simpler, more readable way to write asynchronous and parallel code. The more you explore the landscape of Swift Concurrency, the more you’ll discover the sophistication provided by a simple API.
In this tutorial, you’ll build Roving Mars, an app that lets you follow the Mars rovers and see the photos they take daily during their missions.
Along the way, you’ll learn:
- How to use
AsyncImage
in SwiftUI to manage the presentation of remote images. - The difference between structured and unstructured concurrency in Swift.
- How to use a
TaskGroup
to handle concurrent asynchronous tasks.
Getting Started
Use Download Materials at the top or bottom of this tutorial to download the starter project. Open it in Xcode and run it to see what you have to work with.
The first tab shows the latest photos from the Mars rovers. The second tab lets users explore all the available photos.
Your app is pretty empty at the moment. Hurry, space awaits!
Downloading a Photo
AsyncImage
is one of SwiftUI’s newest features. Every mobile app developer has dealt with downloading an image asynchronously, showing a placeholder while it downloads and then showing the downloaded image when it’s available. AsyncImage
wraps this whole process in a simple wrapper.
Time to don your space suit and start exploring.
Open LatestView.swift from the starter project. Replace MarsProgressView()
with:
AsyncImage( //1 url: URL(string: "https://mars.nasa.gov/msl-raw-images/proj/msl/redops/ods/surface/sol/03373/opgs/edr/ncam/NRB_696919762EDR_S0930000NCAM00594M_.JPG") //2 ) { image in image .resizable() .aspectRatio(contentMode: .fit) //3 } placeholder: { MarsProgressView() }
Here’s a step by step breakdown:
- Here’s the image’s URL. It’s straight from the NASA collection.
- This closure is designed to output the view you’ll display when the image downloads. The closure argument is an
Image
, which you can return directly or you can add your own views or modifiers. In this code you’re making the image resizable and setting the aspect ratio to fit the available space. - This closure outputs the placeholder displayed while the image downloads. You use the
MarsProgressView
that’s already prepared for you, but a standardProgressView
also works.
Build and run the app. First, you’ll see the placeholder. Then the Image
will appear once the download is complete.
That worked well! But what if you encounter an error while downloading? You’ll address that issue next.
Responding to Download Errors
AsyncImage
has an alternative that allows you to respond to a download error. Replace your first AsyncImage
with:
AsyncImage( url: URL(string: "https://mars.nasa.gov/msl-raw-images/proj/msl/redops/ods/surface/sol/03373/opgs/edr/ncam/NRB_696919762EDR_S0930000NCAM00594M_.JPG") ) { phase in switch phase { //1 case .empty: MarsProgressView() //2 case .success(let image): image .resizable() .aspectRatio(contentMode: .fit) //3 case .failure(let error): VStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) Text(error.localizedDescription) .font(.caption) .multilineTextAlignment(.center) } @unknown default: EmptyView() } }
The closure is passed an AsyncImagePhase
enum value, of which there are three cases:
-
.empty
: An image isn’t available yet, so this is the perfect spot for the placeholder view. -
.success(let image)
: An image was successfully downloaded. This value contains an image you can output as you like. -
.failure(let error):
: If an error occurred while downloading the image, you use this case to output an alternative error view. In your app, you show a warning symbol from SF Symbols and thelocalizedDescription
of the error.
Build and run your app, and you’ll see it works the same as before.
To test the placeholder, change the URL
argument to nil
. Or, try testing the error by changing the domain in the URL
string to a nonexistent domain such as "martianroversworkersunion.com"
.
So far, so good. But how do you display the latest photos from all the rovers?
Using the Mars Rover API
That image URL is from the NASA archive, which also has many public APIs available. For this app, your data source is the Mars Rover Photos API.
First, you need to get an API key. Visit NASA APIs and fill in the Generate API Key form. You’ll need to append the API key to all the API requests your app makes.
Once you’ve obtained your key, and to test that it works, enter the following URL into a browser address field and replace DEMO_KEY
with your API key.
https://api.nasa.gov/mars-photos/api/v1/rovers/curiosity/latest_photos?api_key=DEMO_KEY
You’ll see a large JSON payload returned.
All Mars Rover Photos API requests return a JSON response. In MarsModels.swift you’ll find all the Codable
structs to match each type of response.
Now open MarsRoverAPI.swift and find the line at the top of the file that reads:
let apiKey = "YOUR KEY HERE"
Replace YOUR KEY HERE
with the API key you obtained from the NASA site.
It’s now time to align your dish and make contact!
Fetching Rover Images
You first need to request the latest photos. Open LatestView.swift and add this to LatestView
:
// 1 func latestPhotos(rover: String) async throws -> [Photo] { // 2 let apiRequest = APIRequest<LatestPhotos>( urlString: "https://api.nasa.gov/mars-photos/api/v1/rovers/\(rover)/latest_photos" ) let source = MarsRoverAPI() // 3 let container = try await source.request(apiRequest, apiKey: source.apiKey) // 4 return container.photos }
Here’s how this works:
- You make
latestPhotos(rover:)
a throwing async function because theMarsRoverAPI
function that it calls is also throwing and async. It returns an array ofPhoto
. - Use
APIRequest
to specify the URL to call and how to decode the JSON response. - You call the
apiRequest
endpoint and decode the JSON response. - Return a
Photo
array.
Now in LatestView
, add a state property to hold the photos:
@State var latestPhotos: [Photo] = []
In MarsImageView.swift you’ll also find a view that incorporates the AsyncImage
you already built. It takes a Photo
and displays the image along with some interesting information.
Since you’ll display several photos, you need to present them using ForEach
.
Back in LatestView.swift, replace your previous test AsyncImage
with:
// 1 ScrollView(.horizontal) { HStack(spacing: 0) { ForEach(latestPhotos) { photo in MarsImageView(photo: photo) .padding(.horizontal, 20) .padding(.vertical, 8) } } } // 2 if latestPhotos.isEmpty { MarsProgressView() }
Here’s a code breakdown:
- You make a
ScrollView
containing aHStack
and loop through thelatestPhotos
, creating aMarsImageView
for eachPhoto
. - While
latestPhotos
is empty, you display theMarsProgressView
.
Build and run the app to see the latest Mars photos.
Wait … nothing is happening. The app only shows the loading animation.
You have a stage missing.
Creating a Task
You need to call latestPhotos(rover:)
to start the process. In SwiftUI, a new view modifier runs an async task attached to a View
.
In LatestView
, add the following modifier to the HStack
:
// 1 .task { // 2 latestPhotos = [] do { // 3 let curiosityPhotos = try await latestPhotos(rover: "curiosity") let perseverancePhotos = try await latestPhotos(rover: "perseverance") let spiritPhotos = try await latestPhotos(rover: "spirit") let opportunityPhotos = try await latestPhotos(rover: "opportunity") // 4 let result = [ curiosityPhotos.first, perseverancePhotos.first, spiritPhotos.first, opportunityPhotos.first ] // 5 latestPhotos = result.compactMap { $0 } } catch { // 6 log.error("Error fetching latest photos: \(error.localizedDescription)") } }
Here’s a step by step breakdown:
-
task(priority:_:)
takes a priority argument and a closure. The default priority is.userInitiated
and that’s fine here. The closure is an asynchronous task to perform, in this case, fetch the photos from the API. - You first set
latestPhotos
to an empty array so the progress view displays. - Then you make four async API requests for rovers. Each call is marked
await
so the task waits until a request is complete before moving onto the next. - Once all the requests finish, store the first photo from each in an array.
- You compact the resulting array to remove any
nil
values. - Finally, you catch and log any errors.
Build and run your app. Latest Photos now shows a rotating Mars while the requests complete. Activity indicators appear for each MarsPhotoView
while images download.
Your reward appears in the form of four new photos taken by the Mars rovers.
The first test of your multistage entry sequence is a success! Excellent work, engineer.
At this point, spare a thought for Spirit and Opportunity, known as The Adventure Twins.
Rest in peace, you brave little guys.
Multitasking
Your task(priority:_:)
closure creates a single task that downloads the latest photos for each rover. One request processes at a time, waiting for each to complete before moving to the next.
Each time you call await
, the task execution suspends while the request runs on another thread, which allows your app to run other concurrent code. Once the request is complete, your task resumes its execution until the next await
. This is why you’ll often see await
referred to as a suspension point.
In Swift 5.5, there are several ways you can structure code, so requests run concurrently. Instead of using the standard syntax let x = try await ...
you’ll change your code to use the async let x = ...
syntax instead.
In your .task
modifier, replace the rover requests with this:
async let curiosityPhotos = latestPhotos(rover: "curiosity") async let perseverancePhotos = latestPhotos(rover: "perseverance") async let spiritPhotos = latestPhotos(rover: "spirit") async let opportunityPhotos = latestPhotos(rover: "opportunity")
That might look a little odd. You declare that each let
is async
and you no longer need to use try
or await
.
All four tasks start immediately using that syntax, which isn’t surprising because you’re not using await
. How they execute depends on available resources at the time, but it’s possible they could run simultaneously.
Notice Xcode is now complaining about the definition of result
. Change this to:
let result = try await [ curiosityPhotos.first, perseverancePhotos.first, spiritPhotos.first, opportunityPhotos.first ]
Now you use try await
for the result
array, just like a call to an async function.
async let
creates a task for each value assignment. You then wait for the completion of all the tasks in the result
array. Calling task(priority:_:)
creates the parent task, and your four implicit tasks are created as child tasks of the parent.
Build and run. The app runs the same as before, but you might notice a speed increase this time.
Defining Structured Concurrency
The relationship between a child task and a parent task describes a hierarchy that’s the structure in structured concurrency.
The first version of your task closure created a single task with no parent, so it’s considered unstructured.
The version that uses async let
creates a task that becomes the parent of multiple child tasks. That’s structured concurrency.
The task hierarchy can also be any number of layers deep because a child task can create its own children, too. The distinction between structured and unstructured concurrency might seem academic, but keep in mind some crucial differences:
- All child tasks and descendants automatically inherit the parent’s priority.
- All child tasks and descendants automatically cancel when canceling the parent.
- A parent task waits until all child tasks and descendants complete, throw an error or cancel.
- A throwing parent task can throw if any descendant task throws an error.
In comparison, an unstructured task inherits priority from the calling code, and you need to handle all of its behaviors manually. If you have several async
tasks to manage simultaneously, it’s far more convenient to use structured concurrency because you only need await
the parent task or cancel the parent task. Swift handles everything else!
Canceling Tasks
You used task(priority:_:)
in LatestView.swift to run an asynchronous task. This is the equivalent of creating a Task
directly using init(priority:operation:)
. In this case, the task is tied to the view’s lifecycle.
If the view is updated while the task is running, it’ll be canceled and recreated when the view is redisplayed. If any task is canceled, it’s marked as canceled but otherwise continues to run. It’s your responsibility to handle this matter by cleaning up any resources that might still be in use and exit early.
In any async task code, you can check the current task cancellation status by either observing whether Task.isCancelled
is true or by calling Task.checkCancellation()
, which will throw CancellationError
.
Fortunately for you, engineers from Apple ensured URLSession
tasks will throw a canceled
error if the task is canceled. That means your code will avoid making unnecessary network requests.
However, if you have some long-running or resource-intensive code, be aware it’ll continue to run until finished even though the task is canceled. So it’s good to check the cancellation status at appropriate points during your task’s execution.
Fetching All Rovers
To gain a little more control over your child tasks, you can use a TaskGroup
. In the Model folder, create a file called MarsData.swift and replace the default code with this:
import SwiftUI import OSLog class MarsData { let marsRoverAPI = MarsRoverAPI() }
You’ll use this class to encapsulate all the tasks the views need to perform. It contains a reference to the MarsRoverAPI
so you can make API calls.
Until now, you’ve manually specified each rover’s name, but an API is available to obtain all current rovers. After all, what if NASA launches a new one?
Add the following to MarsData
:
func fetchAllRovers() async -> [Rover] { do { return try await marsRoverAPI.allRovers() } catch { log.error("Error fetching rovers: \(String(describing: error))") return [] } }
This calls the API endpoint that returns an array of Rover
. You’ll use this shortly.
Creating a Task Group
Next, you’ll create a function that returns the latest photos similar to the code you’ve already written, but within a TaskGroup
. Begin the function like this in MarsData
:
// 1 func fetchLatestPhotos() async -> [Photo] { // 2 await withTaskGroup(of: Photo?.self) { group in } }
Here’s a code breakdown:
- Your new async function will return an array of
Photo
. - You use
withTaskGroup(of:returning:body:)
to create the task group and specify that each task in the group will return an optionalPhoto
.
To fetch the list of rovers, add this to the closure:
let rovers = await fetchAllRovers()
rovers
will hold your rover list and let you add a task for each. Dynamically adding tasks only when needed is one of the significant benefits of using a TaskGroup
.
Continue by adding the following:
// 1 for rover in rovers { // 2 group.addTask { // 3 let photos = try? await self.marsRoverAPI.latestPhotos(rover: rover) return photos?.first } }
Here, you:
- Loop through each rover.
- Call
addTask(priority:operation:)
on thegroup
and pass a closure representing the work the task needs to perform. - In the task, you call
latestPhotos(rover:)
and then return the first photo if any were retrieved, ornil
if none were found.
You added tasks to the group, so now you need to collect the results. A TaskGroup
also conforms to AsyncSequence
for all the task results. All you need to do is loop through the tasks and collect the results. Add the following below your previous code:
// 1 var latestPhotos: [Photo] = [] // 2 for await result in group { // 3 if let photo = result { latestPhotos.append(photo) } } // 4 return latestPhotos
Here’s what this does:
- First you set up an empty array to hold the returned photos.
- Then you create a
for
loop that loops through the asynchronous sequence provided by the group, waiting for each value from the child task. - If the photo isn’t
nil
, you append it to thelatestPhotos
array. - Finally, return the array.
Return to LatestView.swift and add a new property to LatestView
:
let marsData = MarsData()
Replace the entire do-catch
block from .task
with:
latestPhotos = await marsData.fetchLatestPhotos()
This sets the latestPhotos
state just like your previous code.
Build and run your app.
The latest photos will load as before. Now you’re prepared for when NASA launches its next Mars rover.
You won’t have to update your app. It’ll work like magic!
async { // ... }
or adding a group task with:
group.async { //... }
This was a short-lived version of the API during the Xcode 13 beta. You’ll even find it in Apple presentations from WWDC 2021.
Be aware that it’s deprecated, and Xcode will display warnings.
Exploring All Photos
Because there are so many Mars rover photos available, give your users a way to see them all. The second tab in the app displays a RoversListView
, where you’ll list all the available rovers and show how many photos are available to browse. To do this, you’ll first need to download a photo manifest for each rover.
In MarsData.swift, add the following function to MarsData
:
// 1 func fetchPhotoManifests() async throws -> [PhotoManifest] { // 2 return try await withThrowingTaskGroup(of: PhotoManifest.self) { group in let rovers = await fetchAllRovers() // 3 try Task.checkCancellation() // 4 for rover in rovers { group.addTask { return try await self.marsRoverAPI.photoManifest(rover: rover) } } // 5 return try await group.reduce(into: []) { manifestArray, manifest in manifestArray.append(manifest) } } }
Here’s a code breakdown:
- You do something a bit differently here to explore this API.
fetchPhotoManifests()
is a throwing async function. - You use the
withThrowingTaskGroup(of:returning:body:)
function so the group can throw errors. - Because downloading all the manifest data might take a while, check whether a parent task isn’t canceled before creating the child tasks.
Task.checkCancellation()
will throw aTask.CancellationError
if there are any errors. - If the parent task hasn’t been canceled, proceed to create child tasks to download the manifest for each rover.
- Using
reduce(into:_:)
is another way to loop through each task result and create an array to return.
In RoversListView.swift, add a property for MarsData
and the manifests state:
let marsData = MarsData() @State var manifests: [PhotoManifest] = []
Like latestPhotos
, manifests
stores the downloaded PhotoManifest
when available.
Next, add a task to the NavigationView
:
.task { manifests = [] do { manifests = try await marsData.fetchPhotoManifests() } catch { log.error("Error fetching rover manifests: \(String(describing: error))") } }
This code calls fetchPhotoManifests()
and sets the view state with the result, catching any potential errors.
To display the manifests, replace MarsProgressView()
inside the ZStack
with:
List(manifests, id: \.name) { manifest in NavigationLink { Text("I'm sorry Dave, I'm afraid I can't do that") } label: { HStack { Text("\(manifest.name) (\(manifest.status))") Spacer() Text("\(manifest.totalPhotos) \(Image(systemName: "photo"))") } } } if manifests.isEmpty { MarsProgressView() }
You create a List
to contain all the navigation links to the rover manifests, using name, status and photo count. The text is only temporary, you’ll get to that soon.
Meanwhile, build and run and select the Rovers tab. Wow, those rovers have been busy!
OK, that’s a lot of photos! Too many photos to show in a single view.
Houston, we have a problem. But don’t fear because the Mars Rover Photos API has you covered. As you would expect, a NASA engineer designed the API to cover any eventuality.
Displaying the Rover Manifests
The solution is to drill down to photos by each Martian day — or sol — of a rover’s mission. The entries
property on PhotoManifest
contains only the data you need.
In the Views group, create a new SwiftUI View file called RoverManifestView.swift, and replace the default code with:
import SwiftUI struct RoverManifestView: View { // 1 let manifest: PhotoManifest var body: some View { // 2 List(manifest.entries, id: \.sol) { entry in NavigationLink { Text("Fear my botany powers, Mars!") } label: { // 3 HStack { Text("Sol \(entry.sol) (\(Text.marsDateText(dateString: entry.earthDate)))") Spacer() Text("\(entry.totalPhotos) \(Image(systemName: "photo"))") } } } .navigationTitle(manifest.name) } }
Here’s what’s going on, step by step:
- Your new view needs a
PhotoManifest
to display. - You create a
List
by looping through each entry in the manifest. - Each entry creates a
NavigationLink
and shows the sol, the corresponding Earth date and the total photos for that day.
For completeness, add a suitable SwiftUI preview below RoverManifestView
:
struct RoverManifestView_Previews: PreviewProvider { static var previews: some View { RoverManifestView(manifest: PhotoManifest( name: "WALL-E", landingDate: "2021-12-31", launchDate: "2021-12-01", status: "active", maxSol: 31, maxDate: "2022-01-31", totalPhotos: 33, entries: [ ManifestEntry( sol: 1, earthDate: "2022-01-01", totalPhotos: 33, cameras: [] ) ] ) ) } }
This code will create a fictional manifest for the preview.
Finally, in RoversListView.swift, replace Text("I'm sorry Dave, I'm afraid I can't do that")
with:
RoverManifestView(manifest: manifest)
Build and run, select the Rovers tab, and drill down to each manifest. These rovers certainly are snap-happy Mars tourists!
So far, so good, but a step remains: displaying the photos.
Presenting the Photos
In the Views group, create another SwiftUI View file called RoverPhotosBySolListView.swift. Replace the default code with:
import SwiftUI struct RoverPhotosBySolListView: View { let name: String let sol: Int private let marsData = MarsData() @State private var photos: [Photo] = [] var body: some View { Text("Douglas Quaid, get to Mars!") } }
Your view has two arguments: the rover name and the sol. You also prepared the MarsData
and state properties you’ll need.
In RoverManifestView.swift, replace the Text
view in the NavigationLink
with:
RoverPhotosBySolListView(name: manifest.name, sol: entry.sol)
Next, in MarsData.swift, add a function to MarsData
that’ll download the photos by rover and sol:
func fetchPhotos(roverName: String, sol: Int) async -> [Photo] { do { return try await marsRoverAPI.photos(roverName: roverName, sol: sol) } catch { log.error("Error fetching rover photos: \(String(describing: error))") return [] } }
This simple async
function makes the appropriate API call and returns an array of Photo
.
In RoverPhotosBySolListView.swift, replace the Text
view with:
// 1 ZStack { // 2 ScrollView { LazyVStack(spacing: 0) { ForEach(photos) { photo in MarsImageView(photo: photo) .padding(.horizontal, 20) .padding(.vertical, 8) } } } // 3 if photos.isEmpty { MarsProgressView() } } // 4 .navigationTitle(name) // 5 .task { photos = await marsData.fetchPhotos(roverName: name, sol: sol) }
That’s a lot of code! Here’s a breakdown:
- You set up a
ZStack
so theMarsProgressView
displays in the center while loading. - Then you present all the photos as a scrollable
LazyVStack
, usingMarsImageView
. - While
photos
is empty, you display the progress view. -
name
is a convenient navigation title. - Finally, your view’s task calls
fetchPhotos(roverName:sol:)
.
Build and run your app. Take some time to drill down and explore those photos. Consider how amazing it is to browse a photo album of pictures from the surface of another planet!
Browse by Earth Date
If you’re looking for an extra challenge, SwiftUI has an alternative to task(priority:_:)
called task(id:priority:_:)
. This alternative takes an Equatable
value representing an ID. When the ID value changes, the view cancels and restarts the task.
You could store the currently selected date in a state variable and use it for the ID. When the user selects a different date, the task will fetch the next photos. And yes, there’s an API endpoint for photos taken by a rover on a specific Earth date.
Where to Go From Here?
You can download the completed version of the project using Download Materials at the top or bottom of this tutorial.
In this tutorial, you learned how to manage concurrent tasks with async
, await
and TaskGroup
. Here are some links to help learn more about Swift Concurrency:
- The official Swift Concurrency page is a great resource as an overview.
- Get the Apple documentation straight from the source.
- raywenderlich.com offers a fantastic in-depth book on Swift Concurrency: Modern Concurrency In Swift.
We hope you’ve enjoyed this tutorial about structured concurrency and AsyncImage
in SwiftUI.
If you have any questions or comments, join the discussion below!
raywenderlich.com Weekly
The raywenderlich.com newsletter is the easiest way to stay up-to-date on everything you need to know as a mobile developer.
Get a weekly digest of our tutorials and courses, and receive a free in-depth email course as a bonus!
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK