Swift Concurrency and Runtime

1026
0
11-22-2021 08:23 AM
Labels (1)
Nicholas-Furness
Esri Regular Contributor
1 0 1,026

Apple recently introduced long-awaited Swift Concurrency capabilities, also known as “async/await”.

In this blog post we’ll discuss how to use async/await with the ArcGIS Runtime SDK for iOS and introduce a new Swift package to help.

What is Swift Concurrency?

In brief, Swift Concurrency lets you ditch callback hell and write your asynchronous code in an easy to understand linear sequence.

Using it, you can write code like this:

 

func openMapFromMobileMapPackage() async throws {
    let mmpk = AGSMobileMapPackage(fileURL: mmpkURL)
    
    try await mmpk.load()
    
    guard let map = mmpk.maps.first else {
        preconditionFailure("MMPK contains no maps!")
    }
    
    mapView.map = map
    
    try await map.load()
    
    if let sr = map.spatialReference {
        showAlert(title: "Map Details", message: "The map's WKID is \(sr.wkid)")
    }
}

 

Whereas without it you'd have to write this:

 

func openMapFromMobileMapPackage() {
    let mmpk = AGSMobileMapPackage(fileURL: mmpkURL)
    
    mmpk.load { [weak self] error in
        guard let self = self else { return }
        
        if let error = error {
            self.showError(error)
            return
        }
        
        guard let map = mmpk.maps.first else {
            preconditionFailure("MMPK contains no maps!")
        }
        
        self.mapView.map = map
        
        map.load { [weak self] error in
            guard let self = self else { return }
            
            if let error = error {
                self.showError(error)
                return
            }
            
            if let sr = map.spatialReference {
                self.showAlert(title: "Map Details",
                             message: "The map's WKID is \(sr.wkid)")
            }
        }
    }
}

 

Later in this post we’ll dig further into the benefits that Swift Concurrency delivers, but first let’s look at how Apple brings it to your existing projects, and what that means for ArcGIS Runtime.

Swift Concurrency support

Firstly, let's set some expectations: Swift Concurrency requires Xcode 13 and targets iOS 15 (although after some spirited feedback from the Apple developer community, Xcode 13.2 will expand support to include iOS 13 and 14 - and yes, it is also supported on macOS and watchOS, but in this post we're focusing on the ArcGIS Runtime SDK for iOS).

To use a method with Swift Concurrency, it must be defined with the new async keyword. Fortunately, if you’re working with an API that doesn’t explicitly do this (that includes all Objective-C APIs and any Swift API that hasn't been updated very recently), then Apple have provided an elegant solution: Xcode will automatically generate a virtual async wrapper method around any method that takes a completion block as its last parameter.

That’s what happened above with the load() methods on AGSMap and AGSMobileMapPackage; we didn’t have to do anything to access an async version of load(completion:).

Needless to say, this makes adopting Swift Concurrency with existing asynchronous APIs really easy. Kudos to Apple for doing this.

However there’s one scenario where Xcode can’t create these virtual wrappers, and unfortunately it affects most asynchronous methods in the ArcGIS Runtime SDK for iOS…

Return values and completion blocks

The problem is that if, unlike the load() method, the original completion-block based method also returns a value, then Xcode can’t be sure how to generate the new virtual wrapper.

Let's explain what I mean by that with a concrete example. Here’s how the geocode(searchText:) method is defined on AGSLocatorTask:

 

-(id<AGSCancelable>)geocodeWithSearchText:(NSString *)searchText
                               completion:(void(^)(NSArray<AGSGeocodeResult*> * __nullable geocodeResults, NSError * __nullable error))completion;

 

This is automatically bridged to Swift as:

 

func geocode(withSearchText searchText: String, 
             completion: @escaping ([AGSGeocodeResult]?, Error?) -> Void) -> AGSCancelable

 

When the geocode completes, this method passes an [AGSGeocodeResult] array (or an Error) to the completion block. The problem is that it also returns an AGSCancelable to the caller when the geocode is initiated.

So, if we create an async wrapper, what should it return? Should it return the [AGSGeocodeResult] array, or the AGSCancelable?

To us as developers, it’s probably clear which of these is of real interest. We want something like this that returns the [AGSGeocodeResult] array (or throws an Error) :

 

func geocode(withSearchText searchText: String) async throws -> [AGSGeocodeResult]

 

But it's not so clear to Xcode, and it can’t make that decision automatically. On the other hand, we can implement a wrapper like this ourselves and be explicit about what we choose to return (and the method above still supports cancelation, which we'll discuss below).

