Groups

Encapsulate operations into a group

Operations makes it easy to decompose significant work into smaller chunks of work which can be combined together. This is always a good architectural practice, as it will make each component have reduced impact and increased testability. However, it can be unwieldy and diminish code re-use opportunities.

Therefore we can create more abstract notions of work with GroupOperation. Earlier in the guide we showed how to use the class directly; whereas here we will focus on subclassing GroupOperation.

The initializer

If all the child operations are known at compile time they can be configured during initialization. Configuration would be things such as setting dependencies, observers, conditions.

class LoginOperation: GroupOperation {

    class PersistLoginInfo: Operation { /* etc */ }
		class LoginSessionTask: NSURLSessionTask { /* etc */ }
    
    init(credentials: Credentials /* etc, inject known dependencies */) {

        // Create the child operations
        let persist = PersistLoginInfo(credentials)
        let login = URLSessionTaskOperation(task: LoginSessionTask(credentials))
        
        // Do any configuration or setup
        persist.addDependency(login)
        
        // Call the super initializer with the operations
        super.init(operations: [persist, login])
        
        // Configure any properties, such as name.
        name = "Login"
        
        // Add observers, conditions etc to the group
        addObserver(NetworkObserver())
        addCondition(MutualExclusive<LoginOperation>())
    }
}

The initialization strategy shown above is relatively simple, but shows some good practices. Creating and configuring child operations before calling the GroupOperation initializer reduces the complexity and increases the testability and readability of the class. Adding observers and conditions to the group inside its initializer sets the default and expected behavior which makes using the class easier. Remember that these can always be nullified by using ComposedOperation.

Adding child operations later

In some cases, the results of one operation are needed by a subsequent operation. We covered techniques to achieve this in Injecting Results which still apply here, but don't cover a critical scenario which is branching. A common usage might be to perform either operation Bar or Baz depending on Foo, which cannot be setup during initialization.

class FooBarBazOperation: GroupOperation {
    /* etc */

    override func operationDidFinish(operation: NSOperation, withErrors errors: [ErrorType]) {
        if errors.isEmpty && !cancelled, let foo = operation as? FooOperation {
           if foo.doBaz {
              addOperation(BazOperation())
           }
           else {
              addOperation(BarOperation())
           }
        }
    }
}

The above function does nothing in GroupOperation and exists purely to allow subclasses to perform actions when each the child operation finishes. Therefore a standard operation should

  1. Inspect and handle any errors
  2. Test the received operation to check that it is the expected instance. For example, optionally cast it to the expected type, and possible check if it equal to a stored property of the group.
  3. Call addOperation or addOperations to add more operations to the queue.

Using this technique the group will keep executing and only finish until all children, including ones added after the group started, have finished.

Cancelling

GroupOperation itself will already handle being cancelled correctly. However, in some cases, such as if an NSOperation is injected into the group at initialization, it is necessary to cancel the group if one of the children is cancelled. We can do this via a CancelledObserver

class LoginSessionTask: NSURLSessionTask { /* etc */ }

class LoginOperation: GroupOperation {

    class PersistLoginInfo: Operation { /* etc */ }
    
    let task: URLSessionTaskOperation
    
    init(credentials: Credentials, task: URLSessionTaskOperation) {
				self.task = task
        
        // Create the child operations
        let persist = PersistLoginInfo(credentials)

        // Do any configuration or setup
        persist.addDependency(task)

        // Call the super initializer with the operations
        super.init(operations: [persist, login])
        
        // Configure any properties, such as name.
        name = "Login"
        
        // Add observers, conditions etc to the group
        addObserver(NetworkObserver())
        addCondition(MutualExclusive<LoginOperation>())
    }
    
    override func execute() {
        task.addObserver(CancelledObserver { [weak self] _ in
            self?.cancel()
        })        
        super.execute()
    }
}

The above example is a modified from the original LoginOperation example, in that the URLSessionTask is injected to the group. This might be preferably to decouple the networking task, possibly to increase testability.

In this situation, it could be possible for the injected operation to be cancelled from outside the group, but logically that should cause the entire group to be cancelled. Therefore the group must observe this event. Although this could be configured after super.init is called, sometimes it is necessary to perform such configuration (which references self) after the initializer has finished.

For these situations, override execute but always call super.execute(). This is because the GroupOperation has critical functionality in its execute implementation (such as starting the queue).