Skip to main

Basics of Operations and Operation Queues in iOS

by Stanisław Paśkowski

Basics of Operations and Operation Queues in iOS

Day by day our applications have to do more and more complicated tasks without compromising their responsiveness. That’s why we need to know how to make the best use of power that awaits our devices. We can do it by utilizing concurrency through the usage of Grand Central Dispatch or Operations. Today we will talk about Operations.

Copy link
Operation

Simply put, Operation is a wrapper around some work that we would like to execute. Speaking more formally it is an abstract class that when subclassed will perform a given task for us. It’s built atop of the Grand Central Dispatch, which allows us to focus less on details of how concurrent execution will be done and more on the implementation of our business logic. Operations can help us with tasks that require more time to complete such as network calls or data processing.

Copy link
Block operation

A very simple example of Operation is Block Operation. Sometimes we may think that a task that we would like to do is just too small to create Operation object for it — that’s when we can use the Block Operation.

import UIKit

let printerOperation = BlockOperation()

printerOperation.addExecutionBlock { print("I") }
printerOperation.addExecutionBlock { print("am") }
printerOperation.addExecutionBlock { print("printing") }
printerOperation.addExecutionBlock { print("block") }
printerOperation.addExecutionBlock { print("operation") }

printerOperation.completionBlock = {
print("I'm done printing")
}

let operationQueue = OperationQueue()
operationQueue.addOperation(printerOperation)

In this playground example, we create a printerOperation as our BlockOperation object, then we add to it blocks of code that will be part of this operation. After adding all of the blocks we set the completion block that will be executed after operation finishes. Now the only thing missing is starting our operation and to do so we create an Operation Queue object. We will go into details of what exactly the Operation Queue is but for now think about it as a queue that waits for operations to get added to it and then executes them on a separate thread. If we were to run this example a few times in a row we would get a different order of printed words because the order isn’t guaranteed here. We will later see how to control the order of task execution inside an Operation Queue.

Creating custom Operation objects

Synchronous operation

Sometimes using the BlockOperation simply won’t be enough. For example when we want to keep our code decoupled in order to be able to swap operations depending on our needs or when we would like to make our operation more generic. That’s when we need to define a custom operation object. When we create our own subclass of the Operation the least we have to do is to override its main method by putting the implementation of our task inside of it and handle the possibility that it was canceled. We will talk more about cancelation later down the road.

To make our operations nicely organized we can add properties, initializers, and helper methods. Here we have an example of the Operation that adds a filter to an image.

import UIKit

class MonoImageOperation: Operation {

var inputImage: UIImage?
var outputImage: UIImage?

init(inputImage: UIImage) {
self.inputImage = inputImage
}

override public func main() {
if self.isCancelled {
return
}
outputImage = applyMonoEffectTo(image: inputImage)
}

private func applyMonoEffectTo(image: UIImage?) -> UIImage? {
guard let image = image,
let ciImage = CIImage(image: image),
let mono = CIFilter(name: "CIPhotoEffectMono",
withInputParameters: [kCIInputImageKey: ciImage])
else { return nil }

let ciContext = CIContext()
guard let monoImage = mono.outputImage,
let cgImage = ciContext.createCGImage(monoImage, from: monoImage.extent)
else { return nil }

return UIImage(cgImage: cgImage)
}
}

Now let’s take a look at how we can use our brand new operation.

import UIKit

class ViewController: UIViewController {

@IBOutlet weak var imageView: UIImageView!

override func viewDidLoad() {
super.viewDidLoad()

let image = UIImage(named: "image-1.jpg")
let monoImageOperation = MonoImageOperation(inputImage: image!)
monoImageOperation.completionBlock = {
DispatchQueue.main.async {
self.imageView.image = monoImageOperation.outputImage
}
}
let operationQueue = OperationQueue()
operationQueue.addOperation(monoImageOperation)
}
}

An important thing to note here is that inside of the completion handler we are setting the imageView’s image property to the output of our operation on the main queue because every UI related change has to happen there.

Now that we know how to create a custom Operation, it’s important to understand its lifecycle.

Operation states

Every operation has states that change throughout the course of its execution. When we instantiate an operation we set its state to isReady. Later when we decide to run our operation, its state switches to isExecuting. Next, it can go down two ways: we can decide to cancel our operation, or we can wait and let it finish — in both cases we end up with isFinished status. We’ll discuss canceling in more detail after the next example.

Using synchronous operations we don’t have to worry about states because they are being handled for us by the system. It’s not the case with an asynchronous operation, that’s why it’s important to know how and when the operation states change.

Asynchronous Operation

Some long-running tasks, such as network calls, require us to make our operation asynchronous. We need to do it because if we were just to call an asynchronous method from regular Operation’s main() method it would think that it’s completed right away, as asynchronous calls return immediately.

Because an operation queue that’s running our operations can’t tell when it’s done with an asynchronous call, we need to notify it about the operation’s state changes through KVO.

To make our operation asynchronous we need to override the following method and properties:

This property explicitly says that our operation is going to be asynchronous.

2. isExecuting

This property has to be overridden by us because in the case of asynchronous operations we must handle state changes on our own. It states whether an operation is currently executing.

3. isFinished

Similar to isExecuting, we have to override isFinished in order to take care of changing the operation state. This property demonstrates whether the operation is already finished.