It turns out that a lot of asynchronous methods in the Runtime SDK fall into this category (over 170 of them!). Here are just a few examples:

  • Querying for features or related records.
  • Solving a route.
  • Geocoding.
  • Posting edits to a feature service.
  • Adding, updating, deleting, or fetching attachments.
  • Interrogating local edits waiting to be written/posted to storage.
  • Downloading/synchronizing offline maps and offline data.
  • Identifying in layers or graphics overlays.
  • Reading and tracing utility networks.
  • Calculating service areas.
  • Animating a map view or scene view.
  • Following a route with the navigation API.
  • Working with geodatabase versions.
  • Creating symbol swatches.
  • Working with Portal Items.
  • Searching a portal.

Even if you only use a handful of these 170+ methods, creating async wrappers is a repetitive task and can be a lot to get your head around if you’re new to it.

So we did it for you…

Introducing the ArcGIS Runtime async/await package

We’re pleased to present a handy set of open source Swift files that fill the gap, implementing the async wrappers that Xcode could not: arcgis-runtime-ios-async-await

Now you can turn this…

 

func getRoute(origin: AGSStop, destination: AGSStop, completion: @escaping (AGSRouteResult?, Error?) -> Void) {
    
    let startTime = Date()
    
    // Get default route parameters (first time, this will load the AGSRouteTask)
    routeTask.defaultRouteParameters { [weak self] routeParameters, error in
        guard let self = self else {
            completion(nil, nil) // Don't forget to call the completion! Or is this a fatalError?
            return
        }
        
        if let error = error {
            print("Error getting default route parameters: \(error.localizedDescription)")
            completion(nil, error) // Don't forget to call the completion!
            return
        }
        
        guard let routeParameters = routeParameters else {
            fatalError("We got neither route parameters NOR an error!")
        }
        
        routeParameters.setStops([origin, destination])
        
        self.routeTask.solveRoute(with: routeParameters) { routeResult, error in
            let calculationTime = Date().timeIntervalSince(startTime)
            print("Route calculation took about \(String(format: "%.1f", calculationTime)) seconds")
            
            completion(routeResult, error) // Don't forget to call the completion!
        }
    }

}

 

Into this…

 

func getRoute(origin: AGSStop, destination: AGSStop) async throws -> AGSRouteResult {
    
    let startTime = Date()
    
    // Get default route parameters (first time, this will load the AGSRouteTask)
    let routeParameters = try await routeTask.defaultRouteParameters()
    
    // Modify the parameters with the stops we're interested in.
    routeParameters.setStops([origin, destination])
    
    // Calculate a route between the stops.
    let result = try await routeTask.solveRoute(with: routeParameters)
    
    let calculationTime = Date().timeIntervalSince(startTime)
    print("Route calculation took about \(String(format: "%.1f", calculationTime)) seconds")
    
    return result

}

 

While still supporting cancelation. That’s pretty neat, right?

As you might have noticed, Swift Concurrency is about more than just flattening callback hell. There are actually a few potential bugs that have been cleaned up in the above code:

  • Capturing a weak self reference (and checking it) goes away. That eradicates a host of potential memory leak opportunities.
  • The result is no longer an optional. Either an error is thrown, or a non-optional result is returned.
  • Because errors now throw you don’t have to check for them before you continue.
  • You don’t have to remember to call the completion block through every code path (see how often completion is called in the original code above, and you have to remember to return right after it). It was often far too easy to forget to call back if something untoward happened, leaving the caller waiting forever. Bugs like that are often hard to track down.
  • You don’t have to reference class level properties with self. Your code that handles the result is now scoped at the same level as the code that requested the result.

Another helpful side-effect is that you have greater clarity over the lifetime of objects that perform asynchronous work (for example, ArcGIS Tasks like AGSLocatorTask or AGSOfflineMapTask, the various types of AGSJob, or simply querying an AGSFeatureTable). A common trap we see many Runtime developers fall into is that the object performing an asynchronous operation is released before that operation completes, which means that the completion block is never called!

For example, this code will silently fail to do anything:

 

