Asynchronous Operations in Swift

This is the first part of two dealing with how to handle preloading of SpriteKit game assets in Swift using an OperationQueue with asynchronous Operation objects.

Asynchronous Operations in Swift
Johan Steen
by Johan Steen

This is the first part of two dealing with how to handle preloading of SpriteKit assets in Swift using an OperationQueue with asynchronous Operation objects.

Using queues in programming is a great way to manage time consuming tasks or execute a batch of tasks. An Operation can be either a synchronous or an asynchronous task, where asynchronous tasks are especially handy when dealing with time consuming tasks in the background without blocking the main thread and UI.

When it come to game development, a queue is a perfect candidate for handling preloading of game assets so everything that needs to be in memory is processed and ready to go when the level starts.

SpriteKit offers preloading methods right out of the box. For SKTexture and SKTextureAtlas we have methods like

  • preload(_:withCompletionHandler:)
  • preloadTextureAtlases(_:withCompletionHandler:)

That's two methods which are both asynchronous and which calls the provided closure when the textures have finished loading.

We are going to use an OperationQueue to handle the preloading of all level assets in the game via the asynchronous methods offered in SpriteKit. In this first part we'll walk through how to work with Operations and in the second part we'll tie it together with Entities and GameplayKit.

Operation Queue

The operation queue is easy enough to use where we simply create an instance of the OperationQueue. The queue will keep executing any Operations we add to it until all Operations have finished.

let queue = OperationQueue()
let operation1 = SomeOperation()
queue.addOperation(operation1)

let operation2 = SomeOtherOperation()
queue.addOperation(operation2)

The above adds two operations to the queue and the queue then executes them on one or multiple threads. And that's how simple it can be sometimes. If the operations are very fast and the queue is simply used to manage what tasks that needs to be run, the OperationQueue can block the main thread and wait for the queue to finish.

queue.addOperations([operation1, operation2], waitUntilFinished: true)

Operation

When creating an operation object to handle a task the Operation class should never be used directly, but always be used by implementing a subclass that handles the logic, or use the system-defined subclass BlockOperation (We'll use BlockOperation for our "CompletionOperation" in part two).

The most basic form of an Operation subclass only requires the implementation of the main() method.

class SomeOperation: Operation {
    override func main() {
        // Execute some task...
    }
}

Which will create a synchronous operation. If a synchronous operation will be able do the job then the above will do the trick. A completion handler can be provided that runs when the Operation is done.

Asynchronous

In our case we are going to run asynchronous tasks in the Operation. Remember, the SKTexture and SKTextureAtlas preloader methods are asynchronous. That makes it slightly more complex, but not by much.

When creating an asynchronous operation, overriding main() won't do the trick anymore, we need to handle a few more parts of the Operation by ourselves. Many of the properties in the Operation object must also be KVO compliant (key-value observing). These are the required methods and properties to override.

  • start()
  • isAsynchronous
  • isExecuting
  • isFinished

The start() method is where the asynchronous task will be executed. When start() is called also the isExecuting property must be updated to notify any observing objects (Which most likely will be the OperationQueue object) via KVO notifications for the isExecuting key path. This can be done via property observers combined with willChangeValue(forKey:) and didChangeValue(forKey:).

When the task in the operation has finished, KVO notifications needs to be triggered once again, this time for both isExecuting and isFinished key paths. By that the Operation is done and can be removed from the queue.

Properties with KVO notifications

The easiest way to handle properties and generate notifications for an Operation is to use an enum for the operation state and have a property observer send out the appropriate KVO notifications when the state changes.

We'll begin by setting up this enum

  enum State: String {
      case isReady
      case isExecuting
      case isFinished
  }

There we have the states the Operation can have. isReady is the initial state before the Operation has started the execution. With a property observer we can then simply generate the KVO notifications when changing the state of the operation.

var state: State = .isReady {
    willSet(newValue) {
        willChangeValue(forKey: state.rawValue)
        willChangeValue(forKey: newValue.rawValue)
    }
    didSet {
        didChangeValue(forKey: oldValue.rawValue)
        didChangeValue(forKey: state.rawValue)
    }
}

In willSet() we inform the operation queue that the property for the current state and the next state will change. In didSet we then inform the operation queue that the property for the previous state and the new state has changed.

KVO mission accomplished.

Finally, sending out the KVO notifications are not enough, we also need to be able to actually check the properties for their current state. We can implement that very easily by using computed properties combined with the state property.

override var isExecuting: Bool { state == .isExecuting }
override var isFinished: Bool { state == .isFinished }

We also need to indicate that the Operation is asynchronous.

override var isAsynchronous: Bool { true }

And at last we need to implement the start() method where the task is executed.

override func start() {
    state = .isExecuting

    someObject.someTaskWithCompletionHandler { [unowned self] in
        self.state = .isFinished
    }
}

In start() we first change the state of the operation to isExecuting and then move on to start the asynchronous task. In the task's completion handler we change the state to isFinished. This would also be the place to do additional things like updating a Progress object.

The someObject accessed in the start() method should most likely be passed to the Operation via the class initializer.

There we have it, the Operation is implemented. It can start, finish and then be removed from the Operation Queue. Job done.

Cancellation

While support for cancellation in Operation is voluntary, it is encouraged to implement it so tasks won't keep running unnecessary in the background if the OperationQueue has been canceled. As it is very easy to implement, we can just as well add it.

First of all we need to tweak the implementation of the isFinished property.

override var isFinished: Bool {
    if isCancelled && state != .isExecuting { return true }
    return state == .isFinished
}

Apart from checking if the state is isFinished as we did before we now also check if the Operation has been canceled. A canceled operation that has not started should have isFinished return true. But in the case it has actually started despite being canceled, we'll still return false until the execution is done.

In the start() method we can then simply have a guard that checks if the Operation has been canceled and return without starting the task if that would be the case.

override func start() {
    guard !isCancelled else { return }

    // Execute the task...
}

Full implementation

Let's take everything that we have talked about above and put it together into a full implementation, including cancellation support.

class PreloadOperation: Operation {
    enum State: String {
        case isReady
        case isExecuting
        case isFinished
    }

    var state: State = .isReady {
        willSet(newValue) {
            willChangeValue(forKey: state.rawValue)
            willChangeValue(forKey: newValue.rawValue)
        }
        didSet {
            didChangeValue(forKey: oldValue.rawValue)
            didChangeValue(forKey: state.rawValue)
        }
    }

    override var isAsynchronous: Bool { true }
    override var isExecuting: Bool { state == .isExecuting }
    override var isFinished: Bool {
        if isCancelled && state != .isExecuting { return true }
        return state == .isFinished
    }

    override func start() {
        guard !isCancelled else { return }

        state = .isExecuting

        someObject.someTaskWithCompletionHandler { [unowned self] in
            self.state = .isFinished
        }
    }
}

We're good to go!

Conclusion

That concludes the first part of asynchronous preloading of game assets using an OperationQueue in Swift.

In the next part we'll deal with how to know when all Operations in the queue has finished running as well as implementing the actual preloader task for GameplayKit entities.

Feel free to continue to the next part, Asynchronous Preloading in SpriteKit with Swift.

Discuss this article

The conversation has just started. Comments? Thoughts?

If you'd like to discuss any of the topics covered in this article, then head over and hang out on Discord.

You can also get in touch with me, and keep up with what I'm up to, on Twitter or Mastodon.

Sign up to the newsletter to get occasional emails about my game development.