Let’s Build with Swift - 3: Swift Concurrency

510
0
05-25-2023 03:09 PM
Ting
by Esri Contributor
Esri Contributor
2 0 510

arcgis-maps-sdk.png

 

Perform Asynchronous Operations With ArcGIS Maps SDK for Swift


Welcome to the Swift Concurrency article in our blog series. Initially, we discussed the basic SwiftUI app structure and then examined how to manage the flow of data in our apps. Now, let's explore one of the most desirable Swift features released in recent years: Swift Concurrency. This post will guide you through the new ways to perform asynchronous operations on your data models using async/await, Task block, and AsyncStream.

 

Topics In This Post

 

  • Perform asynchronous tasks on your data using async/await
  • Create a Task structure to execute an asynchronous task
  • Use the .task modifier to run asynchronous tasks with a view
  • Consume updates from an asynchronous sequence

 

Introducing Swift Concurrency

 

Before Swift Concurrency was introduced, iOS developers extensively used the Dispatch framework (also known as Grand Central Dispatch, GCD) to execute concurrent code. We used to write asynchronous code using DispatchQueue.async(execute:) and run tasks in parallel using DispatchGroup or DispatchQueue.concurrentPerform(iterations:execute:).

Starting with iOS 13, Swift offers built-in support for writing concurrent code in a more structured way. Compared to GCD, the advantages include:

  • Improved readability. It uses the async/await syntax, which removes nested closures and makes it read more like synchronous code.
  • Structured concurrency. The control flow has a more deterministic behavior, making it easier to manage and debug.
  • Easier error handling. We can use the existing do-catch statement to handle the errors from asynchronous code.
  • Better performance. It is efficient and lightweight. For example, it uses fewer system resources than GCD and limits the number of thread-per-core to prevent the thread explosion you may have previously experienced with GCD.

Let's go through a few examples using Swift Concurrency with the new ArcGIS Maps SDK for Swift. Before diving into this section, make sure you have a basic understanding of the new async/await syntax, and of how asynchronous code executes. Follow the links at the bottom of this blog.

 

Defining Asynchronous Functions

 

Let's head back to ContentView.swift in our Mapper app from the second post. At the bottom of the data model, we'll add an asynchronous method to query a city from the previously added World Cities feature layer.

 

private extension ContentView {
    class Model: ObservableObject {
        // ... Code above collapsed.

+        /// Queries a city feature with a name.
+        func queryCity(name: String) async throws {
+            let queryParameters = QueryParameters()
+            queryParameters.whereClause = "CITY_NAME = '\(name)'"
+            // Queries the feature table dataset.
+            if let queryResult = try await featureLayer.featureTable?.queryFeatures(using: queryParameters) {
+                featureLayer.selectFeatures(queryResult.features())
+            }
+        }
    }
}

 

Running an attribute query on a feature table is a common asynchronous ArcGIS workflow where features are retrieved from a remote feature service or a geodatabase file on disk. First, we create the query parameters and specify a whereClause. Next, we call the feature table's queryFeatures method using these query parameters. In the iOS Runtime SDK, we used to call the queryFeatures method, and get the result or error from the completion handler. With the new Swift Maps SDK, we just make this asynchronous call using the await keyword.

With the new async/await coding pattern, await marks a possible suspension point that waits for the method to return. During this code suspension, other concurrent code in the app may run. After the method returns with the query results, the code execution can resume. The most common places you'll see the await keyword are either in an async function, or in an unstructured Task block - both will be covered in the section below.

Note: The try keyword is needed to call a function which can throw an error. Because we are not handling the error in place, the enclosing method needs the throws keyword to become a throwing method.

There you go. Now we have an asynchronous method, called queryCity, that throws in our data model indicated by the async and throws keywords. Next, let's see how to invoke this asynchronous method.

 

Calling Asynchronous Functions

 

As a developer, you will find yourself calling the asynchronous methods in 2 main places: an unstructured Task block, or within another asynchronous function, such as the .task modifier on a view.

 

In Task Block

 

To call asynchronous functions from a synchronous context, you can create a new asynchronous context with a Task. Each task block provides a closure in which you can place your asynchronous functions. You can use the referencing "handle" instance returned by a task to wait for the result of it, or to cancel it. You can also omit the handle instance if you don't want to have control over the lifetime of the task.

Note: Upon creation, the task block immediately runs the functions inside. If the functions are marked with await, they will run one at a time and each function must return before the next starts. To let them run in parallel, use async-let syntax or create a "task group" instead.