func doGeocode(for searchText: String) {

    let worldGeocoder = AGSLocatorTask(url: URL(string: "https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer")!)

    worldGeocoder.geocode(withSearchText: searchText) { [weak self] results, error in
        
        guard let self = self else { return }
        
        if let error = error {
            print("Error while geocoding '\(searchText)': \(error.localizedDescription)")
            return
        }
        
        guard let firstResult = results?.first,
              let location = firstResult.displayLocation else { return }
        
        self.geocodeOverlay.graphics.removeAllObjects()
        
        let graphic = AGSGraphic(geometry: location, symbol: AGSSimpleMarkerSymbol(style: .circle, color: .orange, size: 8))
        self.geocodeOverlay.graphics.add(graphic)
        
        if let envelope = firstResult.extent {
            self.mapView.setViewpointGeometry(envelope, completion: nil)
        } else {
            self.mapView.setViewpointCenter(location, completion: nil)
        }
        
    }
    
}

 

Can you see why? If you’ve hit this before, you’ll probably see it immediately, but until you develop the muscle memory to look for these cases, they can be hard to spot and diagnose. The worldGeocoder instance created at the very start of the doGeocode() method will go out of scope and be deallocated before it has a chance to complete the geocode() request and call back into the completion handler.

Instead, to ensure that worldGeocoder is not deallocated before it delivers the geocode result, you typically store it as an object variable like this:

 

let worldGeocoder = AGSLocatorTask(url: URL(string: "https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer")!)

