SwiftUI and Structured Concurrency
Learn how to manage concurrency into your SwiftUI iOS app. By Andrew Tetlaw.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
SwiftUI and Structured Concurrency
30 mins
- Getting Started
- Downloading a Photo
- Responding to Download Errors
- Using the Mars Rover API
- Fetching Rover Images
- Creating a Task
- Multitasking
- Defining Structured Concurrency
- Canceling Tasks
- Fetching All Rovers
- Creating a Task Group
- Exploring All Photos
- Displaying the Rover Manifests
- Presenting the Photos
- Browse by Earth Date
- Where to Go From Here?
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.