Engineering

30 Nov 2020

Deeper Dive into Operations and OperationQueues

An image showing the different operations and their functions
An image showing the different operations and their functions
An image showing the different operations and their functions

In this article, we will learn more about the power and flexibility of operations and how to utilise it.

If you are unfamiliar with Operation and OperationQueue, there are some great sources to get to know them (Apple’s 2015 WWDC just to mention one), but as the saying goes: “practice makes perfect”.

The world of Software development is at a stage today where developers themselves need to be able to push out code and deliver features more regularly and quickly. We have to make it easy for ourselves to do this. Just within the codebase, we can make this easier by following good software principles, clean and appropriate design patterns, clean architectures, etc. The use of operations and operation queues is an amazing tool that almost forces us to structure our code in this way, though they do have more benefits than just this, and we will dive into these benefits in this article.

Lifecycle and different states of an Operation
Before we can go into the depth of operations and the role they play, it is important to understand the Lifecycle of an Operation. A quick recap and brief look at the lifecycle of an operation:

There are 4 states that we can query from an Operation to determine its state:

var isReady: Bool { get }

var isExecuting: Bool { get }

var isFinished: Bool { get }

var isCancelled: Bool { get }

At any given time, we know in what state an operation is by checking any of these 4 above mentioned Bool properties.

By default, an operation is in a Pending state, even before it is added to an OperationQueue. The state of an operation is then updated automatically when it is being executed on a queue. 

It is also noteworthy that an operation can move into a Cancelled state at any given time prior to moving into a Finished state. Once an Operation is in a Finished or Cancelled state, it can’t be updated from there.

An instance of an operation can only be run once. Once an instance of an operation has been thrown onto a queue, the same instance of that operation cannot be used or executed on a queue again. It’s easier to think about it in such a way that an Operation can only move into any given state once. If an Operation, for example, has moved from a Ready to Executing state, the same instance will never be able to move back into the Ready state again. Same goes for any other state.

Synchronous vs Asynchronous operations
An Operation is an abstract class. Your subclass determines which tasks needs to be completed before your Operation can eventually finish.

For the beneath examples, we’ll assume that operations are managed on a queue, even though you can run an operation without a queue by just calling start(), but the benefit, convenience and logic built into an OperationQueuereally shines when it comes to managing asynchronous operations.

With synchronous operations the setup is quite simple:

class SynchronousOperation: Operation {

    open override func main() {
        // Perform some synchronous task
    }
}

As we can see, there is really not anything needed to set up a synchronous operation. We don’t need to override start() as this manages some state checking for us. For example, the Operation’s state is transferred from Ready to Executing within the start() function, which then in turn calls main(). This is where we want to perform the task assigned to the Operation. When main()returns, our state is also then automatically transferred from Executing to Finished.

Synchronous operations are very straight forward, but the beef and excitement really comes when working with an AsynchronousOperation. As mentioned, synchronous operations can manage their own state, whereas with asynchronous operations, that needs to be managed by the subclass. We can start off with creating an OperationState enum:

num OperationState: String {
    case ready = "Ready"
    case executing = "Executing"
    case finished = "Finished"

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

    var state: String {
        return self.rawValue.lowercased()
    }

    static func state(with value: String) -> OperationState? {
        let capitalised = value.capitalized
        return OperationState(rawValue: capitalised)
    }
}

Now we can implement our AsynchronousOperation subclass:

class AsynchronousOperation: Operation {

    private(set) var state = OperationState.ready {
        willSet(new) {
            willChangeValue(forKey: self.state.keyPath)
            willChangeValue(forKey: new.keyPath)
        }

        didSet(value) {
            didChangeValue(forKey: self.state.keyPath)
            didChangeValue(forKey: value.keyPath)
        }
    }

    // MARK: Property overrides

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

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

    // MARK: Function overrides

    override func start() {
        if self.isCancelled {
            self.finish()
        } else {
            self.state = .ready
            self.main()
        }
    }

    override func main() {
        if self.isCancelled {
            self.finish()
        } else {
            self.state = .executing
        }
    }

	override func cancel() {
        super.cancel()
        self.finish()
    }

    // MARK: Convenience

    func finish() {
        guard self.state != .finished else {
            return
        }

        self.state = .finished
    }
}

