This is based on Operation and OperationQueue Tutorial in Swift article with some modifications.
Let’s start with this: your client wants an app which downloads images from the internet and applies some filters on them then shares them with other apps or saves in the camera roll. The easy way would be downloading them one by one then going back and applying the filter(s) one by one again, a bit painful. The other option would be using OperationQueue
which makes things easier for you a lot.
This is what happens in this scenario: You create two queues, one for downloading images, the other for applying a filter on the images; In the next step you add operations to the queues respectively. When the first queue, download queue, finishes its job, it sends a notification to inform the system it’s done with downloading, then the second queue will be filled with new operations to apply the filter on the images and a another notification will be send when the queue is finished with filters.
Apple defines an Operation class as such:
An abstract class that represents the code and data associated with a single task.
Before we proceed, it should be noted that an Operation can be either synchronous or asynchronous. By default they are synchronous, but since our operation, downloading from an internet location, is async we need to subclass it and set the isAsynchronous
true in addition to a little more modification.
class AsyncOperation: Operation {
override var isAsynchronous: Bool {
return true
}
private let _queue = DispatchQueue(label: "asyncOperationQueue", attributes: .concurrent)
private var _isExecuting: Bool = false
override var isExecuting: Bool {
set {
willChangeValue(forKey: "isExecuting")
_queue.async(flags: .barrier) {
self._isExecuting = newValue
}
didChangeValue(forKey: "isExecuting")
}
get {
return _isExecuting
}
}
var _isFinished: Bool = false
override var isFinished: Bool {
set {
willChangeValue(forKey: "isFinished")
_queue.async(flags: .barrier) {
self._isFinished = newValue
}
didChangeValue(forKey: "isFinished")
}
get {
return _isFinished
}
}
}
We need to override isExecuting
and isFinished
properties so the OperationQueue
will know when the operation is finished. Now AsyncOperation
can be used as the parent class for our Operation
subclasses. What we need now is an Operation
subclass which can asynchronously download an image from a URL. Before continuing our journey to the operations, let’s have another class which holds needed data for our images and call it PhotoRecord
. This class will need three properties, the URL of an image, the downloaded image and another property to keep track of its state:
enum OperationState {
case new, downloading, downloaded, filtered, failed
}
class PhotoRecord {
let url: URL
var image: UIImage? = nil
var state = OperationState.new
init(url: URL) {
self.url = url
}
}
Going back to operations, this subclass is responsible for downloading the image from a URL. For the download part a helper function is used, for the sake of simplicity. When you subclass an Operation
, the main
function will be called when it’s initialized. Notice how isExecuting
and isFinished
are used. In the main
function first it checks if the operation is not canceled; If not, it tells the parent queue that the operation has started and when download, which is asynchronous, is finish it tells the queue it’s done.
class DownloadOperation: AsyncOperation {
let photoRecord: PhotoRecord
init(_ photoRecord: PhotoRecord) {
self.photoRecord = photoRecord
}
override func main() {
if isCancelled {
return
}
isExecuting = true
isFinished = false
downloader(url: photoRecord.url) { (result) in
switch result {
case .failure:
self.photoRecord.state = .failed
case .success(let image):
self.photoRecord.state = .downloaded
self.photoRecord.image = image
}
self.isExecuting = false
self.isFinished = true
}
}
}
Similar to the DownloadOperation
, we have another operation which applies a monochrome effect to the images:
class ImageFilterOperation: AsyncOperation {
let photoRecord: PhotoRecord
init(_ photoRecord: PhotoRecord) {
self.photoRecord = photoRecord
}
override func main() {
if isCancelled {
return
}
isExecuting = true
isFinished = false
guard let currentCGImage = photoRecord.image?.cgImage else {
self.photoRecord.state = .failed
self.isExecuting = false
self.isFinished = true
return
}
let currentCIImage = CIImage(cgImage: currentCGImage)
let filter = CIFilter(name: "CIColorMonochrome")
filter?.setValue(currentCIImage, forKey: "inputImage")
filter?.setValue(CIColor(red: 0.65, green: 0.65, blue: 0.65), forKey: "inputColor")
filter?.setValue(1.0, forKey: "inputIntensity")
guard let outputImage = filter?.outputImage else { return }
let ciContext = CIContext()
if let cgimg = ciContext.createCGImage(outputImage, from: outputImage.extent) {
let processedImage = UIImage(cgImage: cgimg)
self.photoRecord.image = processedImage
self.photoRecord.state = .filtered
self.isExecuting = false
self.isFinished = true
}else{
self.photoRecord.state = .failed
self.isExecuting = false
self.isFinished = true
}
}
}
Now that we are done with operations, it’s time to add them to the queues. For each group of actions we need a separate queue and another property to keep track of operations, .done
, .failed
, etc. Let’s wrap these in a PendingOperations
class.
class PendingOperations {
lazy var downloadInProgress: [Int: Operation] = [:]
lazy var downloadQueue: OperationQueue = {
var queue = OperationQueue()
queue.name = "Download Queue"
return queue
}()
lazy var filteringInProgress: [Int: Operation] = [:]
lazy var filterQueue: OperationQueue = {
var queue = OperationQueue()
queue.name = "Filter Queue"
return queue
}()
}
“A queue that regulates the execution of operations.” Says Apple documents. OperationQueue
inherits from NSObject
, therefore it’s a KVO-complient class which helps us to know about the current state of the queue.
With this last morsel, we are done with the logic of the app. Now we have to put together the pieces.
The operation will start running when it’s added to the queue, so we need to create our DownloadOperation
‘s then add them to the DownloadQueue
. Now you can see how that state
is useful here; When we initialize a PhotoRecord
, its state
value is .new
, so we can track the state and run the appropriate operation .The whole code is not posted here to make it easier to read, but you can download it from github.
var photos = [PhotoRecord]()
listOfImages.append(URL.init(string: "https://picsum.photos/id/1/500/500")!)
listOfImages.append(URL.init(string: "https://picsum.photos/id/2/500/500")!)
listOfImages.append(URL.init(string: "https://picsum.photos/id/3/500/500")!)
for item in listOfImages {
let photo = PhotoRecord(url: item)
photos.append(photo)
}
The next step is creating a DownloadOperation
for each PhotoRecord
and add them to the DownloadQueue
and wait for the queue to finish. Afterwards, we should create ImageFilterQueue
and add ImageFilterOperation
objects to it and wait for it to finish its job. After this last step, we will have images which are fetched from URLs and modified with a monochrome filter, ready to be saved in the gallery, shared with other apps or just be show up on the screen.
func runQueues(){
for (index, item) in self.photos.enumerated() {
startOperations(for: item, at: index)
}
}
func startOperations(for photoRecord: PhotoRecord, at index: Int) {
switch (photoRecord.state) {
case .new:
startRetrieving(for: photoRecord, at: index)
case .downloaded:
startApplyingFilter(for: photoRecord, at: index)
default:
break
}
}
func startRetrieving(for photoRecord: PhotoRecord, at index: Int) {
guard pendingOperations.downloadInProgress[index] == nil else {
return
}
let download = DownloadOperation(photoRecord)
download.completionBlock = {
if download.isCancelled {
return
}
DispatchQueue.main.async {
self.pendingOperations.downloadInProgress.removeValue(forKey: index)
}
}
pendingOperations.downloadInProgress[index] = download
pendingOperations.downloadQueue.addOperation(download)
}
func startApplyingFilter(for photoRecord: PhotoRecord, at index: Int){
guard pendingOperations.filteringInProgress[index] == nil else {
return
}
let filter = ImageFilterOperation(photoRecord)
filter.completionBlock = {
if filter.isCancelled {
return
}
DispatchQueue.main.async {
self.pendingOperations.filteringInProgress.removeValue(forKey: index)
}
}
pendingOperations.filteringInProgress[index] = filter
pendingOperations.filterQueue.addOperation(filter)
}
We call runQueues()
and the whole process begins as described. But there is a missing important thing: how can we know when a queue is finishes its job? The answer is observing the appropriate key path which is named "operations"
and the sender object . To observe it, first we need to register an observer, in our case, two:
pendingOperations.downloadQueue.addObserver(self, forKeyPath: "operations", options: .new, context: nil)
pendingOperations.filterQueue.addObserver(self, forKeyPath: "operations", options: .new, context: nil)
Then override addObserver(_:forKeyPath:options:context:)
and listen for the right key path and object:
override func observeValue(forKeyPath keyPath: String?, of object: Any?,
change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if object as? OperationQueue == pendingOperations.downloadQueue && keyPath == "operations" {
if self.pendingOperations.downloadQueue.operations.isEmpty {
pendingOperations.downloadQueue.removeObserver(self, forKeyPath: "operations")
pendingOperations.filterQueue.addObserver(self, forKeyPath: "operations", options: .new, context: nil)
self.runQueues()
}
}else if object as? OperationQueue == pendingOperations.filterQueue && keyPath == "operations" {
if self.pendingOperations.filterQueue.operations.isEmpty {
pendingOperations.filterQueue.removeObserver(self, forKeyPath: "operations")
}
} else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
}
}
Here is the link to the complete project hosted on github.