swiftiosdev

Truly Asynchronous Operation subclass

How to implement true, tell-when-download-finishes Operation subclass

I’m a big fan of OperationQueue / Operation APIs. They are very simple use, easy to understand and have the simplest possible way to declare inter-dependency between various tasks. Plus everything is thread-safe. Tip-top!

The gist of the Operation lies in its state; it can be either:

Operation is considered complete when it gets into the third state where it wants to get as soon as possible. Which is not immediately obvious when the work you give to the Operation is an asynchronous block, like this:

final class NetworkOperation: Operation {
	typealias Callback = (Data?, URLResponse?, Error?) -> Void

	let url: URL
	let callback: Callback

	init(url: URL, callback: @escaping Callback) {
		self.url = url
		self.callback = callback
		super.init()
	}

	override func main() {
		let task = URLSession.shared.dataTask(with: url, completionHandler: callback)
		task.resume()
	}
}

This looks ok and will likely work in most cases. However, the use of Operation here is superfluous, with no benefit. The trouble is that instance of NetworkOperation implemented like this is instantly marked as finished, before a single byte is downloaded. The entire work of the operation is to create the task and start it which is done in nanosecond or whatever. If you have a queue of such Operations, they will all be executed instantly, not one network fetch after another (which is probably what you wanted).

What you need is for the NetworkOperation to be marked finished only when URLSessionDataTask’s completion handler executes:

URLSession.shared.dataTask(with: url) {
	[weak self] data, response, error in

	self?.callback(data, response, error)

	//	mark as Finished NOW!
}

True AsyncOperation

Hence the first key step in creation of true asynchronous subclass – one where Operation is done only when its asynchronous block has finished executing – is to control when isFinished changes to true.

The second key is to understand that isExecuting / isFinished are two separate state properties. There is no single state property but instead a group of Operation properties (isReady, isExecuting, isFinished) observed by OperationQueue.

Third key is to understand that observing/reporting mechanism between the OperationQueue / Operation is based on KVO. KVO is nothing more then a pair of simple NSObject methods:

And that’s it. Really! For each key (== a property of an object) that should be observable, you need to execute these two calls before and after its value is changed.

Armed with this knowledge, this is how you can do that in Swift:

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

		fileprivate var key: String {
			switch self {
			case .ready:
				return "isReady"
			case .executing:
				return "isExecuting"
			case .finished:
				return "isFinished"
			}
		}
	}

	private(set) public var state = State.ready {
		willSet {
			willChangeValue(forKey: state.key)
			willChangeValue(forKey: newValue.key)
		}
		didSet {
			didChangeValue(forKey: oldValue.key)
			didChangeValue(forKey: state.key)
		}
	}
	
	//...
}

Key thing: execute KVO calls for both old and new value of state.

Now it’s fairly easy to implement each observed property:

open class AsyncOperation: Operation {
	//...
	
	final override public var isAsynchronous: Bool {
		return true
	}

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

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

	final override public var isReady: Bool {
		return state == .ready
	}
}

The rest is easy. You can find AsyncOperation as part of my SwiftEssentials pack of snippets. It’s completely independent from the rest of the pack thus you can use it on your own.

I chose to forbid overriding both start() and main() and instead offer one single customization point for subclasses, called workItem():

/// You **should** override this method and start and/or do your async work here.
///	**Must** call `markFinished()` inside your override
///	when async work is done since operation needs to be mark `finished`.
open func workItem() {
	markFinished()
}

Here’s how NetworkOperation will then be implemented:

final class NetworkOperation: AsyncOperation {
	typealias Callback = (Data?, URLResponse?, Error?) -> Void

	let url: URL
	let callback: Callback

	required init() {
		fatalError("Use the `init(url:callback:)`")
	}

	init(url: URL, callback: @escaping Callback) {
		self.url = url
		self.callback = callback
		super.init()
	}

	//	The main part

	private(set) var task: URLSessionDataTask?

	override func workItem() {
		task = URLSession.shared.dataTask(with: url) {
			[weak self] data, response, error in

			self?.callback(data, response, error)

			self?.markFinished()
		}
		task?.resume()
	}
}

Note: for more advanced NetworkOperation implementation, see my Swift-Network library.