Keeping application data synchronized across devices is a complex and daunting task. Fortunately, that is exactly why Apple built iCloud. In this Tuts+ Premium series, you will learn how iCloud works and how your applications can seamlessly share data across multiple devices.
Also available in this series:
- Working with iCloud: Introduction
- Working with iCloud: Key-Value Storage
- Working with iCloud: Document Storage
- Working with iCloud: Core Data Integration
In the second installment of this series, I showed you how to leverage iCloud's Key-Value Storage to keep small amounts of user data synchronized across multiple devices. Even though Key-Value Storage is easy to use and adopt, one of the downsides is the limitation it poses on the amount of data that can be stored. Remember that each application can only store 1MB of data and the number of key-value pairs is limited to 1024. As I mentioned at the end of the previous tutorial, our bookmarks manager might run into this limitation if any of our users wants to store a lot of bookmarks.
The solution to this problem is switching from iCloud Key-Value Storage to iCloud Document Storage for storing bookmarks. In terms of disk space, iCloud Document Storage is only limited by the user's iCloud storage. Knowing that a free account comes with 5GB of data storage, iCloud Document Storage is the ideal solution for our bookmark manager. In this tutorial, we will refactor the bookmarks manager from using iCloud Key-Value Storage to adopting iCloud Document Storage.
Before We Start
I would like to emphasize that it is important that you have read the first and second installment in this series before reading this piece. In this tutorial, we will refactor our bookmarks manager by building on the foundations we laid in part 2 of the series.
A Few Words About UIDocument
With the introduction of iCloud, Apple also made UIDocument
available to developers. The engineers at Apple created UIDocument
with iCloud in mind. UIDocument
makes iCloud integration for document based application much easier. However, it is important to note that UIDocument
does much more than providing an easy to use API for iCloud integration.
Document based applications have to deal with a number of challenges, such as (1) reading and writing data from and to disk without blocking the user interface, (2) saving data to disk at appropriate intervals, and (3) optionally integrating with iCloud. UIDocument
provides built-in solutions for these challenges.
Before we start working with UIDocument
, I want to make clear what UIDocument
is and what it isn't. UIDocument
is a controller object that manages one or more models just like UIViewController
controls and manages one or more views. UIDocument
doesn't store any data, but manages the model objects that hold the user's data. This is an important concept to understand, which will become clearer when we start refactoring our application to use UIDocument
.
Another important concept to understand is how read and write operations work when using UIDocument
. If you decide to use UIDocument
in your application, you don't need to worry about blocking the main thread when reading or writing data to disk. When using UIDocument
, the operating system will automatically handle a number of tasks for you on a background queue and ensure that the main thread remains responsive. I'd like to take a moment and explain each operation in more detail to give you a good understanding of the different moving parts that are involved.
Let's start with reading data from disk. The read operation starts by an open operation initiated on the calling queue. The open operation is initiated when a document is opened by sending it a message of openWithCompletionHandler:. We pass a completion handler that is invoked when the entire read operation is finished. This is an important aspect of the read and write operations. It can take a non-trivial amount of time to read or write the data from or to disk, and we don't want to do this on the main thread and block the user interface. The actual read operation takes place in a background queue managed by the operating system. When the read operation finishes, the loadFromContents:ofType:error: method is called on the document. This method sends UIDocument
the data necessary to initialize the model(s) it manages. The completion handler is invoked when this process finishes, which means that we can respond to the loading of the document by, for example, updating the user interface with the contents of the document.
The write operation is similar. It starts with a save operation initiated in the calling queue by sending saveToURL:forSaveOperation:completionHandler: to the document object. As with the read operation, we pass a completion handler that is invoked when the write operation finishes. Writing data to disk takes place in a background queue. The operating system asks UIDocument
for a snapshot of its model data by sending it a message of contentsForType:error:. The completion handler is invoked when the write operation finishes, which gives us the opportunity to update the user interface.
UIDocument
is a base class and is not meant to be used directly. We need to subclass UIDocument
and adapt it to our needs. In other words, we subclass UIDocument
so that it knows about our model and how to manage it. In its most basic form, subclassing UIDocument
only requires us to override loadFromContents:ofType:error: for reading and contentsForType:error: for writing.
Confused? You should be. Even though UIDocument
makes life much easier, it is an advanced class and we are dealing with a complex topic. However, I am convinced that you will get a good grasp of document based applications once we have refactored our application.
Before we continue, I want to make clear what our goals for this tutorial are. The primary goal is to refactor our application to make use of iCloud Document Storage instead of iCloud Key-Value Storage. This means that we will make use of UIDocument
and subclass it to fit our needs. In addition, we will create a custom model class for our bookmark that will be used and managed by the UIDocument
subclass.
Step 1: Configuring Entitlements
Currently, our application is configured to only use Key-Value Storage. To enable Document Storage, we first need to configure our application's entitlements. Open the Target Editor by selecting our application in the Project Navigator and select the only target from the targets list. In the Entitlements section, you should see iCloud Containers below iCloud Key-Value Store. The list next to iCloud Containers is empty at the moment. Click the plus button at the bottom of the list and you'll see that Xcode creates an iCloud container identifer for you that matches your appication's bundle identifier.
What is an iCloud container? As the name implies, it is a container in the user's iCloud data storage. By specifying one (or more) iCloud containers in our application's entitlements file, we tell the operating system what containers our application has access to.
Step 2: Creating the Bookmark Class
In the previous tutorial, we stored each bookmark as an instance of NSDictionary
, but this isn't a good solution for a document based application. Instead, we will create a custom bookmark class that will allow us to easily archive its data.
Create a new NSObject
subclass by choosing File from the menu, selecting New, and then File.... Select Cocoa Touch from the left panel and choose Objective-C class from the list of templates on the right. Give the class a name of Bookmark and make sure it is a subclass of NSObject. Specify where you want to save the new class and hit Create.
In our model's header file, we add the two properties of the bookmark model that we used in the previous tutorial, a name and a URL. Both properties are instances of NSString
. We also declare a designated initializer, which takes a name and a URL as its parameters. Finally, it is important to ensure that our Bookmark
class conforms to the NSCoding
protocol.
#import <Foundation/Foundation.h> @interface Bookmark : NSObject <NSCoding> { NSString *_name; NSString *_url; } @property (nonatomic, copy) NSString *name; @property (nonatomic, copy) NSString *url; - (id)initWithName:(NSString *)name andURL:(NSString *)url; @end
In our model's implementation file, we first define two constants for our bookmark's name and URL. This is a good pratice as it will minimize the chance that we mistype the keys that we will use shortly. Next, we implement our initialization method. This method is straightforward. We initialize our instance and assign the name and URL to our bookmark instance variables.
The important part is to implement the required methods to make the Bookmark
class conform to the NSCoding
protocol. If the NSCoding
protocol is new to you, then I encourage you to read the Archives and Serializations Programming Guide as this is an important topic for any Cocoa-Touch developer. The gist of it is that the NSCoding
protocol allows us to easily archive and unarchive instances of the Bookmark
class.
#import "Bookmark.h" #define kBookmarkName @"Bookmark Name" #define kBookmarkURL @"Bookmark URL" @implementation Bookmark @synthesize name = _name, url = _url; #pragma mark - #pragma mark Initialization - (id)initWithName:(NSString *)name andURL:(NSString *)url { self = [super init]; if (self) { self.name = name; self.url = url; } return self; } #pragma mark - #pragma mark NSCoding Protocol - (void)encodeWithCoder:(NSCoder *)coder { [coder encodeObject:self.name forKey:kBookmarkName]; [coder encodeObject:self.url forKey:kBookmarkURL]; } - (id)initWithCoder:(NSCoder *)coder { self = [super init]; if (self != nil) { self.name = [coder decodeObjectForKey:kBookmarkName]; self.url = [coder decodeObjectForKey:kBookmarkURL]; } return self; } @end
Step 3: Subclassing UIDocument
Subclassing UIDocument
isn't as difficult as you might think. As I mentioned earlier, all we need to do is override two methods and create a property for the bookmark model it will manage.
Create a new class just like we did for our Bookmark
class and give it a name of BookmarkDocument
. Ensure that it is a subclass of UIDocument
. In the header file of our UIDocument
subclass, we add a forward declaration for the Bookmark
class and we create a property for our bookmark model. Don't forget to synthesize accessors for this property.
#import <UIKit/UIKit.h> @class Bookmark; @interface BookmarkDocument : UIDocument { Bookmark *_bookmark; } @property (nonatomic, strong) Bookmark *bookmark; @end
In the implementation file we import the header file of the Bookmark class and define another constant to serve as the key for archiving and unarchiving the bookmark model. As I mentioned earlier, we only need to override two methods in the UIDocument subclass, (1) loadFromContents:ofType:error: and (2) contentsForType:error:. The first method will be invoked when we open a document whereas the second method will be invoked when a document is saved. Both methods are called by the operating system. We never need to call these mehods directly.
#import "BookmarkDocument.h" #import "Bookmark.h" #define kArchiveKey @"Bookmark" @implementation BookmarkDocument @synthesize bookmark = _bookmark;
Let me walk you through loadFromContents:ofType:error:. The first argument is contents of type id
. This can either be an instance of NSData
or NSFileWrapper
. The latter is only applicable when the application uses file packages. In our case, we can expect an NSData
instance. We first check whether the length of the NSData
instance is not equal to zero. We initialize an instance of NSKeyedUnarchiver
and supply it with the contents object. By decoding the data, we get an instance of the Bookmark
class back. This is the reason why the Bookmark
class conforms to the NSCoding
protocol. If the length of the NSData
instance is equal to zero, we initialize a new bookmark with default values for name and URL. Note that we return YES
at the end of the method.
- (BOOL)loadFromContents:(id)contents ofType:(NSString *)typeName error:(NSError *__autoreleasing *)outError { if ([contents length] > 0) { NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:contents]; self.bookmark = [unarchiver decodeObjectForKey:kArchiveKey]; [unarchiver finishDecoding]; } else { self.bookmark = [[Bookmark alloc] initWithName:@"Bookmark Name" andURL:@"www.example.com"]; } return YES; }
The contentsForType:error: method does the opposite. That is, we supply the data that needs to be written to disk. This data object is the so-called snapshot of our model data. We do this by initializing an instance of NSMutableData
and use this to initialize an instance of NSKeyedArchiver
. We can then archive our bookmark instance so that it can be written to disk. This method expects us to return an instance of NSData
and that is exactly what we do. Our UIDocument
subclass is now ready for us to use.
- (id)contentsForType:(NSString *)typeName error:(NSError *__autoreleasing *)outError { NSMutableData *data = [[NSMutableData alloc] init]; NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data]; [archiver encodeObject:self.bookmark forKey:kArchiveKey]; [archiver finishEncoding]; return data; }
Step 4: Refactoring View Controllers
There are four elements of our application that need to be refactored if we want to make use of iCloud Document Storage:
(1) loading, (2) displaying, (3) saving, and (4) deleting bookmarks. Let's start with loading bookmarks.
Before we take a look at the loadBookmarks method, we need to declare a private property, an instance of NSMetadataQuery
. It will become clear why we need to do this in just a few minutes. Don't forget to add two extra import statements to our view controller's implementation file, one for the Bookmark
class and one for the BookmarkDocument
class.
#import "ViewController.h" #import "Bookmark.h" #import "BookmarkDocument.h" #import "AddBookmarkViewController.h" @interface ViewController () { NSMetadataQuery *_query; } @property (nonatomic, strong) NSMetadataQuery *query; @end @implementation ViewController @synthesize bookmarks = _bookmarks; @synthesize tableView = _tableView; @synthesize query = _query;
Step 4A: Loading Bookmarks
Instead of NSDictionary
instances, our bookmarks array, the data source of our table view will contain instances of the BookmarkDocument
class. Let's take a look at the refactored loadBookmarks method. We start by initializing the bookmarks array. Next, we ask NSFileManager
for the URL of the iCloud container we will use to store our bookmarks. Don't be thrown off by the colorful name of this method. URLForUbiquityContainerIdentifier: accepts one argument, the identifier of the iCloud container we want to get access to. By passing nil as the argument, NSFileManager
will automatically select the first iCloud container that is declared in our application's entitlements file. Note that if you specify an iCloud container identifier, you need to provide the team identifier as well. The correct format is <Team Identifier>.<Container>.
- (void)loadBookmarks { if (!self.bookmarks) { self.bookmarks = [[NSMutableArray alloc] init]; } NSURL *baseURL = [[NSFileManager defaultManager] URLForUbiquityContainerIdentifier:nil]; if (baseURL) { self.query = [[NSMetadataQuery alloc] init]; [self.query setSearchScopes:[NSArray arrayWithObject:NSMetadataQueryUbiquitousDocumentsScope]]; NSPredicate *predicate = [NSPredicate predicateWithFormat:@"%K like '*'", NSMetadataItemFSNameKey]; [self.query setPredicate:predicate]; NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; [nc addObserver:self selector:@selector(queryDidFinish:) name:NSMetadataQueryDidFinishGatheringNotification object:self.query]; [nc addObserver:self selector:@selector(queryDidUpdate:) name:NSMetadataQueryDidUpdateNotification object:self.query]; [self.query startQuery]; } }
This method is not just for finding out where we can store our iCloud documents. By successfully calling this method, the operating system will extend our application's sandbox to include the iCloud container directory we specified. This means that we need to call this method before we can start reading or writing data to this directory. If you were to log the returned URL to the console, you would notice two oddities, (1) the returned URL for the iCloud container is a local URL (located on the device itself), and (2) this local URL does not live in our application's sandbox. The way iCloud works, is that we store the documents we want to store in iCloud in this local directory that NSFileManager
provides us. The iCloud daemon, running on our device in the background, will take care of the synchronization aspect of the documents and it will do this even if our application is not running.
Because the local URL lives outside of our application's sandbox, we need to invoke this method before we read or write to this directory. By invoking this method we ask the operating system for permission to read and write to this directory.
Let's continue dissecting the loadBookmarks method. We verify that the URL we get back from NSFileManager
is not equal to nil. The latter implies two important things, (1) we have a location that we can read from and write to and (2) iCloud is enabled on the device. The second point is especially important as not all devices will have iCloud enabled.
If NSFileManager
did indeed return a valid URL, we initialize an instance of NSMetadataQuery
and assign it to the instance variable we declared earlier. The NSMetadataQuery
class allows us to search the iCloud container for documents. After initializing an instance of NSMetadataQuery
, we specify the scope of our search. In our case, we will search in the Documents directory of our iCloud container since that is the location where we will store the bookmark documents. You can refine the query by setting a search predicate. If you are familiar with Core Data, then this isn't new to you. Our search will be simple, we will search for all the documents in the documents directory of our iCloud container, hence the asterisk in the predicate.
Before we start our query, it is important to realize that we shouldn't expect an immediate result back from our query. Instead, we will register our view controller as an observer for the NSMetadataQueryDidUpdateNotification
and NSMetadataQueryDidFinishGatheringNotification
notifications with our query instance as the sender. This means that we will be notified when our query has returned any results or when the results have been updated. Finally, we start the query.
It is important that we keep a reference to the query instance to prevent it from being released. This is the reason that our view controller keeps a reference to the query (as an instance variable) as long as the query is running.
Let's take a look at the queryDidFinish: and queryDidUpdate: notification callback methods to see how to handle the results of the query. Both methods pass the notification's sender object, the NSMetadataQuery
instance, to a convenience method, processQueryResults:. When we take a look at this method, we see that we first start by disabling updates for the query. This is important as the results of the query can receive live updates when changes take place and we need to prevent that as long as we are processing the query's results. Next, we remove all the objects from our bookmarks array and enumerate the results of the query. Each item in the results array is an instance of NSMetadataItem
, which contains the metadata associated with each bookmark document, including the file URL we need to open the document. We ask each metadata item for the file URL and initialize the respective document.
Note that initializing a bookmark document does not mean that we have loaded it from disk. Remember that this is done by sending our bookmark document a message of openWithCompletionHandler:. If the open operation is successful and the document is loaded, we add it to our array of bookmarks and display it in the table view. Finally, we need to remove our view controller as an observer as we no longer need to receive notifications at this point.
- (void)queryDidFinish:(NSNotification *)notification { NSMetadataQuery *query = [notification object]; // Stop Updates [query disableUpdates]; // Stop Query [query stopQuery]; // Clear Bookmarks [self.bookmarks removeAllObjects]; [query.results enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { NSURL *documentURL = [(NSMetadataItem *)obj valueForAttribute:NSMetadataItemURLKey]; BookmarkDocument *document = [[BookmarkDocument alloc] initWithFileURL:documentURL]; [document openWithCompletionHandler:^(BOOL success) { if (success) { [self.bookmarks addObject:document]; [self.tableView reloadData]; } }]; }]; [[NSNotificationCenter defaultCenter] removeObserver:self]; }
Step 4B: Displaying Bookmarks in the Table View
The code to display the bookmarks in our table view doesn't need to change much. Instead of fetching the correct NSDictionary
instance from the data source, we fetch an instance of the BookmarkDocument
class. Accessing the name and URL of the bookmark needs to be updated as well.
- (UITableViewCell *)tableView:(UITableView *)aTableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { static NSString *CellIdentifier = @"Cell Identifier"; UITableViewCell *cell = [aTableView dequeueReusableCellWithIdentifier:CellIdentifier]; if (cell == nil) { cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:CellIdentifier]; } // Fetch Bookmark BookmarkDocument *document = [self.bookmarks objectAtIndex:indexPath.row]; // Configure Cell cell.textLabel.text = document.bookmark.name; cell.detailTextLabel.text = document.bookmark.url; return cell; }
Step 4C: Saving A Bookmark
Head over to the save: method in AddBookmarkViewController. Instead of creating a NSDictionary
and sending it to our main view controller, we create a new Bookmark
instance. That's it. The rest is handled in the saveBookmark: method of our main view controller. Don't forget to add an import statement for the Bookmark
class.
- (IBAction)save:(id)sender { Bookmark *bookmark = [[Bookmark alloc] initWithName:self.nameField.text andURL:self.urlField.text]; [self.viewController saveBookmark:bookmark]; [self dismissViewControllerAnimated:YES completion:nil]; }
Saving a bookmark to our iCloud container is almost as simple as saving it to our application's sandbox. First, we ask NSFileManager
for the URL of our iCloud container as we did earlier. Based on that URL, we construct the correct URL for saving the bookmark document in the Documents directory of the iCloud container. The name of our document can be whatever we want it to be as long as its name is unique. I have chosen to use the bookmark's name and a timestamp. The user will not see this filename so the name is not that important. What is important is that it's unique.
We have a bookmark instance, but we don't have a bookmark document yet. We create a new bookmark document by initializing it with the URL we just constructed. Next, we assign our new bookmark to the document's bookmark property. Finally, we add the document to the bookmarks array and reload the table view.
Saving the document to the iCloud container is easy. We initiate the save operation I talked about earlier by sending our new document the message saveToURL:forSaveOperation:completionHandler:. The second parameter of this method specifies the type of save operation. In our case, we pass UIDocumentSaveForCreating
, which means creating a brand new bookmark document. Since we don't need to do anything special in our example, we simply log a message to the console when the save operation finishes.
You may have noticed that our method declaration changed slightly. We no longer pass an instance of NSDictionary
as the only argument. Instead we pass an instance of the Bookmark
class. Make sure that you update the header file to reflect this change. You will also need to add a foward class declaration to prevent any compiler warnings from showing up. These are tasks that you should be familiar with by now.
- (void)saveBookmark:(Bookmark *)bookmark { // Save Bookmark NSURL *baseURL = [[NSFileManager defaultManager] URLForUbiquityContainerIdentifier:nil]; if (baseURL) { NSURL *documentsURL = [baseURL URLByAppendingPathComponent:@"Documents"]; NSURL *documentURL = [documentsURL URLByAppendingPathComponent:[NSString stringWithFormat:@"Bookmark_%@-%f", bookmark.name, [[NSDate date] timeIntervalSince1970]]]; BookmarkDocument *document = [[BookmarkDocument alloc] initWithFileURL:documentURL]; document.bookmark = bookmark; // Add Bookmark To Bookmarks [self.bookmarks addObject:document]; // Reload Table View [self.tableView reloadData]; [document saveToURL:documentURL forSaveOperation:UIDocumentSaveForCreating completionHandler:^(BOOL success) { if (success) { NSLog(@"Save succeeded."); } else { NSLog(@"Save failed."); } }]; } }
Step 4D: Deleting Bookmarks
The last missing piece of the puzzle is the deletion of bookmarks. This is very easy compared to what we have done so far. We fetch the correct bookmark document from the data source and tell NSFileManager
to delete it from the iCloud container by passing the correct URL. This will also delete the document on iCloud. That's how easy it is. Of course, we also update the data source and the table view.
- (void)tableView:(UITableView *)aTableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath { if (editingStyle == UITableViewCellEditingStyleDelete) { // Fetch Document BookmarkDocument *document = [self.bookmarks objectAtIndex:indexPath.row]; // Delete Document NSError *error = nil; if (![[NSFileManager defaultManager] removeItemAtURL:document.fileURL error:&error]) { NSLog(@"An error occurred while trying to delete document. Error %@ with user info %@.", error, error.userInfo); } // Update Bookmarks [self.bookmarks removeObjectAtIndex:indexPath.row]; // Update Table View [aTableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade]; } }
Conclusion
In this tutorial, we have refactored our application to use iCloud Document Storage instead of iCloud Key-Value Storage. Even though we have refactored quite a bit of code, the process was fairly straightforward. We now have a document based application that is much more suitable for handling larger amounts of user data. The basic components are in place and extending the application requires little effort on our part. Note that our application is still a minimal implementation of a bookmarks manager. For example, there is no option to edit bookmarks. We also should do more error checking if we want to make our application into a robust and reliable application that is ready for release.
You also might have noticed that we have a number of methods that we don't need any longer. I have removed these methods from the final code sample and I suggest that you do the same. Removing obsolete code is also part of the refactoring process and essential when you want to keep your code maintainable.
Next Time
In this tutorial, we took a close look at iCloud Document Storage as well as the UIDocument
class. In the next tutorial, we will zoom in on UIManagedDocument
, a discrete subclass of UIDocument
designed to work closely with Core Data.
Comments