It's typical for a user to interact with UI controls to start a query on a feature layer, so let's go back to the view's body and add the following lines to the map view.

 

    var body: some View {
        MapView(map: model.map, viewpoint: viewpoint)
            .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 }
            .locationDisplay(model.locationDisplay)
+            .toolbar {
+                ToolbarItem(placement: .bottomBar) {
+                    Button("Query City") {
+                        Task {
+                            if model.featureLayer.loadStatus != .loaded {
+                                try? await model.featureLayer.load()
+                            }
+                            try? await model.queryCity(name: "Los Angeles")
+                        }
+                    }
+                }
+            }
            .overlay(alignment: .top) {
                Text(model.currentLocation?.position.xyCoordinates ?? "No location update.")
            }
            .task {
                await model.startLocationDataSource()
                await model.locationDidChange()
            }
            .onDisappear {
                model.stopLocationDataSource()
            }
    }

 

Query result highlighted in the feature layerQuery result highlighted in the feature layerHere, we have added a toolbar modifier to the map view and a Button called "Query City" to the bottom ToolbarItem. In the button, we created a task block, and called 2 asynchronous methods. First, we check then call the load() asynchronous method on the feature layer to ensure it is loaded, before we can perform any query on it. Next, we call the newly created async method queryCity(name:) with the try and await keywords.

In this example, a task block works well for 2 reasons. First, a feature table query often completes quickly and gives a deterministic result, so we don't mind giving up the ability to cancel the task. Second, since the method doesn't return any values, the task block is destroyed once it completes, making memory management easy.

Note: For simplicity, we omitted the error handling by using the optional try? keyword. You can use the typical do-catch statement to handle the error.

The load method is only called for demo purposes. In a real world app, the feature layer is implicitly loaded while the map is loading.

Run the app, pan to Los Angeles in the USA. Tap the "Query City" button at the bottom, and the circle symbol will be highlighted.

 

In .task Modifier

 

In previous posts, we have used the task modifier, but haven't explained it. The task(priority:_:) modifier allows the view to run asynchronous actions before the view appears. If you are familiar with UIKit, there is a stage in UIViewController's life cycle called viewDidLoad(). The task modifier is similar to this functionality when loading the view's initial data.

Note: Keep in mind that a view in SwiftUI can change identity, so only run tasks that have a lifetime that matches the view's lifetime. If a view has changed its identity, or is removed from the view hierarchy, the task will also be canceled.

In the code above, we have used one task modifier to start the location data source and receive the location updates from the data source's asynchronous stream (see the next section). If you want to separate async tasks that have different purposes, you can create multiple task modifiers on a view.

There are other view life cycle related modifiers as well. For example, in the code you see an onDisappear(perform:) modifier, which resembles viewDidDisappear(_:) method in UIKit. Here, we can run actions to clean up the data model once the view's lifetime comes to an end.

 

Understanding AsyncStream Pattern in ArcGIS Maps SDK for Swift

 

In the iOS Runtime SDK, events were provided through callback handlers, delegate methods, or the notification center.

In the new Swift Maps SDK, events and updates are asynchronously provided in the form of asynchronous streams. You can use the for-await-in syntax to process each instance because the AsyncStream also conforms to AsyncSequence.

Take a look at the locationDidChange method.

 

private extension ContentView {
    class Model: ObservableObject {
        // ... Code above collapsed.

        /// Uses `for-await-in` to access location updates produced by the async stream.
        @MainActor
        func locationDidChange() async {
            for await newLocation in locationDisplay.dataSource.locations {
                currentLocation = newLocation
            }
        }
    }
}

 

The location data source's locations property has the type AsyncStream<Location>. This means the locations are generated asynchronously, so we need to wait for the location update to come and then consume it. The await keyword here means the loop will suspend execution at the beginning of each iteration, wait for the next location to become available, perform the code block, and then resume.

After the location data source has started in the task modifier, call the locationDidChange method to display the current location's coordinates in the UI. Be aware! Because the method constantly gets the next update from a never-ending asynchronous stream, it will only stop executing when the task modifier is destroyed as the view's lifetime is terminated. Any code that is written after the method that contains a for-await-in loop will not be executed.

There are two options to manage the lifetime of a method that accesses an asynchronous stream:

  1. Create separate task modifiers for each asynchronous stream so they will be operational for the lifetime of the view.
  2. Create separate task blocks and hold the handle instances in the data model. With this option, the data model can cancel the task and therefore stop the asynchronous stream. You can also use a task group to manage these task blocks.

