6.
Operations
Written by Scott Grosch
Now that you’re a ninja master of Grand Central Dispatch, it’s time to shift gears and take a look at operations. In some regards, operations act very much like GCD, and it can be confusing as to the difference when you first start utilizing concurrency.
Both GCD and operations allow you to submit a chunk of code that should be run on a separate thread; however, operations allow for greater control over the submitted task.
As mentioned at the start of the book, operations are built on top of GCD. They add extra features such as dependencies on other operations, the ability to cancel the running operation, and an object-oriented model to support more complex requirements.
Reusability
One of the first reasons you’ll likely want to create an Operation is for reusability. If you’ve got a simple “fire and forget” task, then GCD is likely all you’ll need.
An Operation is an actual Swift object, meaning you can pass inputs to set up the task, implement helper methods, etc. Thus, you can wrap up a unit of work, or task, and execute it sometime in the future, and then easily submit that unit of work more than once.
Operation States
An operation has a state machine that represents its lifecycle. There are several possible states that occur at various parts of this lifecycle:
- When it’s been instantiated and is ready to run, it will transition to the
isReadystate. - At some point, you may invoke the
startmethod, at which point it will move to theisExecutingstate. - If the app calls the
cancelmethod, then it will transition to theisCancelledstate before moving onto theisFinishedstate. - If it’s not canceled, then it will move directly from
isExecutingtoisFinished.
Each of the aforementioned states are read-only Boolean properties on the Operation class. You can query them at any point during the execution of the task to see whether or not the task is executing.
The Operation class handles all of these state transitions for you. The only two you can directly influence are the isExecuting state, by starting the operation, and the isCancelled state, if you call the cancel method on the object.
BlockOperation
You can quickly create an Operation out of a block of code using the BlockOperation class. Normally, you would simply pass a closure to its initializer:
let operation = BlockOperation {
print("2 + 3 = \(2 + 3)")
}
A BlockOperation manages the concurrent execution of one or more closures on the default global queue. This provides an object-oriented wrapper for apps that are already using an OperationQueue (discussed in the next chapter) and don’t want to create a separate DispatchQueue as well.
Being an Operation, it can take advantage of KVO (Key-Value Observing) notifications, dependencies and everything else that an Operation provides.
What’s not immediately apparent from the name of the class is that BlockOperation manages a group of closures. It acts similar to a dispatch group in that it marks itself as being finished when all of the closures have finished. The example above shows adding a single closure to the operation. You can, however, add multiple items, as you’ll see in a moment.
Note: Tasks in a
BlockOperationrun concurrently. If you need them to run serially, submit them to a privateDispatchQueueor set up dependencies.
Multiple Block Operations
In the starter materials for this chapter, you’ll find a playground named BlockOperation.playground. This playground provides a default duration function for timing your code, which you’ll use in a moment.
When you want to add additional closures to the BlockOperation, you’ll call the addExecutionBlock method and simply pass in a new closure. Use that method to print out a public service announcement, one word at a time. Paste the following code into your Playground:
let sentence = "Ray’s courses are the best!"
let wordOperation = BlockOperation()
for word in sentence.split(separator: " ") {
wordOperation.addExecutionBlock {
print(word)
}
}
wordOperation.start()
The above code splits the sentence apart on spaces, so you have an array of words. For each word, another closure is added to the operation. Thus, each word is printed as part of the wordOperation.
Display the console (Shift-Command-Y) and then run the playground. The sentence is printed to the console, one word per line, but the order is jumbled. Remember that a BlockOperation runs concurrently, not serially, and thus the order of execution is not deterministic.
Time to learn a little lesson in concurrency using the duration helper function I mentioned earlier.
Add a delay of two seconds right after the print call with the following line:
sleep(2)
Next, wrap the start call with the built-in duration function:
duration {
wordOperation.start()
}
Take a look at the total time displayed on the line where you call duration.
Even though each operation sleeps for two seconds, the total time of the operation itself is just over two seconds, not 10 seconds (five prints times two seconds each).
Remember that BlockOperation works similar to a DispatchGroup — that means it’s gotta be easy to know when all the operations have completed, right?
If you provide a completionBlock closure, then it will be executed once all of the closures added to the block operation have finished. Add this code to your playground, before you call duration, and run it again to see the results:
wordOperation.completionBlock = {
print("Thank you for your patronage!")
}
Subclassing Operation
BlockOperation is great for simple tasks but if performing more complex work, or for reusable components, you’ll want to subclass Operation yourself.
Open up Concurrency.xcodeproj from the starter folder inside this chapter’s download materials. Build and run the project and tap on the Show Tilt Shift button at the top of the screen.
You’ll see an example of what you’ll work with over the next few chapters. The image displayed at the top of the screen is the source image. After a few moments of processing, the tilt shifted image will appear below it.
Tilt shifting is a technique used on images to alter their depth of field. If you compare the two images, you’ll see the the center of the bottom image is still in focus, but everything around it is blurred.
The example project provides a TiltShiftFilter.swift file, which is a subclass of CIFilter. Note that it works fine for educational purposes as the code is very clear and easy to follow, but it’s far from optimal in terms of performance. If you need to use tilt shifting in a real application, there are far better solutions available.
If you jumped ahead and tapped on the Show Table button, you were probably pretty disappointed to just get an empty table view! Time to build that out.
Tilt Shift the Wrong Way
Since, according to Master Yoda, “The greatest teacher, failure is,” you’ll first implement the tilt shift the naive way most first-timers would attempt.
As mentioned earlier, you can see how the tilt shift is performed by taking a look at TiltShiftFilter.swift. If you’re not familiar with Core Image, check out “Core Image Tutorial: Getting Started”. While not specifically required to continue following along, this may be helpful to understand how filters work in the examples that follow.
The sample project provides ten images in its Asset Catalog for you to use. They’re simply named 0 through 9 for ease of use. Open ImageView.swift in Xcode’s editor window and add the following code to tiltShiftImage:
let name = "\(rowNumber).png"
let uiImage = UIImage(named: name)!
This will grab the image from the Asset Catalog. Next, add the following code to filter the image:
print("Tilt shifting image \(name)")
guard
let filter = TiltShiftFilter(image: uiImage, radius: 3),
let output = filter.outputImage
else {
print("Failed to tilt shift \(name)")
return
}
You’ll try to filter the image using TiltShiftFilter, and if everything works out, you should get an output image. If something goes wrong, you’ll print out an error message. The output image is of type CIImage. In order to display it, you need to convert it to UIImage and then to Image.
Add the following code:
print("Generating UIImage for \(name)")
let fromRect = CGRect(origin: .zero, size: uiImage.size)
guard let cgImage = CIContext().createCGImage(output, from: fromRect) else {
print("No image generated")
return
}
let result = UIImage(cgImage: cgImage)
image = Image(uiImage: result)
print("Displaying \(name)")
You pass the output back through a CIContext to turn it back to a CGImage, then convert that to a UIImage.
Note: Your phone will run Core Image operations an order of magnitude faster than an Intel-cpu Mac will. If you’re running on the simulator, change the number of rows in the table view from 10 to two. I strongly suggest you test directly on your iOS device!
For the purposes of this demo, if using your Mac, those print statements are critical, so be sure that Xcode’s console is showing (⇧ + ⌘ + Y) and then build and run the project.
Once the app starts up, tap on the Show Table button and watch the console window. Depending on the speed of your device or simulator, you’ll see that the filtering takes a bit of time. If you scroll the table view, you’ll observe stutters while the app tries to perform the tilt shift filter.
For a smoother experience, you’ll have to move the tilt shifting off the main thread and perform it in the background, so let’s do that.
Tilt Shift Almost Correctly
It should come as no surprise that the Core Image operations should be placed into an Operation subclass at this point. You’re going to need both an input and an output image, so you’ll create those two properties. The input image should never change so it makes sense to pass it to the initializer and make it private.
Create a new Swift file called TiltShiftOperation.swift and replace its content with the following:
import SwiftUI
final class TiltShiftOperation: Operation {
var outputImage: UIImage?
private let inputImage: UIImage
init(image: UIImage) {
inputImage = image
super.init()
}
}
In the ImageView.swift file, you created an instance of CIContext for every image, which is a bad thing to do. CIContext should be reused when possible, and Apple’s documentation explicitly states that the CIContext is thread-safe, so you can make it static.
Add the context property to your class, right at the start of the class:
private static let context = CIContext()
All that’s left to do now is override the main method, which is the method that will be invoked when your operation starts. Most of the code is similar to what you just wrote, with a few minor tweaks:
override func main() {
guard
let filter = TiltShiftFilter(image: inputImage, radius: 3),
let output = filter.outputImage
else {
print("Failed to generate tilt shift image")
return
}
let fromRect = CGRect(origin: .zero, size: inputImage.size)
guard let cgImage = TiltShiftOperation.context.createCGImage(
output, from: fromRect) else {
print("No image generated")
return
}
outputImage = UIImage(cgImage: cgImage)
}
Notice that all print statements have been removed and the context references the static property, but other than that, things work the same way. If the filter is applied successfully, then the outputImage will be a non-nil value. If anything fails, it will stay nil.
All that’s left to do now is to switch ImageView to utilize your new operation. If you want to manually run an operation, you can call its start method. Go back to ImageView.swift and replace tiltShitImage with this code:
private func tiltShiftImage() {
print("Filtering")
let op = TiltShiftOperation(image: UIImage(named: "\(rowNumber).png")!)
op.start()
if let outputImage = op.outputImage {
print("Updating image")
image = Image(uiImage: outputImage)
}
print("Done")
}
Before you build and run the app again, take a moment to consider the changes you’ve made and their impact on the end user’s experience. Build and run, now.
Did reality meet your expectations? Were you surprised there were no performance gains between this version of the app and the previous version? When you call the start method directly on an operation, you’re performing a synchronous call on the current thread (i.e., the main thread). So while the code has been refactored into an Operation subclass, you’re not yet taking advantage of the concurrency opportunities provided therein.
Note: Besides the fact that calling
startruns the operation on the current thread, it can also lead to an exception if the operation is not yet ready to be started. Generally, you shouldn’t callstartmanually, unless you really know what you’re doing!
In the next chapter, you’ll begin to truly utilize the benefits of Operation and resolve that synchronous issue.