Asynchronous operations allow you to write long-running tasks in a distinct matter while being able to add dependencies between several tasks. Progress can be tracked, and dispatching is made easy by making use of the OperationQueue
. By adding generics and the Swift result type, we can get even more out of asynchronous operations.
After getting started with operations and writing concurrent solutions using asynchronous operations it’s now time to see how we can make a more advanced solution for our codebase. This is fully based on the implementation we’re using in the Collect by WeTransfer app in which we use over 50 different operations.
Creating a result-driven asynchronous operation
It’s often the case that an operation results in a specific value. It’s at least valuable to have a way of catching an occurred error. Both value and error come together in the Result<Success, Failure>
type in Swift.
Adding generics to the asynchronous operation
By making use of the AsyncOperation
created in the blog post Asynchronous operations for writing concurrent solutions we give ourselves a kickstart. We add the same generics as the result type on top of it, which results in the following base:
class AsyncResultOperation<Success, Failure>: AsyncOperation where Failure: Error {
private(set) var result: Result<Success, Failure>?
}
This gives the asynchronous task a linked result type with a generic for both value and error.
Ensuring results on finish
To make sure the result updates, we’re adding a check in the finish method to help implementers and give them feedback while developing:
final override func finish() {
guard !isCancelled else { return super.finish() }
fatalError("Make use of finish(with:) instead to ensure a result")
}
func finish(with result: Result<Success, Failure>) {
self.result = result
super.finish()
}
First, we make sure that the default finish()
method is no longer useful. It throws a fatal exception when one of its implementers uses it:
Fatal error: Make use of finish(with:) instead to ensure a result: file UnfurlURLOperation.swift, line 44
Second, we add a new finish(with:)
method that enforces implementers to set a result.
Ensuring result on cancelation
The last part makes sure that we have a result set on cancelation. As we like to have a type of error, we’re forced to create another override in our class:
override func cancel() {
fatalError("Make use of cancel(with:) instead to ensure a result")
}
func cancel(with error: Failure) {
self.result = .failure(error)
super.cancel()
}
We have to define a case in our error enum for cancelation. Doing so gives us the benefit of strongly typed errors while still being able to have a result upon cancelation.
Putting it all together into an example operation
The final AsyncResultOperation
looks as follows:
open class AsyncResultOperation<Success, Failure>: AsyncOperation where Failure: Error {
private(set) public var result: Result<Success, Failure>?
final override public func finish() {
guard !isCancelled else { return super.finish() }
fatalError("Make use of finish(with:) instead to ensure a result")
}
public func finish(with result: Result<Success, Failure>) {
self.result = result
super.finish()
}
override open func cancel() {
fatalError("Make use of cancel(with:) instead to ensure a result")
}
public func cancel(with error: Failure) {
self.result = .failure(error)
super.cancel()
}
}
We can make use of this class by creating a custom operation, like a task to unfurl short URLs.
final class UnfurlURLOperation: AsyncResultOperation<URL, UnfurlURLOperation.Error> {
enum Error: Swift.Error {
case canceled
case missingRedirectURL
case underlying(error: Swift.Error)
}
private let shortURL: URL
private var dataTask: URLSessionTask?
init(shortURL: URL) {
self.shortURL = shortURL
}
override func main() {
var request = URLRequest(url: shortURL)
request.httpMethod = "HEAD"
dataTask = URLSession.shared.dataTask(with: request, completionHandler: { [weak self] (_, response, error) in
if let error = error {
self?.finish(with: .failure(Error.underlying(error: error)))
return
}
guard let longURL = response?.url else {
self?.finish(with: .failure(Error.missingRedirectURL))
return
}
self?.finish(with: .success(longURL))
})
dataTask?.resume()
}
override func cancel() {
dataTask?.cancel()
cancel(with: .canceled)
}
}
This class takes a short URL, executes a HEAD
request to fetch the long URL, and returns that as a result. If any error occurs, it’s as a result of the operation. Lastly, we make sure to cancel the data task on cancelation, and we call the cancel(with:)
method using our canceled
error case.
Executing this method finally results in www.avanderlee.com
as the unfurled URL:
let queue = OperationQueue()
let unfurlOperation = UnfurlURLOperation(shortURL: URL(string: "https://bit.ly/33UDb5L")!)
queue.addOperations([unfurlOperation], waitUntilFinished: true)
print("Operation finished with: \(unfurlOperation.result!)")
// Prints: Operation finished with: success(https://www.avanderlee.com/)
Strongly typed chaining of tasks
Although the above operation is already valuable, we can take another step to make operations even more flexible. We can do this by creating a chained asynchronous operation.
This operation takes into account dependencies and uses the result of its dependency as an input. We start by creating a new ChainedAsyncOperation
class that takes input and output as a generic parameter.
open class ChainedAsyncResultOperation<Input, Output, Failure>: AsyncResultOperation<Output, Failure> where Failure: Swift.Error {
private(set) public var input: Input?
public init(input: Input? = nil) {
self.input = input
}
}
We create an initializer with an optional input. As the chain has to start somewhere, we need to have an option to set the input manually instead of deferring it from dependencies.
Updating the input from dependencies
Next up is updating the input from the dependencies. We want to do this at the moment the task starts, which we can do by overriding the start()
method.
override public final func start() {
updateInputFromDependencies()
super.start()
}
/// Updates the input by fetching the output of its dependencies.
/// Will always get the first output matching dependency.
/// If `input` is already set, the input from dependencies will be ignored.
private func updateInputFromDependencies() {
guard input == nil else { return }
input = dependencies.compactMap { dependency in
return (dependency as? ChainedOperationOutputProviding)?.output as? Input
}.first
}
As you can see, we’ve created a new ChainedOperationOutputProviding
protocol. This protocol allows us to fetch the output from another operation in the chain. The protocol itself looks as follows:
protocol ChainedOperationOutputProviding {
var output: Any? { get }
}
extension ChainedAsyncResultOperation: ChainedOperationOutputProviding {
var output: Any? {
return try? result?.get()
}
}
Putting it all together by chaining two operations
The final chained operation class looks as follows:
open class ChainedAsyncResultOperation<Input, Output, Failure>: AsyncResultOperation<Output, Failure> where Failure: Swift.Error {
private(set) public var input: Input?
public init(input: Input? = nil) {
self.input = input
}
override public final func start() {
updateInputFromDependencies()
super.start()
}
/// Updates the input by fetching the output of its dependencies.
/// Will always get the first output matching dependency.
/// If `input` is already set, the input from dependencies will be ignored.
private func updateInputFromDependencies() {
guard input == nil else { return }
input = dependencies.compactMap { dependency in
return (dependency as? ChainedOperationOutputProviding)?.output as? Input
}.first
}
}
We can use this class to create a chain of operations. For example, taking the previous unfurl operation by adding another operation that fetches the title of the unfurled URL. For this, we have to create a new FetchTitleChainedOperation
:
public final class FetchTitleChainedOperation: ChainedAsyncResultOperation<URL, String, FetchTitleChainedOperation.Error> {
public enum Error: Swift.Error {
case canceled
case dataParsingFailed
case missingInputURL
case missingPageTitle
case underlying(error: Swift.Error)
}
private var dataTask: URLSessionTask?
override final public func main() {
guard let input = input else { return finish(with: .failure(.missingInputURL)) }
var request = URLRequest(url: input)
request.httpMethod = "GET"
dataTask = URLSession.shared.dataTask(with: request, completionHandler: { [weak self] (data, response, error) in
do {
if let error = error {
throw error
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
throw Error.dataParsingFailed
}
guard let pageTitle = self?.pageTitle(for: html) else {
throw Error.missingPageTitle
}
self?.finish(with: .success(pageTitle))
} catch {
if let error = error as? Error {
self?.finish(with: .failure(error))
} else {
self?.finish(with: .failure(.underlying(error: error)))
}
}
})
dataTask?.resume()
}
private func pageTitle(for html: String) -> String? {
guard let rangeFrom = html.range(of: "<title>")?.upperBound else { return nil }
guard let rangeTo = html[rangeFrom...].range(of: "</title>")?.lowerBound else { return nil }
return String(html[rangeFrom..<rangeTo])
}
override final public func cancel() {
dataTask?.cancel()
cancel(with: .canceled)
}
}
This operation takes the input URL, performs a GET request, and takes the title from the final HTML result.
Executing both together will look as follows:
let queue = OperationQueue()
let unfurlOperation = UnfurlURLOperation(shortURL: URL(string: "https://bit.ly/33UDb5L")!)
let fetchTitleOperation = FetchTitleChainedOperation()
fetchTitleOperation.addDependency(unfurlOperation)
queue.addOperations([unfurlOperation, fetchTitleOperation], waitUntilFinished: true)
print("Operation finished with: \(fetchTitleOperation.result!)")
// Prints: Operation finished with: success("A weekly Swift Blog on Xcode and iOS Development - SwiftLee")
The code demonstrates how easy it is after setting up the operations to chain them together. Add a dependency between the operations, and it all works out.
Combining tasks is especially useful if you have to perform multiple, significant tasks, and you’d like to keep the code separated. It allows you to create separation of concern and isolated testable code.
Conclusion
That’s it! We’ve created advanced operations by making use of generics in Swift. By chaining them together we allow ourselves to create relationships between tasks while keeping code separated.
This post is part of a series:
- Getting started with Operations and OperationQueues in Swift
- Asynchronous operations for writing concurrent solutions in Swift
- Advanced asynchronous operations by making use of generics
And can also be found in the form of a Swift Playground: https://github.com/AvdLee/AsyncOperations
If you like to improve your Swift knowledge, even more, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.
Thanks!