4. start()

This method is the place where our operation begins. We’re responsible for checking if it wasn’t canceled, then changing its state to isExecuting and generating KVO notifications. After that, we have to call an asynchronous method right away or the main method with an asynchronous method inside it which gives us a nicer code organization.

import Foundation

class AsyncOperation: Operation {
public enum State: String {
case ready, executing, finished

fileprivate var keyPath: String {
return "is" + rawValue.capitalized
}
}

public var state = State.ready {
willSet {
willChangeValue(forKey: state.keyPath)
willChangeValue(forKey: newValue.keyPath)
}
didSet {
didChangeValue(forKey: oldValue.keyPath)
didChangeValue(forKey: state.keyPath)
}
}
}

Here we can see an Operation subclass called AsyncOperation which will be a base for an asynchronous operation that we will create in the next example. We added a computed property state that will generate proper KVO notifications before and after the state changes so we won’t have to worry about doing it manually. These notifications are being used by the OperationQueue for handling operations.

extension AsyncOperation {

override var isAsynchronous: Bool {
return true
}

override var isExecuting: Bool {
return state == .executing
}

override var isFinished: Bool {
return state == .finished
}

override func start() {
if isCancelled {
return
}
main()
state = .executing
}
}

Next, we have an extension inside which we override the necessary properties and start() method as described at the beginning of this section.

This operation subclass was taken from one of Ray Wenderlich’s tutorials.

Now let’s see how we can use this base class to create an actual asynchronous operation.

import UIKit

class AsyncImageDownloadOperation: AsyncOperation {

var downloadedImage: UIImage?

override func main() {
let defaultSession = URLSession(configuration: .default)
guard let imgUrl = URL(string: "https://unsplash.com/photos/M9O6GRrEEDY/download?force=true") else { return }
let dataTask = defaultSession.dataTask(with: imgUrl) { (data, response, error) in
if let error = error {
print("Image download encountered an error: \(error.localizedDescription)")
} else if let data = data, let response = response as? HTTPURLResponse,
response.statusCode == 200 {
if self.isCancelled {
self.state = .finished
return
}
let image = UIImage(data: data)
self.downloadedImage = image
self.state = .finished
}
}
dataTask.resume()
}
}

As discussed above inside main() method we have an asynchronous method, in this case, some image downloading code. Inside the dataTask call, we check if the operation wasn’t canceled in the meantime — if it was then we return without setting the downloadedImage. Each state change sends KVO notifications through the computed state property inside the AsyncOperation class.

To actually use an object of this class we should add it to an operation queue, and we already know how to do it, but we didn’t yet talk in detail about what an operation queue is.

Copy link
Operation Queue

Operation Queue is a special queue to which we can add our operations to have them executed on a separate thread. It is a kind of FIFO queue but we don’t have any certainty about the exact order execution. If we would like to have one operation executed faster than the other, we can make use of a queue priority.

Queue priority has to be set on the operation before adding it to an operation queue.

We can pick from one of 5 priorities:

  1. veryLow

  2. low

  3. normal

  4. high

  5. veryHigh

By default, an operation has normal queuePriority.

On the chart above we have an example of adding multiple processes with different priorities to an operation queue. As we can see tasks with higher priority are getting executed faster. We can limit the number of operations being executed concurrently inside a queue by setting its maxConcurrentOperationCount property. For example, making it equal one would make the queued serial. There is also an option to make queue wait for all of its operations to complete, but keep in mind that it will block the current thread.

If we would like to cancel all tasks that are currently inside an operation queue we can do so by calling its cancelAllOperations() method. This method is the reason why we check if the operation isn’t canceled before starting it because there may be a case when it was waiting for its turn inside a queue but got canceled before even starting. We also have an option to suspend a queue preventing it from starting any queued operations, but operations that are already executing will continue to do so. Even if our operation queue is suspended, we can keep adding new operations to it, however, it will not start any of them until we will set its isSuspended property to false.

Quality of Service

Through the usage of qualityOfService we determine which object will get faster access to resources such as network, CPU time, disk resources, and so on. We can set the quality of service to a certain operation object or to the whole operation queue. If the operation has its qualityOfService property set then it will be used instead of the queue’s qualityOfService.

We can pick one of five options:

Used for things that have to be provided to the user as fast as possible such as drawing to the screen or processing control events

2. userInitiated

Used for a user-requested work — for example loading a recipe after a user has selected it on the recipes list.

3. utility

Used for work that the user is willing to wait longer for such as exporting a bulk file.

4. background

Used for work that is not visible to the user — syncing with remote database or pre-fetching content.

5. default

This is the one that will be assigned to the object that got no qualityOfService assigned by us — but it’s something that won’t happen in our case because both Operation and Operation Queue have a default background qualityOfService.

It’s important to choose the right qualityOfService for the job that our operation or operation queue should do because it can have both a positive or negative impact. If we are doing some resource-heavy operation that is important for the user we need to pick the userInitiated, but doing so for something not important because we feel like it may help will only waste battery.

Copy link
Summary

Concurrency programming isn’t easy but with the right tools, such as the Operations we can make good use of it without worrying too much about all the details happening under the hood. Now that we know the basics of Operations we will be able to tackle more complicated (and fun) cases in the next post!


IntroductionArticle

Related Articles