Before I show you how to adopt Swift Concurrency in a project, I want to take a moment to show you why native support for concurrency was added to the language. Let's take a look at an example to better understand what problem Swift Concurrency solves.
The starter project of this episode is Cloudy, the application we refactor in Mastering MVVM With Swift. Open Geocoder.swift. The Geocoder class exposes an API to translate an address to a set of coordinates. Most of the heavy lifting is handled by the CLGeocoder class of Apple's Core Location framework.
import CoreLocation
class Geocoder: LocationService {
// MARK: - Properties
private lazy var geocoder = CLGeocoder()
// MARK: - Location Service
func translate(addressString: String, completion: @escaping Completion) {
guard !addressString.isEmpty else {
return
}
// Geocode Address String
geocoder.geocodeAddressString(addressString) { (placemarks, error) in
if let error = error {
completion(.failure(.requestFailed(error)))
} else if let placemarks = placemarks {
// Create Locations
let locations = placemarks.compactMap({ (placemark) -> Location? in
guard let name = placemark.name else { return nil }
guard let location = placemark.location else { return nil }
return Location(name: name, latitude: location.coordinate.latitude, longitude: location.coordinate.longitude)
})
completion(.success(locations))
}
}
}
}
We are interested in the translate(addressString:completion:) method of the Geocoder class. It accepts an address as its first argument and a completion handler, a closure, as its second argument. The completion handler is invoked when the forward geocoding request completes, successfully or unsuccessfully.
Forward geocoding is an asynchronous operation. It takes time for the geocoding request to complete. The CLGeocoder instance performs the geocoding request in the background to make sure the calling thread isn't blocked. The Geocoder instance invokes the completion handler that is passed to the translate(addressString:completion:) method to notify the caller that the geocoding request completed, successfully or unsuccessfully.
func translate(addressString: String, completion: @escaping Completion) {
guard !addressString.isEmpty else {
return
}
// Geocode Address String
geocoder.geocodeAddressString(addressString) { (placemarks, error) in
if let error = error {
completion(.failure(.requestFailed(error)))
} else if let placemarks = placemarks {
// Create Locations
let locations = placemarks.compactMap({ (placemark) -> Location? in
guard let name = placemark.name else { return nil }
guard let location = placemark.location else { return nil }
return Location(name: name, latitude: location.coordinate.latitude, longitude: location.coordinate.longitude)
})
completion(.success(locations))
}
}
}
Before we move on, it is essential that you understand what that means. Let's take a look at the flow of execution of a forward geocoding request. What happens when the translate(addressString:completion:) method is invoked. The geocoder uses a guard statement to exit early if addressString contains an empty string. There is no need to issue a geocoding request for an empty string.
If a valid address is passed to the translate(addressString:completion:) method, the address is passed to the geocodeAddressString(_:completionHandler:) method of the CLGeocoder instance. The geocodeAddressString(_:completionHandler:) method returns immediately because it performs the geocoding request asynchronously.
The completion handler of the geocodeAddressString(_:completionHandler:) method is invoked when the geocoding request completes, successfully or unsuccessfully. The Geocoder instance inspects the result of the request in the completion handler and invokes the completion handler of its translate(addressString:completion:) method.
This is a pattern that should look familiar. We initiate an asynchronous operation and invoke a closure when that operation completes. This pattern ensures the calling thread isn't blocked by the operation.
There are a few problems, though. The flow of execution isn't linear, making the implementation difficult to understand. In the previous episode, we discussed synchronous and asynchronous execution. The benefit of synchronous execution is that statements are executed from top to bottom. That is what makes synchronous execution easy to read and understand. Asynchronous execution doesn't follow this pattern, making the implementation more complex to understand and, if something goes wrong, to debug.
Another problem is the use of completion handlers. Even though this works fine, it is prone to bugs. Can you spot the bug in the translate(addressString:completion:) method? We use a guard statement to exit the method early if addressString contains an empty string. In the else clause of the guard statement, the completion handler isn't invoked. This could be considered a bug since the caller of the translate(addressString:completion:) method most likely expects the completion handler to be invoked at some point, even if the geocoding request fails.
The compiler doesn't raise an error or a warning because there is no strict contract in place that enforces this. It is up to you, the developer, to make sure the completion handler is invoked, regardless of the outcome of the geocoding request.
Let's take a look at how we can improve the implementation using Swift Concurrency. I won't explain the details in this episode. In this episode, we focus on the problem Swift Concurrency solves. We make a few changes to drastically improve the implementation of the translate(addressString:completion:) method.
Making the Method Awaitable
We start by removing the completion handler of the translate(addressString:completion:) method. We no longer need it if we embrace Swift Concurrency.
func translate(addressString: String) {
...
}
We turn the translate(addressString:) method into an awaitable method by adding the async keyword after the closing parenthesis. An awaitable method is a method that can suspend. We talk more about suspension in the next episode. Don't worry about it for now.
func translate(addressString: String) async {
...
}
The completion handler no longer returns the result of the geocoding request. How do we return the result of the geocoding request to the caller of the translate(addressString:) method? The solution is trivial thanks to Swift Concurrency. The translate(addressString:) method simply returns the result of a successful geocoding request, an array of Location objects.
func translate(addressString: String) async -> [Location] {
...
}
If the geocoding request fails, the translate(addressString:) method throws an error. We can mark an asynchronous method with the throws keyword to indicate that it can throw errors. This is a pattern you should already be familiar with.
func translate(addressString: String) async throws -> [Location] {
...
}
This means we no longer need Swift's Result type to communicate the result of the geocoding request. If the geocoding request is successful, the translate(addressString:) method returns an array of locations. If the geocoding request is unsuccessful, the translate(addressString:) method throws an error. It's that simple.
The signature of the translate(addressString:) method is ready. Let's now update its implementation. Notice that the compiler helps out by throwing a few errors. We start by fixing the guard statement. In the else clause of the guard statement, we remove the return statement, throwing an error of type LocationServiceError instead. The error communicates to the caller that the value of addressString is invalid.
func translate(addressString: String) async throws -> [Location] {
guard !addressString.isEmpty else {
throw LocationServiceError.invalidAddressString
}
...
}
Most of the APIs of Apple's frameworks have been updated for Swift Concurrency. Let me show you what that means for the translate(addressString:) method. We no longer pass a completion handler to the geocodeAddressString(_:completionHandler:) method of the CLGeocoder instance. We invoke its awaitable equivalent instead. The method is awaitable and throwing so we wrap it in a do-catch statement and prefix the method call with the try and await keywords. The try keyword indicates the method is throwing. The await keyword indicates the method is awaitable and may suspend.
func translate(addressString: String) async throws -> [Location] {
guard !addressString.isEmpty else {
throw LocationServiceError.invalidAddressString
}
do {
// Geocode Address String
let placemarks = try await geocoder.geocodeAddressString(addressString)
} catch {
}
}
We can move the logic of the else clause to the do clause of the do-catch statement. The key difference is that we don't invoke the completion handler. We simply return the array of locations.
func translate(addressString: String) async throws -> [Location] {
guard !addressString.isEmpty else {
throw LocationServiceError.invalidAddressString
}
do {
// Geocode Address String
let placemarks = try await geocoder.geocodeAddressString(addressString)
// Create Locations
return placemarks.compactMap({ (placemark) -> Location? in
guard let name = placemark.name else { return nil }
guard let location = placemark.location else { return nil }
return Location(name: name, latitude: location.coordinate.latitude, longitude: location.coordinate.longitude)
})
} catch {
}
}
In the catch clause of the do-catch statement, we throw an error of type LocationServiceError, wrapping the error thrown by the CLGeocoder instance.
func translate(addressString: String) async throws -> [Location] {
guard !addressString.isEmpty else {
throw LocationServiceError.invalidAddressString
}
do {
// Geocode Address String
let placemarks = try await geocoder.geocodeAddressString(addressString)
// Create Locations
return placemarks.compactMap({ (placemark) -> Location? in
guard let name = placemark.name else { return nil }
guard let location = placemark.location else { return nil }
return Location(name: name, latitude: location.coordinate.latitude, longitude: location.coordinate.longitude)
})
} catch {
throw LocationServiceError.requestFailed(error)
}
}
The refactored translate(addressString:) method is easier to understand and reason about. Because we no longer make use of completion handlers, the statements of the translate(addressString:) method are executed linearly as if it were a synchronous method. This is possible because we use the awaitable equivalent of the geocodeAddressString(_:completionHandler:) method.
What I love most is that the compiler helps out to avoid common bugs. The method signature of the translate(addressString:) method defines the contract. The method returns an array of locations on success and it throws an error if something goes wrong. The compiler enforces that contract, throwing an error if we break it. This means we can only exit the translate(addressString:) method by returning an array of locations or by throwing an error. I hope it is clear how much of an improvement that is.
Improvements at the Call Site
Before we continue, we need to update the LocationService protocol. The protocol defines the translate(addressString:) method and the Geocoder class conforms to it. Open LocationService.swift and update the translate(addressString:) method. We can also remove the type alias since we no longer need it.
protocol LocationService {
// MARK: - Methods
func translate(addressString: String) async throws -> [Location]
}
The translate(addressString:) method is invoked in the AddLocationViewModel class. Open AddLocationViewModel.swift and navigate to the translate(addressString:) method. We no longer need to pass a completion handler to the translate(addressString:) method of the location service. The translate(addressString:) method returns an array of locations and can throw so we wrap it in a do-catch statement and prefix the method call with the try and await keywords. We assign the result of the translate(addressString:) method to the locations property. Handling errors becomes trivial in the catch clause. We set locations to an empty array and print the error that is thrown. We also set querying to false below the do-catch statement.
private func translate(addressString: String?) {
guard let addressString = addressString else {
// Reset Locations
locations = []
return
}
// Update Helper
querying = true
do {
// Geocode Address String
locations = try await locationService.translate(addressString: addressString)
} catch {
// Reset Locations
locations = []
print("Unable to Forward Geocode Address (\(error)")
}
// Update Helper
querying = false
}
The compiler complains that we invoke an asynchronous function in a function that doesn't support concurrency. Don't worry about this for now. I explain what that means and how to resolve it later in this series. This episode focuses on the benefits of Swift Concurrency and what problem it solves.
What Did We Gain?
There are a number of benefits we gained by replacing completion handlers with awaitable methods. First, the translate(addressString:) method has a linear execution flow, from top to bottom. This makes the implementation easier to understand and reason about. Second, the await keyword clearly indicates at what point the execution of the translate(addressString:) method can be suspended. I talk more about suspension in the next episode. Third, error handling is more intuitive and identical to how errors thrown by a synchronous method are handled.
Also note that updating the querying property is simple and intuitive. It is set to true before the translate(addressString:) method of the location service is invoked to indicate to the user that the forward geocoding request is in flight. We set querying to false after the translate(addressString:) method of the location service returns. This is much easier to understand and less prone to bugs.
What's Next?
I have shown you a glimpse of what Swift Concurrency means for your code. There is much more to come, though. In the next episode, you learn more about awaitable functions and methods. What does it mean for a function or method to suspend? How does it affect the execution of the translate(addressString:) method of the Geocoder class? We explore that in the next episode.