Why You Should Use NSFetchedResultsController?
NSFetchedResultsController is a very useful class provided by the CoreData framework. It solves many performance issues you frequently run into while reading a large amount of data from database and displaying that data using a UITableview, UICollectionView or MKMapView. You should always use the fetched results controller unless you have a good reason not to. In this post, I would like to show you why using a fetched results controller is a good idea.
Consider an app that shows a list of news feed related to Apple products using a table view. We will call this app FeedLoader. When a row in feed table view is tapped, it shows more information about the feed in a detail view.
Initial Design #
We can approach the design of this app in multiple ways, but the one below will provide us a good context for discussing why not using a fetched results controller might not be a good idea.
Although the figure above has a lot of boxes, the design is not that complicated. I will walk you through it. Not wanting to create a God class that consumes every responsibility in the app, I have created multiple classes each responsible for one thing. The app is primarily responsible for following tasks:
- Download news feed
- Persist news feed locally
- Display news feed
- Download and cache images associated with news feed
I won’t explain every aspect of the FeedLoader app in this post. I will highlight only those that are relevant to the discussion of
NSFetchedResultsController
. I have tried to write the code for FeedLoader in a clean way. Hopefully, you will be able to read it with relative ease.
Download News Feed #
Let’s assume that there exists a news feed service that provides API for fetching news published between certain dates. When the user enters the feed list view, the process of downloading the latest news feed is initiated. Once the feed is downloaded, we persist them in a local database. That way we don’t need to make unnecessary calls to the news feed service when we want to show the feed that was already downloaded in the past.
To keep things simple, we will read feed data from a JSON file stored locally instead of downloading it from a remote feed service. We can accommodate this change in our design by creating a protocol named FeedFetcher
and a concrete class named FileFeedFetcher
that conforms to this protocol.
@protocol FeedFetcher <NSObject>
- (void)fetchFeedWithCompletionHandler:
(void(^)(id JSON, NSError *error))handler;
@end
@interface FileFeedFetcher : NSObject <FeedFetcher>
@end
@implementation FileFeedFetcher
- (void)fetchFeedWithCompletionHandler:
(void (^)(id JSON, NSError *error))handler
{
NSString *filePath = [[NSBundle mainBundle] pathForResource:@"feeds"
ofType:@"json"];
NSData *jsonData = [NSData dataWithContentsOfFile:filePath];
NSError *error = nil;
NSArray *jsonObjects = [NSJSONSerialization JSONObjectWithData:jsonData
options:0
error:&error];
if (jsonObjects == nil) {
NSLog(@"Unable to parse feeds JSON: %@", error.description);
handler(nil, error);
}
else {
handler(jsonObjects, nil);
}
}
@end
In the future, when we actually download a list of feeds from a remote server, we can easily create a class named ServerFeedFetcher
that conforms to the FeedFetcher
protocol as well. We can then inject a server feed fetcher instead of a file feed fetcher without having to change anything in FeedManager
.
@implementation ObjectConfigurator
- (FeedManager *)feedManager {
FeedManager *feedManager = [[FeedManager alloc] init];
[feedManager setFeedFetcher:[[ServerFeedFetcher alloc] init]];
// ...
return feedManager;
}
@end
ObjectConfigurator
provides a light-weight framework for injecting dependencies. You can checkout thecreate_basic_setup
branch from FeedLoader repo to see its full implementation. Through out this blog post, I will ask you to checkout a specific branch so that you can see various stages of the app in code as we change its design iteratively.
Persist News Feed Locally #
Once the FeedManager
has retrieved feed JSON (either from a remote service or a local JSON file), it tells FeedBuilder
to build Feed
objects from that JSON. FeedBuilder
creates a new Feed
object only if one with the same source URL already doesn’t exist in database. When the builder is done creating Feed
objects, it will tell FeedDataManager
to persist them in a local database. Finally, it returns the Feed
objects back to the FeedManager
.
FeedDataManager
provides a layer of abstraction on top of Core Data. Instead of passing around the managed object context to any class that might need to query feed related data from the database and sprinkling complex fetch request code all over the place, we can simply ask FeedDataManager
to perform specific data related task for us. For example, checking whether a Feed
object with a specific source URL already exists or not.
Display News Feed #
FeedListViewController
uses custom cells of type FeedCard
to display the feed information in a UITableView. Rather than creating the cells itself, the list view controller delegates that task to an object that conforms to the FeedListDataSource
protocol. (A default implementation of the FeedListDataSource
protocol is provided in FeedListDefaultDataSource
class). The data source accepts an array of Feed
objects. When the table view needs to display a cell at a specific indexpath, it asks the data source to provide that cell. The data source then creates a FeedCard
instance, populates it with feed data and gives it to the tableview.
Download and Cache Feed Image #
All information required to display a news feed is persisted in database except the image data. Feed
objects store image URL but not the actual image data. A third party library called SDWebImage is used to asynchronously download images from a remote server and cache them locally on disk. SDWebImage
adds a category to the UIImageView class. As a result, we can initiate the loading and caching of images by sending setImageWithURL:placeholderImage:
message directly to the image view included in a feed card. Images are loaded only when needed, i.e., when a feed card is visible to the user.
UIImage *placeholder = [UIImage imageNamed:@"feedPlaceholderImage"];
[self.feedImageView sd_setImageWithURL:[NSURL URLWithString:feed.imageUrl]
placeholderImage:placeholder];
Caching Feed Data #
At this point, if you run the app second time, you will notice that the feed list is empty. The issue here is that FeedBuilder
doesn’t create a new Feed
object if one with the same source URL already exists in database.
@implementation FeedBuilder
- (NSArray *)feedsFromJSON:(NSArray *)JSON {
NSMutableArray *feedsArray = [NSMutableArray array];
for (NSDictionary *feedDict in JSON) {
if ([self feedExistsInDatabase:feedDict]) {
continue;
}
Feed *feed = [self.feedDataManager newFeed];
[self fillInDetailsForFeed:feed
fromDictionary:feedDict];
[feedsArray addObject:feed];
}
[self.feedDataManager saveData];
return [feedsArray copy];
}
@end
First time we run the app, there are no Feed
objects stored in the database at all. Therefore, the builder creates five new feed objects and returns them to the feed manager. On the second run, the feed builder doesn’t create any because all five feed items in feeds.json
file have already been persisted in the database. As a result, the builder returns an empty array to the feed manager. To fix this issue, we need to return locally saved feeds if the builder doesn’t create new ones. We could easily accomplish that by adding following code to buildFeedsFromJSON:
method in FeedManager
:
if ([feeds count] == 0) {
feeds = [self.feedDataManager allFeed];
}
However, that seems like a bit of a hack to me. Also, if you look inside feeds.json
file, there are only five feed items in there. Therefore, loading them all into the feed list table view won’t cause any performance issues for now. In reality the number of news feed that needs to be displayed will be much higher than five. Although, Apple engineers have done a fantastic job of optimizing UITableView
, we still need to take care of the issue of not loading too many table view rows up-front by ourselves. What we need here is a proper caching mechanism that will not only retrieve feeds from a local database if we are unable to fetch new ones from a remote server, but also is smart enough to not load too many rows unless the user needs them. This approach will make scrolling through the table view quite smooth. It will also prevent the overall memory footprint from increasing unnecessarily. Let’s start building a foundation for that caching mechanism. The first thing we need to do is create FeedCache
class.
@class FeedBuilder;
@class FeedDataManager;
@interface FeedCache : NSObject
- (void)setFeedBuilder:(FeedBuilder *)feedBuilder;
- (void)setFeedDataManager:(FeedDataManager *)feedDataManager;
- (NSArray *)cachedFeed;
- (NSArray *)addFeedToCacheFromJSON:(NSArray *)feedJSON;
@end
@interface FeedCache ()
@property (nonatomic) FeedBuilder *feedBuilder;
@property (nonatomic) FeedDataManager *feedDataManager;
@end
@implementation FeedCache
- (NSArray *)cachedFeed {
return [self.feedDataManager allFeedSortedByKey:@"publishedDate"
ascending:NO];
}
- (NSArray *)addFeedToCacheFromJSON:(NSArray *)feedJSON {
NSMutableArray *feeds = [NSMutableArray array];
for (NSDictionary *feedDict in feedJSON) {
if ([self feedExistsInDatabase:feedDict]) {
continue;
}
Feed *feed = [self.feedDataManager newFeed];
[self.feedBuilder fillInDetailsForFeed:feed
fromJSON:feedDict];
[feeds addObject:feed];
}
[self.feedDataManager saveData];
[self sortFeedsByPublishedDate:feeds];
return [feeds copy];
}
- (BOOL)feedExistsInDatabase:(NSDictionary *)feed {
return [self.feedDataManager feedExistsWithSourceUrl:feed[@"url"]];
}
- (void)sortFeedsByPublishedDate:(NSMutableArray *)feeds {
[feeds sortUsingComparator:^NSComparisonResult(Feed *feed1, Feed *feed2) {
// The minus sign here has the effect of reversing
// order from ascending to descending.
return -[feed1.publishedDate compare:feed2.publishedDate];
}];
}
@end
When asked for cached feeds, FeedCache
returns all feeds in the database stored thus far. It also allows us to add new feeds into the cache. It essentially means taking the feed JSON, creating new instances of Feed
objects from that JSON, saving them into the database and returning them back to the caller. Now FeedManager
can delegate the task of building Feed
objects from JSON to FeedCache
instead of FeedBuilder
.
- (void)buildFeedsFromJSON:(NSArray *)JSON {
NSArray *newlyFetchedFeeds =
[self.feedCache addFeedToCacheFromJSON:JSON];
if ([self.delegate respondsToSelector:
@selector(feedManager:didReceiveFeeds:)])
{
[self.delegate feedManager:self
didReceiveFeeds:newlyFetchedFeeds];
}
}
Now when FeedManager
is asked to fetch the latest feeds, it can simply return the ones in cache while the new ones are still being fetched.
- (NSArray *)fetchFeeds {
[self.feedFetcher fetchFeedWithCompletionHandler:
^(id JSON, NSError *error)
{
if (error) {
NSLog(@"Unable to fetch feeds.");
}
else {
[self buildFeedsFromJSON:JSON];
}
}];
return [self.feedCache cachedFeed];
}
The original problem of empty list when we run the app second time can now be solved by displaying the cached feeds when user enters the feed list view.
- (void)viewDidLoad {
// ...
[self fetchFeeds];
}
- (void)fetchFeeds {
NSArray *feeds = [self.feedManager fetchFeeds];
[self.dataSource setFeeds:feeds];
[self.feedTableView reloadData];
}
Now that we can fetch feeds incrementally, we need to give the table view data source an ability to add new feeds to its collection.
@protocol FeedListTableDataSource
<UITableViewDataSource, UITableViewDelegate>
- (void)setFeeds:(NSArray *)feeds;
- (void)addFeeds:(NSArray *)feeds;
@end
@implementation FeedListTableDefaultDataSource
// ...
- (void)addFeeds:(NSArray *)feeds {
[self setFeeds:[self.feeds arrayByAddingObjectsFromArray:feeds]];
}
@end
Finally, we need to insert new rows into the table view when we receive new feeds from a remote server.
@implementation FeedListViewController
//...
- (void)feedManager:(FeedManager *)manager
didReceiveFeeds:(NSArray *)feeds
{
NSLog(@"Feed manager did receive %ld feeds", (long)[feeds count]);
[self.dataSource addFeeds:feeds];
[self insertFeedsIntoTableView:feeds];
}
- (void)insertFeedsIntoTableView:(NSArray *)feeds {
if ([feeds count] > 0) {
NSMutableArray *newRows = [NSMutableArray array];
for (int i = 0; i < [feeds count]; i++) {
[newRows addObject:[NSIndexPath indexPathForRow:i
inSection:0]];
}
[self.feedTableView
insertRowsAtIndexPaths:newRows
withRowAnimation:UITableViewRowAnimationTop];
}
}
@end
With the addition of caching, our initial design has evolved a bit as shown in figure below.
Following diagram shows feed data flow from end-to-end with caching in place.
You can checkout the
add_caching
branch from FeedLoader repo to see all the caching related code.
Now that we have a basic caching mechanism in place, we can make it more robust by providing a way to specify how many feeds we want to fetch rather than returning everything in the database. We can also remove feeds that are not visible to the user from the table view data source. Currently, it holds onto each feed added to its collection which is quite inefficient because the collection could grow infinitely as we keep fetching new feeds. As you can see the to-do list for a robust cache keeps growing and growing as we add new performance related requirements.
This is where the fetched results controller provided by Apple comes very handy. In addition to solving all caching related problems we have encountered so far, the fetched results controller also provides following:
- Monitor changes to Feed objects in database.
- Cache the results of its computation so that if the same data is subsequently re-displayed, the work does not have to be repeated.
NSFetchedResultsController to the Rescue #
Rather than going down a rabbit hole of implementing our own caching solution, let’s give NSFetchedResultsController
a try. First thing we need to do is expose the managed object context used by our Core Data stack via the data manager. The fetched results controller interacts with Core Data directly via the managed object context. Ideally, I would have liked not to leak this implementation detail related to database to our business logic but if we want to use the fetched results controller we have no other choice.
You can checkout the
replace_caching_implementation_with_fetched_results_controller
branch from FeedLoader repo if you would like to follow along with the code listed in this section.
@interface FeedDataManager : NSObject
// ...
@property (nonatomic, readonly) NSManagedObjectContext
*managedObjectContext;
@end
Now instead of manually adding the new list of feed returned by FeedManager
to the data source, we can simply give it a fetched results controller.
@implementation FeedListViewController
- (void)viewDidLoad {
// ...
[self.dataSource
setFetchedResultsController:self.fetchedResultsController];
}
@end
We also need to modify the FeedListTableDataSource
protocol to take the fetched results controller instead of manually setting the feed array.
@protocol FeedListTableDataSource
<UITableViewDataSource, UITableViewDelegate>
- (void)setFetchedResultsController:
(NSFetchedResultsController *)controller;
@end
When we create a fetched results controller, we need to give it a fetch request that contains details such as which entity to fetch and how many of them to fetch in a batch. We can also tell it to sort the result by certain attributes such as publishedDate
. Here is the code that creates a fetched results controller:
- (NSFetchedResultsController *)fetchedResultsController {
if (_fetchedResultsController == nil) {
NSFetchRequest *fetchRequest =
[NSFetchRequest fetchRequestWithEntityName:@"Feed"];
NSSortDescriptor *sortByPublishedDate =
[[NSSortDescriptor alloc] initWithKey:@"publishedDate"
ascending:NO];
fetchRequest.sortDescriptors = @[sortByPublishedDate];
fetchRequest.fetchBatchSize = 10;
NSManagedObjectContext *context =
self.feedDataManager.managedObjectContext;
_fetchedResultsController =
[[NSFetchedResultsController alloc]
initWithFetchRequest:fetchRequest
managedObjectContext:context
sectionNameKeyPath:nil
cacheName:nil];
_fetchedResultsController.delegate = self;
}
return _fetchedResultsController;
}
Finally, we need to implement the delegate methods the fetched results controller will call when the managed objects in Core Data are created, updated or deleted.
- (void)controllerWillChangeContent:(NSFetchedResultsController *)controller {
[self.feedTableView beginUpdates];
}
- (void)controller:(NSFetchedResultsController *)controller
didChangeObject:(id)anObject
atIndexPath:(NSIndexPath *)indexPath
forChangeType:(NSFetchedResultsChangeType)type
newIndexPath:(NSIndexPath *)newIndexPath
{
UITableView *tableView = self.feedTableView;
switch(type) {
case NSFetchedResultsChangeInsert:
[tableView insertRowsAtIndexPaths:@[newIndexPath]
withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[tableView deleteRowsAtIndexPaths:@[indexPath]
withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeUpdate:
[tableView reloadRowsAtIndexPaths:@[indexPath]
withRowAnimation:UITableViewRowAnimationNone];
break;
case NSFetchedResultsChangeMove:
[tableView deleteRowsAtIndexPaths:@[indexPath]
withRowAnimation:UITableViewRowAnimationFade];
[tableView insertRowsAtIndexPaths:@[newIndexPath]
withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
- (void)controller:(NSFetchedResultsController *)controller
didChangeSection:(id )sectionInfo
atIndex:(NSUInteger)sectionIndex
forChangeType:(NSFetchedResultsChangeType)type
{
switch(type) {
case NSFetchedResultsChangeInsert:
[self.feedTableView
insertSections:[NSIndexSet indexSetWithIndex:sectionIndex]
withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[self.feedTableView
deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex]
withRowAnimation:UITableViewRowAnimationFade];
break;
default:
break;
}
}
- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller {
[self.feedTableView endUpdates];
}
When the fetched results controller is about to start sending change notifications, it will call the controllerWillChangeContent
delegate method. We need to prepare the feed table view for updates in that method by calling beginUpdates. If a Feed
managed object is created, deleted or updated in database, we insert, delete or update a table view row respectively. When all current change notifications provided by the fetched results controller have been received, we need to tell the table view to process all pending updates by calling endUpdates in controllerDidChangeContent:
delegate method.
Now we can remove all traces of FeedCache
class and let the fetched results controller take over. Here is how the design looks after replacing our own caching implementation with the fetched results controller:
The end-to-end feed data flow has also changed with introduction of the fetched results controller.
Conclusion #
You might be thinking that this blog post has been a giant waste of time and I agree with you. In the beginning I mentioned that you should always use a fetched results controller unless you have a good reason not to. I could have stopped there and moved on with our lives, but I went on and on to show you why it’s a good idea to do that. We started with a code base that didn’t have any caching mechanism in place. We implemented our own caching and then replaced it with a NSFetchedResultsController
instance.
In one of the projects I worked on, I came very close to implementing functionalities already provided by NSFetchedResultsController
. I thought I would share that experience with you so that you won’t waste time trying to reinvent the wheel like I did.
The full code for FeedLoader app is available on GitHub.