You may have spotted that our AsynchronousOperation subclass automatically calls finish() when it gets into the Cancelled state. The reason for this will be made clear once we’ve reached the section talking about adding observers to AsynchronousOperation.

The principle with AsynchronousOperation is the same as with SynchronousOperation. Any task assigned to the operation needs to happen within main(), after we’ve transferred the state to Executing. The only difference here is that you need to call super.main() when you override main() within your AsynchronousOperation subclass. This is to ensure that your operation's state is transferred correctly.


Hopefully you’ve spotted it, but there is one thing missing within the AsynchronousOperation subclass. We’ve transferred state successfully to Executing, but what about Finished? The subclass has to call finish() when the asynchronous task has completed successfully or cancel() if the task completed with an error.

NOTE:

It is very important that you pay attention and ensure that your asynchronous operation subclass always transfer state from Ready all the way to Finish or Cancel. If this by any chance is missed, it’ll mean that your operation never completes, which also means that your OperationQueue is never deallocated, hence the object that has a reference to that queue never gets deallocated, and so on and so forth. It could create an ugly ripple effect within your application.

An Operation should only manage a single task
As we’ve mentioned right in the beginning, operations and operation queues are a massive tool we can use to sustain our code base. One of the major use cases for operations is to abstract logic into a single command/task, which also makes it easier to change later. The Single Responsibility Principle is so much more important, especially when working with operations, and we will see why this is so much more valuable when we move into the next section, focusing on setting up dependencies between operations.

Re-using certain components in different scenarios and complex flows is so much easier with operations. It is so much easier when we have one task associated with an operation.

Let’s say, for instance, we have an application that needs to download and cache data. We’d want to have the download task embedded into an operation, and the caching of the data embedded into a different operation. In one instance we would obviously want to download and cache the data before using it. In another case, it might not be needed to cache data, but only download it. To take this a step even further, we don’t necessarily need to download data before we want to cache it, especially in the case where the data comes from user input.

This is where we move into the next section talking about dependencies. From the example above, the two operations can be used throughout the different scenarios. This is a very simple example, but imagine components that we can re-use in much more complex flows with even more moving parts.

Complicated flows made simple with Operation
If we bring everything together mentioned thus far, we can very easily solve complex flows or scenarios in our codebase, just by utilising the power of operations and queues to their fullest.

EXAMPLE:
Let’s imagine we have an app that can show a user all of their different insurance policies. Life insurance, car insurance, home insurance etc. When a user opens the app, they need to see a summary of their different insurance policies. For the benefit of the example, each of these policies have their own request.

In summary, if we need to map out what needs to happen after the app is launched:

  • Request the user’s profile 

  • Cache user information

  • Request a summary of the user’s life insurance detail

  • Request a summary of the user’s car insurance detail

  • Request a summary of the user’s home insurance detail


As we can see, there is quite a lot of things that need to happen just after launching the application, but we can have quite a simple solution for this with operations.

To easily map out how we can include these in operations, and set up their dependencies:

With each level, we can see that one operation is dependent on the other. For example: Home insurance is dependent on the user’s profile request.

The real power here is that each product loads asynchronously on the user’s profile. With each operation that eventually finishes, we can show the summarised view of that data on the user’s profile, and that is even without the Home insurance operation having to know anything about the Car insuranceoperation.

NOTE:

Depending on your architecture, an operation can be dependent on an operation in another queue. So your solution doesn’t have to bear in mind making use of just one queue. For example You can have your Home insurance operation kicked off on one queue, being dependent on the Request user profile operation living within a completely different queue.

For an Operation example from the image above, we’d have a CarInsuranceOperation. There are certain times and places within the application we’d want to reload the user’s car insurance data. For instance if the user is planning to add a new car to his/her insurance profile. With this operation having its own set of rules it is dependent on, we can easily run this operation at any given moment, as long as it is a new instance. This is so easy to accomplish with operations. Depending on your architecture, you’d have an operation, add its dependencies, and fire them off on a queue. We’ve made this easy for us by making use of an OperationTask:

class CarInsuranceOperationTask: OperationTask {

    var operation: Operation {
        let op = CarInsuranceOperation()
        op.addDependency(UserProfileOperation())

        return op
    }
}

As we can see from the example above. The CarInsuranceOperationTask class conforms to OperationTask, which is a protocol that we’ve identified and can optionally use if an object needs to fire off an operation and its dependencies on a queue:

protocol OperationTask {

