The final step in enabling background fetch for our application is to add the application(_:performFetchWithCompletionHandler:)
method. As explained before, this method will be called by iOS whenever we're awoken from the background and it allows us to perform an arbitrary amount of work. Once we're done performing our task, we must call the completion handler that iOS has passed in to this method.
Upon calling the completion handler, we will inform iOS about the results of our operation. It's important to correctly report this status because background fetch is intended to improve the user experience. If you falsely report to iOS that you have new data all the time so your app is woken up more often, you're actually degrading the user experience. You should trust the system to judge when your app is woken up. It's in the best interest of your users, their battery life, and ultimately your app to not abuse background fetch.
In order to efficiently implement background fetch, we will take the following steps:
The first two steps are not directly tied to implementing background fetch, but they do illustrate that an efficient background fetch strategy may involve refactoring some of your app's existing code. Remember, there is nothing wrong with refactoring old code to implement a new feature. Both the new feature and the old code will benefit from refactoring your app.
The data model we currently have associates movies with a single family member. This means that we could potentially store the same movie over and over again. When we were only storing data, this wasn't that big of a deal. However, now that we will query the movie database in a background fetch task, we need this task to be as fast as we possibly can make it. This means that we don't want to ask for the same movie twice. Also, we most certainly don't want to use the search API as we did before, we want to be as specific about a movie as we can.
To facilitate this, we will change the relationship between movies and family members to a many-to-many relationship. We'll also add a new field to the movie entity: remoteId
. This remoteId
will hold the identifier the movie database uses for the particular movie so we can use it directly in later API calls.
Open the model editor in Xcode and add the new property to Movie. Make sure that it's a 64-bit integer and that it's optional. Also, select the familyMember relationship and change it to a To Many relationship in the sidebar. It's also a good idea to rename the relationship to familyMembers since we're now relating it to more than one family member.
Great, the model has been updated. We still need to perform a bit of work though. Because we changed the name and nature of the family member relationship, our code won't compile. Make the following modifications to the managedObjectContextDidChange(_:)
method in MoviesViewController.swift
; the modified lines are highlighted:
if let updatedObjects = userInfo[NSUpdatedObjectsKey] as? Set<Movie> {
for object in updatedObjects {
if let familyMember = self.familyMember,
let familyMembers = object.familyMembers
where, familyMembers.contains(familyMember) {
tableView.reloadData()
break
}
}
}
There is just one more model-related change that we will need to incorporate. In order to efficiently search for an existing movie or create a new one, we will add an extension to the Movie
model. Create a new group called Models and add a new Swift file named Movie.swift
to it. Finally, add the following implementation to the file:
import CoreData extension Movie { static func find(byName name: String, orCreateIn moc: NSManagedObjectContext) -> Movie { let predicate = Predicate(format: "name ==[dc] %@", name) let request: NSFetchRequest<Movie> = Movie.fetchRequest() request.predicate = predicate guard let result = try? moc.fetch(request) else { return Movie(context: moc) } return result.first ?? Movie(context: moc) } }
The preceding code will query CoreData
for an existing movie with the same name. This matching will be done case insensitively because people might write the same movie name with different capitalization. If we aren't able to find a result, or if the results come back empty, we will create a new movie. Otherwise, we return the first and presumably the only result CoreData
has for our query. This wraps up the changes we need to make to our data layer.
Our existing code compiles, but it's not optimal yet. The MovieDBHelper
doesn't pass the movie's remote ID to its callback, and the movie insertion code doesn't use this remote ID yet. When the user wants to save a new movie, the app still defaults to creating a new movie even though we just wrote our helper method to either find or create a movie to avoid data duplication. We should update our code so the callback is called with the fetched remote ID.
Let's update the MovieDBHelper
first. Replace the following lines in the fetchRating(forMovie:callback:)
method; changes are highlighted:
typealias MovieDBCallback = (Int?, Double?) -> Void let apiKey = "YOUR_API_KEY_HERE" func fetchRating(forMovie movie: String, callback: MovieDBCallback) { guard let searchUrl = url(forMovie: movie) else { callback(nil, nil) return } let task = URLSession.shared().dataTask(with: searchUrl) { data, response, error in var rating: Double? = nil var remoteId: Int? = nil defer { callback(remoteId, rating) } guard error == nil else { return } guard let data = data, let json = try? JSONSerialization.jsonObject(with: data, options: []), let results = json["results"] as? [[String:AnyObject]], let popularity = results[0]["popularity"] as? Double, let id = results[0]["id"] as? Int else { return } rating = popularity remoteId = id } task.resume() }
These updates change the callback handler so it takes both the remote ID and the rating as parameters. We also add a variable to hold the remote ID, and we incorporate this variable into the callback. We also extract the ID from our JSON object.
With this code, the MovieDBHelper
is fully up to date. Let's update the movie creation code to wrap up the refactoring step. Update the following lines in the MoviesViewController'ssaveMovie(withName:)
method; changes are once again highlighted:
moc.persist { let movie = Movie.find(byName: name, orCreateIn: moc) if movie.name == nil || movie.name?.isEmpty == true { movie.name = name } familyMember.favoriteMovies = familyMember.favoriteMovies?.adding(movie) let helper = MovieDBHelper() helper.fetchRating(forMovie: name) { remoteId, rating in guard let rating = rating, let remoteId = remoteId else { return } moc.persist { movie.popularity = rating movie.remoteId = remoteId } } }
First, the preceding code either fetches an existing movie or creates a new one with the find(byName:orCreateIn:)
method we just created. Next, it checks whether or not the returned movie already has a name. If it doesn't have a name yet, we will set it. Also, if it does have a name, we can safely assume we were handed an existing movie object so we don't need to set the name. Next, the rating and ID are fetched and we set the corresponding properties on the movie object to the correct values in the callback.
This is all the code we needed to refactor to prepare our app for background fetch. Let's implement the actual updating feature now.