Creating reusable tasks with Operations

We just explored dispatch queues and how we can use them to schedule tasks that need to be performed on a different thread. You saw how this speeds up code and how it avoids blocking the main thread. In this section, we're going to take this all one step further. The first reason for this is because our asynchronous work would be better organized if we have an object that we can schedule for execution rather than a closure. Closures pollute our code and they are much harder to reuse.

The solution to this is using an Operation instead of a closure. And instead of queueing everything in a dispatch queue, we should queue our Operation instances on an OperationQueue. The OperationQueue and the DispatchQueue are not quite the same. An operation queue can schedule operations on one or more dispatch queues. This is important because of the way in which operations work.

Using an operation queue, we can execute our operations in parallel or we can opt to run them serially. We can also specify dependencies for our operations. This means that we can make sure that certain operations are completed before the next operation is executed. The operation queue will manage the dispatch queues needed to make everything happen and it will execute the operations in the order in which they become ready to execute.

This section will briefly cover some of the basic concepts of operations. If you're looking to learn more about using operations in interesting and advanced ways, make sure to check out Apple's Advanced NSOperations talk from WWDC 2015.

Note

All code for this talk is presented in Swift 2.0 so you'll need to make an attempt to translate this code to Swift 3.0 somehow, but it's definitely worth a watch.

Using Operations in your apps

Let's take a deep dive into Operations and refactor the background fetch code from the FamilyMovies app so it uses operations. To do this, we're going to create two Operation subclasses, one that fetches data and updates the movie object and one that calls the completion handler.

Our setup will use a single OperationQueue onto which we push all of the instances of our fetch operation subclass, and one operation that calls the background fetch completion handler. The completion operation will have all of the fetch operations as its dependency.

Whenever you create an OperationQueue instance, you can specify the amount of concurrent operations that can be executed on the queue. If you set this to zero, the operations will be executed in the order in which they become ready. An operation is considered ready when all preconditions for the operation are met. A great example of this is dependencies. An operation with dependencies is not ready to execute until all of the operations that it depends on are completed. Another example is exclusivity. You can set an operation up in such a way that you make sure that only one operation of the current type is running at any given time. An operation like that is not ready unless there is no operation with the same type running.

If you set the maximum number of concurrent operations to a higher number, it's not guaranteed that this amount is actually used. Imagine setting the maximum amount to a thousand and you place two thousand operations on the queue. It's not likely that you will actually see one thousand operations being executed in parallel. The system ultimately decides how many operations will run at the same time, but it's never more than your maximum value. Apart from the fact that a parallel queue will execute more tasks at the same time, no operations are started before they are ready to execute, just like on a serial queue.

The first thing we'll do is simply create an operation queue that we can use to push our operations onto. Replace the implementation of application(_:performBackgroundFetchWithCompletionHandler:) in the AppDelegate with the following:

func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { 
     
    let queue = OperationQueue() 
     
    let fetchRequest: NSFetchRequest<Movie> = Movie.fetchRequest() 
    let managedObjectContext = persistentContainer.viewContext 
    guard let allMovies = try? managedObjectContext.fetch(fetchRequest) else { 
        completionHandler(.failed) 
        return 
    } 
} 

This implementation creates a queue for us to push our operations on. We also fetch the movies like we did before because ultimately, we'll create an update operation for each movie. Let's create an implementation for this operation now. Create a new group in the Project navigator and name it Operations. In it, you should add a file called UpdateMovieOperation.swift.

Every custom operation you create should subclass the Operation base class. The Operation class implements most of the glue and boilerplate code involved in managing and executing operations and queues. In this file, we'll need to implement a few mandatory, read-only variables that indicate the state of our operation. To update other objects about state changes, we must use iOS' Key Value Observing (KVO) pattern. This pattern enables other objects to receive updates when a certain key in an object changes. You'll see how to fire the KVO notifications from your operation soon. Let's define our variables and initializer first. Add the following starting implementation for UpdateMovieOperation.

import Foundation 
 
class UpdateMovieOperation: Operation { 
    override var isAsynchronous: Bool { return true } 
    override var isExecuting: Bool { return _isExecuting } 
    override var isFinished: Bool { return _isFinished } 
     
    private var _isExecuting = false 
    private var _isFinished = false 
    var didLoadNewData = false 
     
    let movie: Movie 
     
    init(movie: Movie) { 
        self.movie = movie 
    } 
} 

You'll immediately notice that we override a couple of variables. These are the read-only variables that were mentioned earlier. The isExecuting and isFinished variables simply return the value of two private variables that we'll mutate appropriately later. Furthermore, we keep track of whether new data was loaded and we have a property and initializer to attach a movie to our operation. So far, this operation isn't very exciting. Let's look at the actual heart of the operation. Add these methods to your operation class:

override func start() { 
    super.start() 
     
    willChangeValue(forKey: #keyPath(isExecuting)) 
    _isExecuting = true 
    didChangeValue(forKey: #keyPath(isExecuting)) 
     
    let helper = MovieDBHelper() 
    helper.fetchRating(forMovieId: movie.remoteId) { [weak self] id, popularity in 
        defer { 
            self?.finish() 
        } 
         
        guard let popularity = popularity, 
            let movie = self?.movie, 
            popularity != movie.popularity 
            else { return } 
         
        self?.didLoadNewData = true 
         
        movie.managedObjectContext?.persist { 
            movie.popularity = popularity 
        } 
    } 
} 
 
func finish() { 
    willChangeValue(forKey: #keyPath(isFinished)) 
    _isFinished = true 
    didChangeValue(forKey: #keyPath(isFinished)) 
} 

Apple's guidelines state that we should override the start() method and initiate our operation from there. You'll note that we call the superclass implementation first, this is because the superclass takes care of several under-the-hood tasks that must be performed in order to make operations work well. Next, we use willChangeValue(forKey:) and didChangeValue(forKey:) to fire the KVO notifications mentioned earlier. Note that we don't actually change the value for the key but rather the private property that reflects the value of the key we've changed.

Next, we use our code from before to fetch and update the movie. A defer block is used to call the finish() method, regardless of how our network request went. By using defer instead of manually calling finish() when appropriate, we can't forget to call finish() if our code changes. The finish() method makes sure that the operation queue is notified about the operation's completion by firing the corresponding KVO notifications.

We should create another operation that calls the background fetch completion handler. This operation should loop through all of its dependencies, check whether it's a movie update operation, and if it is, it should check if new data was loaded. After doing this, the completion handler should be called with the corresponding result and finally, the operation should finish itself. Create a new file in the Operations folder and name it BackgroundFetchCompletionOperation. Add the following implementation:

import UIKit 
 
class BackgroundFetchCompletionOperation: Operation { 
    override var isAsynchronous: Bool { return true } 
    override var isExecuting: Bool { return _isExecuting } 
    override var isFinished: Bool { return _isFinished } 
     
    var _isExecuting = false 
    var _isFinished = false 
     
    let completionHandler: (UIBackgroundFetchResult) -> Void 
     
    init(completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { 
        self.completionHandler = completionHandler 
    } 
     
    override func start() { 
        super.start() 
         
        willChangeValue(forKey: #keyPath(isExecuting)) 
        _isExecuting = true 
        didChangeValue(forKey: #keyPath(isExecuting)) 
         
        var didLoadNewData = false 
         
        for operation in dependencies { 
            guard let updateOperation = operation as? UpdateMovieOperation 
                else { continue } 
 
            if updateOperation.didLoadNewData { 
                didLoadNewData = true 
                break 
            } 
        } 
         
        if didLoadNewData { 
            completionHandler(.newData) 
        } else { 
            completionHandler(.noData) 
        } 
         
        willChangeValue(forKey: #keyPath(isFinished)) 
        _isFinished = true 
        didChangeValue(forKey: #keyPath(isFinished)) 
    } 
} 

The implementation for this operation is pretty similarly constructed as the movie update operation. We initialize the operation with the completion handler that was passed to the background fetch method in AppDelegate and we call it once we've determined if new data was fetched. Let's see how all this comes together by updating the background fetch logic in AppDelegate. Add the following code to the application(_:performFetchWithCompletionHandler:) method, right after fetching the movies:

let completionOperation = BackgroundFetchCompletionOperation(completionHandler: completionHandler) 
 
for movie in allMovies { 
    let updateOperation = UpdateMovieOperation(movie: movie) 
    completionOperation.addDependency(updateOperation) 
     
    queue.addOperation(updateOperation) 
} 
 
queue.addOperation(completionOperation) 

This code is a lot more readable than what was in its place before. First, you create the completion operation. Next, we create an update operation for each of the movies and we add this operation as a dependency for the completion operation. We also add the update operation to the queue. Finally, we add the completion operation itself to the queue as well and that's all we need to do.

All of the movie update operations will automatically start executing simultaneously and once they're all done, the completion operation becomes ready to execute. Once this happens, the completion operation will start running and the completion handler will be called.

Even though operations involve a bit more boilerplate code in terms of managing execution state, you do end up with code that makes use of your operations cleanly. We've only explored dependencies for now but if you study Apple's advanced NSOperations video that was mentioned earlier, you'll find that you can do really powerful, complex, and amazing things with operations. However, even in a basic form, operations can greatly improve your code and reduce complexity.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.221.154.151