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 dependencies.
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 1,000, and you place 2,000 Operations on the queue. It's not likely that you will actually see 1,000 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 OperationQueue 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 onto. 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 the 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 basic 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.
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 whether 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 to 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 whether 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, we create the completion operation. Next, we create an update operation for each movie 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.