    var queue: OperationQueue { get }

    var operation: Operation { get }

    func start()
}

The OperationTask protocol does have a default implementation for queue, which just returns a new OperationQueue. There is also a default implementation for start(), which then adds the operation and its dependencies to a queue and kicks off the queue.

With this approach, we can easily, without any hassle, initialise the CarInsuranceOperationTask and just call start:

task.start()

Observe AsynchronousOperation state changes
We have seen how to design, which seems like a fairly simple solution by utilising the best of Operation and OperationQueue to solve complex technical problems. That is great and all, but I want to let my application know when my operation is done, and in some cases know if it failed and why.

We’ve accomplished this by utilising the KVC and KVO compliance of the Operation class. Let’s see what this would look like, by adding onto the AsynchronousOperation class:

class AsynchronousOperation: Operation {
    // …

    private var observers: [StateObservable] = []

    init(observers: [StateObservable]? = nil) {
        super.init()
        self.observers.forEach({ self.addObserver($0) })
    }

    // ...
}

We’ve added a new init to AsynchronousOperation where you can optionally pass in an array of observers. Each observer conforming to the new protocol StateObservable:

public protocol StateObservable: AnyObject {

    var observingStates: [OperationState] { get }

    func operation(_ operation: Operation, didChangeState state: OperationState, withError error: Error?)
}

In short, every object that is passed through as an observer will receive feedback when the operation moves into the observing state via the operation:didChangeState:withError: function. This function will be called for each state the observer is observing.

Now to get to the implementation of the observing states. You can add an observer to the operation after it has been initialised as well, but this is at your own risk. There is a risk that the observer you’re adding is trying to observe a state on the operation that has already passed.

For each observer added to the operation, the addObserver(_ observer: StateObservable) function is called:

func addObserver(_ observer: StateObservable) {
        let existingStates = self.observers.flatMap({ $0.observingStates })

        observer.observingStates.forEach({
            if !existingStates.contains($0) {
                self.addObserver(self, forKeyPath: $0.state, options: .new, context: nil)
            }
        })

        self.observers.append(observer)
    }

The operation itself observes any new changes to the key path of any of the state properties. The state changes the operation observe will only be on the different states being observed by all the observers added to the operation.

When state is transferred to a new state, the operation will receive the state change via the override of:

override open func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        guard let path = keyPath, let state = OperationState.state(with: path) else {
            return
        }

        DispatchQueue.main.async() {
            self.observers(observing: state).forEach { (observer) in
                if let errorReportable = self as? ErrorReportable {
                    observer?.operation(self, didChangeState: state, withError: errorReportable.error)
                } else {
                    observer?.operation(self, didChangeState: state, withError: nil)
                }
            }
        }
    }

The operation will then in turn call operation:didChangeState:withError: for each observer added to the operation, observing the state in question.

One last thing to mention is the ErrorReportable protocol:

public protocol ErrorReportable {

    var error: Error? { get }
}

Any AsynchronousOperation subclass can optionally conform to this protocol. When the task assigned to the operation completes with an error, the operation will move into a Finish state and the operation will then transfer the error through this protocol when calling operation:didChangeState:withError:.


Conclusion


Operation and OperationQueue has a lot of potential. It can be used in different cases and different scenarios. From synchronous to asynchronous operations. We can mix and match these types of operations that can work with or without each other to ensure we have a simple solution to what seems like a complex problem. 

We can easily re-use components embedded within an Operation anywhere in our codebase.

The logic for a single task/command is abstracted into an operation, making it easier to change later as well as making it future proof.

With Operation being an Abstract class, it is in your power to model your operations that meets your needs, all while maintaining the built in convenience of Operation.

Learn more

Explore our services

Our services are design to bring your idea to life.
Explore our digital services to learn more.
Our services are design to bring your idea to life. Explore our digital services to learn more.

Our Services

Our Services

Our Services

get started

Bring your ideas to life

We'd love to hear about your project and how we can help bring your ideas to life.

Let's Chat

© 2024 Glucode. All rights reserved.

get started

Bring your ideas to life

We'd love to hear about your project and how we can help bring your ideas to life.

Let's Chat

© 2024 Glucode. All rights reserved.

get started

Bring your ideas to life

We'd love to hear about your project and how we can help bring your ideas to life.

Let's Chat

© 2024 Glucode. All rights reserved.