func doGeocode(for searchText: String) {

    worldGeocoder.geocode(withSearchText: searchText) { [weak self] results, error in
            
        guard let self = self else { return }

 

But now, with async/await, you can write something like this:

 

func doGeocode(for searchText: String) async throws {

    let worldGeocoder = AGSLocatorTask(url: URL(string: "https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer")!)
    
    guard let firstResult = (try await worldGeocoder.geocode(withSearchText: searchText)).first,
          let location = firstResult.displayLocation else { return }
    
    geocodeOverlay.graphics.removeAllObjects()
    
    let graphic = AGSGraphic(geometry: location, symbol: AGSSimpleMarkerSymbol(style: .circle, color: .orange, size: 8))
    geocodeOverlay.graphics.add(graphic)
    
    if let envelope = firstResult.extent {
        await mapView.setViewpointGeometry(envelope)
    } else {
        await mapView.setViewpointCenter(location)
    }

}

 

Just as before, worldGeocoder is released when execution leaves doGeocode(), but now, thanks to Swift Concurrency, execution doesn’t leave doGeocode() until after the results have been calculated and the mapView has navigated to show them.

Note: Although this highlights a potential benefit of sequential asynchronous execution, you might not actually want to do this. AGSLocatorTask needs to load its metadata before it can be used (which is done automatically when first needed), so storing a single instance to reuse over and over again and loading the metadata only once is probably preferable to creating a new instance and loading metadata for each geocode. Loading metadata is a lightweight operation, but still takes time.

How does this magic work?

There are great WWDC sessions from Apple watch that cover the new capabilities (start here, and be sure to check out the related videos listed on that page), but let’s provide a quick overview by looking at this code:

 

func getRoute(origin: AGSStop, destination: AGSStop) async throws -> AGSRouteResult {
        
    let startTime = Date()
        
    // Get default route parameters (first time, this will load the AGSRouteTask)
    let routeParameters = try await routeTask.defaultRouteParameters()

 

The asynchronous call to routeTask.defaultRouteParameters() is marked with the new await keyword. When await routeTask.defaultRouteParameters() is called, Swift Concurrency will actually pause execution of getRoute() and make the thread it was executing on available for use. It will then execute the guts of defaultRouteParameters() on another thread, and when that finishes executing Swift Concurrency will provide the defaultRouteParameters() return value back on the original thread and we store it in the routeParameters variable.

There’s more to it than that, of course, but this is accurate enough for the purposes of understanding and using async/await for now.

Swift Concurrency includes many features to help make asynchronous code safer and easier to write, including the await and async keywords, Actors (which help with race conditions), Tasks (which include the cancelation capabilities we hook into with our wrappers), and Task Groups. Again, I encourage you to take a look at the various excellent WWDC sessions on the topic.

Canceling and await

Our library of async wrappers maintains cancelation capabilities (something Xcode could not automatically do). So how do we cancel something?

Let’s take this example again:

 

func getRoute(origin: AGSStop, destination: AGSStop) async throws -> AGSRouteResult {
    
    let startTime = Date()
    
    // Get default route parameters (first time, this will load the AGSRouteTask)
    let routeParameters = try await routeTask.defaultRouteParameters()
    
    // Modify the parameters with the stops we're interested in.
    routeParameters.setStops([origin, destination])
    
    // Calculate a route between the stop stops.
    let result = try await routeTask.solveRoute(with: routeParameters)
    
    let calculationTime = Date().timeIntervalSince(startTime)
    print("Route calculation took about \(String(format: "%.1f", calculationTime)) seconds")
    
    return result

}

 

What happens if the solveRoute() method call takes a long time to execute and the user decides they want to cancel the request? If, as I described above, the getRoute() method execution is paused while the defaultRouteParameters() or solveRoute() methods are executing, how can I cancel either of those method calls?

The answer is to wrap them in a Task. A Task is a fundamental type introduced by Swift Concurrency, and it includes a framework for cancelation. You end up with code like this:

 

@MainActor
var cancelableTask: Task<AGSRouteResult, Error>? = nil

func getRoute(origin: AGSStop, destination: AGSStop) async throws -> AGSRouteResult {

    cancelableTask = Task { () -> AGSRouteResult in
        let startTime = Date()
        
        // Get default route parameters (first time, this will load the AGSRouteTask)
        let routeParameters = try await routeTask.defaultRouteParameters()
        
        // Modify the parameters with the stops we're interested in.
        routeParameters.setStops([origin, destination])
        
        // Calculate a route between the stop stops.
        let result = try await routeTask.solveRoute(with: routeParameters)
        
        let calculationTime = Date().timeIntervalSince(startTime)
        print("Route calculation took about \(String(format: "%.1f", calculationTime)) seconds")
        
        return result
    }
    
    return try await cancelableTask!.value

}

@IBAction func userTappedCancel(sender: UIButton) {
    cancelableTask?.cancel()
}

 

Tasks are used to asynchronously execute a block of code. In the above code, we hold on to the Task in the cancelableTask variable (the @MainActor modifier makes sure that we don’t enter race conditions while manipulating the cancelableTask variable).

If the user wants to cancel, we just call the Task’s cancel() method. Our custom wrappers integrate with this and take care of canceling the Runtime calls. As a result of canceling the Runtime call, getRoute() will throw a userCancelled error, which you can catch and report with something like this:

 

do {
    // Cancelations are propagated as CocoaError.userCancelled errors. See the catch statement below.
    try await getRoute(origin: stop1, destination: stop2)
} catch let error as CocoaError where error.code == CocoaError.userCancelled {
    // Handle a cancelation. This is propagated as a userCancelled error.
    showAlert(title: "Canceled", message: "The route request was canceled by the user.")
} catch {
    // Handle other (non-cancelation) errors.
    showError(title: "Error calculating route", error: error)
}

 

This description simplifies things a little and only scratches the surface of what Tasks can do. I’d encourage you to watch Apple’s sessions on Swift Concurrency covering Tasks and TaskGroups to learn more.

AGSJobs and cancelation

AGSJobs also support cancelation, but they do not use AGSCancelable.

Instead, each AGSJob exposes an NSProgress object and is cancelled by calling the NSProgress’s cancel() method. As a result, AGSJob.start() doesn’t return an AGSCancelable, and so Xcode can (and does) create a virtual wrapper.

If you need to cancel AGSJobs, we provide an additional file of AGSJob subclass async wrappers which supercede the ones Xcode generates. Unfortunately, these can’t be delivered through the Swift Package Manager without conflicting with the Xcode generated wrappers, so you have to explicitly add the AsyncCancelableJobWrappers.swift file to your project. Adding this file stops Xcode creating its own wrappers. If for some reason you don’t need to cancel AGSJobs, you could skip our custom wrappers and just use the Xcode generated ones, but your users would probably prefer you let them cancel long running jobs.

Take it for a spin

If you’ve been chomping at the bit to adopt async/await in your Runtime app, we encourage you to take the repo for a spin. See the instructions for more details on how to bring it into your project.

The repo also includes an example app which shows how to cancel long running Runtime tasks and jobs, how to animate the map view without waiting for the animation to complete, and various other Swift Concurrency tips and tricks. Just bring your own API Key, scoped to Routing and Geocoding, and set it in the example’s AppDelegate.

Let us know how you like the wrappers and if you're planning on using them in your own code. I've already started writing demos with them and I'm finding it hard to switch back. We anticipate that interest in Swift Concurrency will skyrocket once Xcode 13.2 is released and you'll be able to target all the way back to iOS 13.

About the Author
Product Manager for the ArcGIS Runtime SDKs, focusing on the Runtime SDK for iOS. My background is in computer science, but my professional career has always been GIS, in particular Utilities.