There are a handful of other AsyncStream properties in the Swift Maps SDK, such as the loadStatus of various loadable resources, the autoPanMode of a location display, and many other properties that emit updates over time. During your migration, whenever you are uncertain about how to refactor a "status changed handler" or a delegate method, try out these AsyncStream properties with for-await-in. You would be amazed by how simple they are in your new codebase!

 

@MainActor and Race Condition

 

The only other Swift Concurrency thing we used in the example, that we haven't mentioned yet, is the @MainActor attribute. "Actor" is a new and fairly advanced concept. I would recommend reading the official documentation and online articles to better understand. In short, we added it to safely modify the UI on the main dispatch queue without hitting a race condition. The location updates are published from the data model to be consumed by the UI, so the locationDidChange method needs to use @MainActor.

 

Coming Up Next

 

We have gone through the fundamental topics of Swift Concurrency in this post. As you may tell, it is far more concise than the old closure-based concurrency. By moving to the new SwiftUI patterns, we have been able to develop a more performant and modern SDK that allows you to build a clean and robust app from ground up using the latest and greatest technologies.

In our next blog post, we'll wrap up the series with view modifiers, gestures, and other advanced topics with the view. See you there!

P.S. Below I've attached all the code we've written so far. See how much you have learned about the Swift Maps SDK and SwiftUI in such a short period of time. Wow! 😎

The code we've written so far 👇

Spoiler
import SwiftUI
import ArcGIS
import CoreLocation

struct ContentView: View {
    @StateObject private var model = Model()
    
    @State private var viewpoint = Viewpoint(latitude: 34.02700, longitude: -118.80500, scale: 72_000)
    
    var body: some View {
        MapView(map: model.map, viewpoint: viewpoint)
            .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 }
            .locationDisplay(model.locationDisplay)
            .toolbar {
                ToolbarItem(placement: .bottomBar) {
                    Button("Query City") {
                        Task {
                            if model.featureLayer.loadStatus != .loaded {
                                try? await model.featureLayer.load()
                            }
                            try? await model.queryCity(name: "Los Angeles")
                        }
                    }
                }
            }
            .overlay(alignment: .top) {
                Text(model.currentLocation?.position.xyCoordinates ?? "No location update.")
            }
            .task {
                await model.startLocationDataSource()
                await model.locationDidChange()
            }
            .onDisappear {
                model.stopLocationDataSource()
            }
    }
}

private extension ContentView {
    /// The model for this example.
    class Model: ObservableObject {
        /// A topographic map.
        let map: Map
        
        /// A feature layer showing the locations of World Cities.
        let featureLayer: FeatureLayer
        
        /// The location display for the map.
        let locationDisplay: LocationDisplay
        
        /// The latest location update from the location data source.
        @Published var currentLocation: Location?
        
        init() {
            map = Map(basemapStyle: .arcGISTopographic)
            featureLayer = FeatureLayer(item: PortalItem(portal: .arcGISOnline(connection: .anonymous), id: PortalItem.ID("6996f03a1b364dbab4008d99380370ed")!))
            map.addOperationalLayer(featureLayer)
            locationDisplay = LocationDisplay(dataSource: SystemLocationDataSource())
            locationDisplay.autoPanMode = .recenter
        }
        
        /// Starts the location data source.
        func startLocationDataSource() async {
            // Requests location permission.
            let locationManager = CLLocationManager()
            locationManager.requestWhenInUseAuthorization()
            do {
                try await locationDisplay.dataSource.start()
            } catch {
                // Shows the error if starting the data source fails.
                print(error.localizedDescription)
            }
        }
        
        /// Stops the location data source.
        func stopLocationDataSource() {
            Task {
                await locationDisplay.dataSource.stop()
            }
        }
        
        /// Uses `for-await-in` to access location updates produced by the async stream.
        @MainActor
        func locationDidChange() async {
            for await newLocation in locationDisplay.dataSource.locations {
                currentLocation = newLocation
            }
        }
        
        /// Queries a cities feature table with a name.
        func queryCity(name: String) async throws {
            let queryParameters = QueryParameters()
            queryParameters.whereClause = "CITY_NAME = '\(name)'"
            // Queries the feature table dataset.
            if let queryResult = try await featureLayer.featureTable?.queryFeatures(using: queryParameters) {
                featureLayer.selectFeatures(queryResult.features())
            }
        }
    }
}

private extension Point {
    /// The point's decimal-formatted x and y coordinates.
    var xyCoordinates: String {
        String(format: "x: %.5f, y: %.5f", x, y)
    }
}

Links