4

SwiftUI and Structured Concurrency [FREE]

 2 years ago
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.
neoserver,ios ssh client
Home iOS & Swift Tutorials

SwiftUI and Structured Concurrency

Learn how to manage concurrency into your SwiftUI iOS app.

By Andrew Tetlaw Apr 11 2022 · Article (30 mins) · Intermediate

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.
Note: This intermediate-level tutorial assumes you’re comfortable building an iOS app using Xcode and Swift. You should have used SwiftUI and have at least a beginner understanding of Swift async and await.

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:

  1. Here’s the image’s URL. It’s straight from the NASA collection.
  2. 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.
  3. This closure outputs the placeholder displayed while the image downloads. You use the MarsProgressView that’s already prepared for you, but a standard ProgressView 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:

  1. .empty: An image isn’t available yet, so this is the perfect spot for the placeholder view.
  2. .success(let image): An image was successfully downloaded. This value contains an image you can output as you like.
  3. .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 the localizedDescription 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".

3 iPhone simulator screens showing the placeholder, photo and error message

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.

Note: The API documentation is available on NASA APIs, and also on the maintainer’s GitHub repository.

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:

  1. You make latestPhotos(rover:) a throwing async function because the MarsRoverAPI function that it calls is also throwing and async. It returns an array of Photo.
  2. Use APIRequest to specify the URL to call and how to decode the JSON response.
  3. You call the apiRequest endpoint and decode the JSON response.
  4. 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:

  1. You make a ScrollView containing a HStack and loop through the latestPhotos, creating a MarsImageView for each Photo.
  2. While latestPhotos is empty, you display the MarsProgressView.

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:

  1. 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.
  2. You first set latestPhotos to an empty array so the progress view displays.
  3. 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.
  4. Once all the requests finish, store the first photo from each in an array.
  5. You compact the resulting array to remove any nil values.
  6. 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.

iPhone simulator screen showing a nice Mars photo from Perseverance

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:

  1. Your new async function will return an array of Photo.
  2. You use withTaskGroup(of:returning:body:) to create the task group and specify that each task in the group will return an optional Photo.

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:

  1. Loop through each rover.
  2. Call addTask(priority:operation:) on the group and pass a closure representing the work the task needs to perform.
  3. In the task, you call latestPhotos(rover:) and then return the first photo if any were retrieved, or nil 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:

  1. First you set up an empty array to hold the returned photos.
  2. Then you create a for loop that loops through the asynchronous sequence provided by the group, waiting for each value from the child task.
  3. If the photo isn’t nil, you append it to the latestPhotos array.
  4. 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!

Note: If you’re reading tutorials or looking through some Apple sample code you may see a different syntax. For example creating a task with:
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:

  1. You do something a bit differently here to explore this API. fetchPhotoManifests() is a throwing async function.
  2. You use the withThrowingTaskGroup(of:returning:body:) function so the group can throw errors.
  3. 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 a Task.CancellationError if there are any errors.
  4. If the parent task hasn’t been canceled, proceed to create child tasks to download the manifest for each rover.
  5. 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!

iPhone simulator screen showing the rover list and total photos for each

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:

  1. Your new view needs a PhotoManifest to display.
  2. You create a List by looping through each entry in the manifest.
  3. 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:

  1. You set up a ZStack so the MarsProgressView displays in the center while loading.
  2. Then you present all the photos as a scrollable LazyVStack, using MarsImageView.
  3. While photos is empty, you display the progress view.
  4. name is a convenient navigation title.
  5. 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!

A display of photos from Curiosity and Perseverance and Opportunity

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:

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!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK