Date
Nov. 21st, 2024
 
2024年 10月 21日

Post: NSCoding Tutorial for iOS: How To Save Your App Data

NSCoding Tutorial for iOS: How To Save Your App Data

Published 12:09 Sep 07, 2010.

Created by @ezra. Categorized in #Programming, and tagged as #iOS.

Source format: Markdown

Table of Content
This bug doesn't look so scary on disk!

This bug doesn't look so scary on disk!

There are many different ways to save your data to disk in iOS – raw file APIs, Property List Serialization, SQLite, Core Data, and of course NSCoding.

For apps with heavy data requirements, Core Data is often the best way to go.

However, for apps with light data requirements, NSCoding with NSFileManager can be a nice way to go because it’s such a simple alternative.

In this NSCoding tutorial, we’re going to take the “Scary Bugs” app that we’ve been working on in a simple project and extend it so that it saves the app’s data to disk.

Along the way, we’ll cover how you can use NSCoding to persist your normal app data, and use NSFileManager to store large files for efficiency.

Implementing NSCoding

NSCoding is a protcol that you can implement on your data classes to support encoding and decoding your data into a data buffer, which can then be persisted to disk.

Implementing NSCoding is actually ridiculously easy – that’s why I find it so helpful to use sometimes. Watch how quickly we can bang this out!

First make the following mod to ScaryBugData.h:

// Modify @interface line to include the NSCoding protocol
@interface ScaryBugData : NSObject <NSCoding> {

Then add the following to the bottom of ScaryBugData.m:

#pragma mark NSCoding
 
#define kTitleKey       @"Title"
#define kRatingKey      @"Rating"
 
- (void) encodeWithCoder:(NSCoder *)encoder {
    [encoder encodeObject:_title forKey:kTitleKey];
    [encoder encodeFloat:_rating forKey:kRatingKey];
}
 
- (id)initWithCoder:(NSCoder *)decoder {
    NSString *title = [decoder decodeObjectForKey:kTitleKey];
    float rating = [decoder decodeFloatForKey:kRatingKey];
    return [self initWithTitle:title rating:rating];
}

That’s it! Note that we have to implement two methods: encodeWithCoder, and initWithEncoder. I like to think of encodeWithCoder as “encode”, and initWithCoder as “decode”.

In encodeWithCoder, we’re passed in a NSCoder object, and we can call helper methods on it to encode various pieces of data. It has methods such as encodeObject, encodeFloat, encodeInt, etc. Each time we encode something, we provide a key that we can use to decode the object in initWithCoder later.

The nice thing about the way NSCoder is set up is that it makes it easy to modify your objects over time as you release new versions of your app and still be able to support older files.

One of the most common things you’ll do in your apps is add new fields to your objects. When you have a new field, you can encode it in encodeWithCoder, and in decodeWithCoder you can try decoding it, but check if it’s nil (for objects) or 0 (for numerics), which means “that key was not found”. And if it’s not found, you can provide a default value.

If 0 or nil are actually valid values, you can also take the approach of serializing a version number along with your data and use that to understand what should be there or not (in fact that’s the approach I usually take).

If you want to read up more on NSCoding, I recommend this great article by Mike Ash on implementing NSCoding.

Loading and Saving To Disk

We’ve implemented the code to encode and decode our data, but we still need some code to load and save that to disk.

We’re going to approach this by adding a new initializer to our ScaryBugDoc that takes a directory name to look in for data to load. However for efficiency purposes we won’t load the data in right away – we will load it the first time it’s accessed, by implementing the “get data” method.

We’ll also provide a method that we can call to save the document back out to disk, a method to delete the document, and an initializer that doesn’t take a document path at all, for the case where we are making a new file so will need a new directory to save the file in.

You may wonder why we’re taking a directory path as an argument rather than just a single file path. This is because we’re going to eventually have three pieces of data we’re saving in this directory – the encoded ScaryBugData, the thumbnail image, and the full image. We’ll cover why we’re taking this approach and how to save the images out later on.

Ok so let’s do this! Make the following changes to ScaryBugDoc.h:

// Inside @interface
NSString *_docPath;
 
// After @interface
@property (copy) NSString *docPath;
- (id)init;
- (id)initWithDocPath:(NSString *)docPath;
- (void)saveData;
- (void)deleteDoc;

Next we need to make some changes to ScaryBugDoc.m. There’s several bits here that we need to discuss, so let’s go over it bit by bit.

1) Add initializer and bookkeeping code

// At top of file
#import "ScaryBugDatabase.h"
#define kDataKey        @"Data"
#define kDataFile       @"data.plist"
 
// After @implementation
@synthesize docPath = _docPath;
 
// Add to dealloc
[_docPath release];
_docPath = nil;
 
// Add new methods
- (id)init {
    if ((self = [super init])) {
    }
    return self;
}
 
- (id)initWithDocPath:(NSString *)docPath {
    if ((self = [super init])) {
        _docPath = [docPath copy];
    }
    return self;
}

First, we include a file we haven’t written yet – ScaryBugDatabase.h. We’ll be writing that next, so don’t worry about it for now.

Next we make some definitions for the key we’re going to save out data out to and the filename, which we’ll use a bit later.

We synthesize our new property and remember to release it in dealloc.

Finally, we write our two new initializers. Regular init does just about nothing, and initWithDocPath sets our docPath instance variable based on the value passed in.

Note that for both init and initWithTitle, docPath will be nil. When docPath is nil, that means to us that this document hasn’t been saved to disk yet, so when we save we’ll need to find a new location to save it in.

2)Write helper function to create document path

- (BOOL)createDataPath {
 
    if (_docPath == nil) {
        self.docPath = [ScaryBugDatabase nextScaryBugDocPath];
    }
 
    NSError *error;
    BOOL success = [[NSFileManager defaultManager] createDirectoryAtPath:_docPath withIntermediateDirectories:YES attributes:nil error:&error];
    if (!success) {
        NSLog(@"Error creating data path: %@", [error localizedDescription]);
    }
    return success;
 
}

When we go to save our document, the first thing we’re going to want to do is create the directory to save it in if it doesn’t exist already.

To find an unused directory, we’re going to use the ScaryBugDatabase helper class which we’ll write next to figure that out.

Once we have an unused directory, we can use the createDirectoryAtPath method in NSFileManager to create it for us. It will return success if it creates the directory, or if it already exists.

3) Override data property to load from disk

- (ScaryBugData *)data {
 
    if (_data != nil) return _data;
 
    NSString *dataPath = [_docPath stringByAppendingPathComponent:kDataFile];
    NSData *codedData = [[[NSData alloc] initWithContentsOfFile:dataPath] autorelease];
    if (codedData == nil) return nil;
 
    NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:codedData];
    _data = [[unarchiver decodeObjectForKey:kDataKey] retain];
    [unarchiver finishDecoding];
    [unarchiver release];
 
    return _data;
 
}

When someone tries to access the data property, we’re going to check if we have it loaded into memory – and if so go ahead and return it. But if not, we’ll load it from disk!

The first thing we do is create the full path to the file by appending the kDataFile constant (from the top of our file – “data.plist”) to our directory name, and then loading the data from disk with NSData’s initWithContentsOfFile method.

Next we need to unserialize the data. The way you do that is by using the NSKeyedArchiver class, passing in the NSData buffer, and then call decodeObjectForKey. Behind the scenes, it will detect that the data buffer contains your ScaryBugDoc, call the initWithCoder method on that to initialize the class, and return you the new object.

4) Add method to save to disk

- (void)saveData {
 
    if (_data == nil) return;
 
    [self createDataPath];
 
    NSString *dataPath = [_docPath stringByAppendingPathComponent:kDataFile];
    NSMutableData *data = [[NSMutableData alloc] init];
    NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data];
    [archiver encodeObject:_data forKey:kDataKey];
    [archiver finishEncoding];
    [data writeToFile:dataPath atomically:YES];
    [archiver release];
    [data release];
 
}

In saveData we do the reverse of the above. First we call our createDataPath method to create the directory if it doesn’t already exist, then we serialize the data with NSKeyedArchiver and write it out to disk.

5) Add method to delete doc

- (void)deleteDoc {
 
    NSError *error;
    BOOL success = [[NSFileManager defaultManager] removeItemAtPath:_docPath error:&error];
    if (!success) {
        NSLog(@"Error removing document path: %@", error.localizedDescription);
    }
 
}

Last more thing to add. Now that we’re actually saving data out to disk, if the user deletes a bug from the table view, we’ll also need to delete the data from the disk, so we need a method for that:

Very simple stuff here – just a call to removeItemAtPath, which will remove the entire directory plus its contents.

Ok we’re getting pretty close! We’re just missing two pieces now: the ScaryBugDatabase object, and integration into the rest of our app.

Scary Bug Database

We’ve already seen one thing we need the Scary Bug Database object to do – figure out an available directory name for a new bug document.

But there’s one other thing we need it to do too – figure out what bug document directories are there on startup, and loop through creating ScaryBugDocs and calling initWithDocPath for each.

So let’s put these together! Right click on Model, go to “Add\New File…”, and add a new Cocoa Touch Class\Objective-C class\Subclass of NSObject. Name the new file ScaryBugDatabase.m, and click Finish.

Then make the following mod to ScaryBugDatabase.h:

// Add to bottom of file
+ (NSMutableArray *)loadScaryBugDocs;
+ (NSString *)nextScaryBugDocPath;

So we’re just creating two static methods – one to load the scary bug documents (and return them in a NSMutableArray), and the one we were using earlier to get the next available path.

Now let’s implement these, bit by bit.

1) Write helper function to get document root

// Add to top of file
#import "ScaryBugDoc.h"
 
// After @implementation, add new function
+ (NSString *)getPrivateDocsDir {
 
    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES);
    NSString *documentsDirectory = [paths objectAtIndex:0];
    documentsDirectory = [documentsDirectory stringByAppendingPathComponent:@"Private Documents"];
 
    NSError *error;
    [[NSFileManager defaultManager] createDirectoryAtPath:documentsDirectory withIntermediateDirectories:YES attributes:nil error:&error];
 
    return documentsDirectory;
 
}

The other two functions are going to need a function to tell them where to save the bug documents subdirectories.

One of the most common places to save data for your apps is in the “Documents” directory for your app. You can get the path to this with NSSearchPathForDirectoriesInDomains, passing in NSDocumentDirectory as the value.

However, we don’t want to save here because in the next tutorial in our series, we’re going to extend this app even further to support the new File Sharing feature on iOS 4 and the iPad. The way file sharing works is it shows anything you save in the documents directory to the user, but for reasons we’ll get into later we don’t want the user seeing these directories as-is. According to an Apple doc on Storing Private Data, the recommended solution is to save your app’s data in a subdirectory of Library, so that’s what we’re doing here.

So – we construct the path (which will be /Library/Private Documents), and then create the directory if it doesn’t exist, returning the new path.

2) Write helper function to load all existing documents

+ (NSMutableArray *)loadScaryBugDocs {
 
    // Get private docs dir
    NSString *documentsDirectory = [ScaryBugDatabase getPrivateDocsDir];
    NSLog(@"Loading bugs from %@", documentsDirectory);
 
    // Get contents of documents directory
    NSError *error;
    NSArray *files = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:documentsDirectory error:&error];
    if (files == nil) {
        NSLog(@"Error reading contents of documents directory: %@", [error localizedDescription]);
        return nil;
    }
 
    // Create ScaryBugDoc for each file
    NSMutableArray *retval = [NSMutableArray arrayWithCapacity:files.count];
    for (NSString *file in files) {
        if ([file.pathExtension compare:@"scarybug" options:NSCaseInsensitiveSearch] == NSOrderedSame) {
            NSString *fullPath = [documentsDirectory stringByAppendingPathComponent:file];
            ScaryBugDoc *doc = [[[ScaryBugDoc alloc] initWithDocPath:fullPath] autorelease];
            [retval addObject:doc];
        }
    }
 
    return retval;
 
}

Here we get our documents directory, and use contentsOfDirectoryAtPath to get a list of files/directories inside. We loop through them all, looking for files/directories ending with the “scarybug” extension, which we’re using to represent a directory with our scary bug data. When we find them, we construct the full path, then create a ScaryBugDoc with the initWithDocPath initializer and add it to our list.

3) Write helper function to get next available document path

+ (NSString *)nextScaryBugDocPath {
 
    // Get private docs dir
    NSString *documentsDirectory = [ScaryBugDatabase getPrivateDocsDir];
 
    // Get contents of documents directory
    NSError *error;
    NSArray *files = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:documentsDirectory error:&error];
    if (files == nil) {
        NSLog(@"Error reading contents of documents directory: %@", [error localizedDescription]);
        return nil;
    }
 
    // Search for an available name
    int maxNumber = 0;
    for (NSString *file in files) {
        if ([file.pathExtension compare:@"scarybug" options:NSCaseInsensitiveSearch] == NSOrderedSame) {
            NSString *fileName = [file stringByDeletingPathExtension];
            maxNumber = MAX(maxNumber, fileName.intValue);
        }
    }
 
    // Get available name
    NSString *availableName = [NSString stringWithFormat:@"%d.scarybug", maxNumber+1];
    return [documentsDirectory stringByAppendingPathComponent:availableName];
 
}

Here we basically loop through our documents directory looking for “scarybug” directories, which should be in the format “#.scarybug”. We loop through to find the max number we currently have, and then return just one more than that number. That’s just one easy way to get a unique name for us to use.

Ok we’re almost done! All we need to do now is integrate this with the rest of the app and try it out!

Try it Out!

This is pretty easy. Open up ScaryBugsAppDelegate.m and make the following changes:

// Add to top of file
#import "ScaryBugDatabase.h"
 
// Comment out the code to load the sample ScaryBugDoc data in the beginning of application:didFinishLaunchingWithOptions, and replace it with the following:
NSMutableArray *loadedBugs = [ScaryBugDatabase loadScaryBugDocs];
RootViewController *rootController = (RootViewController *) [navigationController.viewControllers objectAtIndex:0];
rootController.bugs = loadedBugs;

That handles loading of bugs. But we need to save bugs too! Make the following changes to EditBugViewController.m:

// In titleFieldValueChanged
[_bugDoc saveData];
 
// In rateView:ratingDidChange
[_bugDoc saveData];

We go ahead and just save the doc immediately every time the title or rating changes. Since our data is so ridiculously small, this is fine for performance, but if you have a larger data set you might want to save periodically in the background instead, or when the user terminates the app or enters the background, etc.

Lastly, make the following mod to RootViewController.m:

// In tableView:commitEditingStyle:forRowAtIndexPath, before removing the object from the bugs array:
ScaryBugDoc *doc = [_bugs objectAtIndex:indexPath.row];
[doc deleteDoc];

That’s it! Compile and run your app. When it starts up, you’ll see your table view with no bugs in it. However, that’s fine because no bugs are on disk yet. You can double check this by going to Run\Console, and scrolling to the bottom: it will print out where it’s looking for bug documents:

Loading bugs from /Users/rwenderlich/Library/Application Support/
   iPhone Simulator/4.0.2/Applications/
   D13C7304-25FB-4EDC-B23D-62A084AD90B4/Library/Private Documents

If you open up that directory in Finder, you’ll see it empty of course. So go ahead and create a bug in the app, and when you do you’ll see a new directory pop up in your Private Docs directory:

Scary Bug Document on Disk

Also, you may find it interesting to double click on the data.plist file to see what that looks like as well:

Object Encoded with NSCoding and NSKeyedArchiver as a Property List

So as you can see since we’re using NSCoding + NSKeyedArchiver, our data is being saved out in a property list that is semi-human readable, which can be kind of nice for debugging sometimes.

Now shut down Scary Bugs, and start it back up again. (Make sure you fully shut it down and restart, not just enter into the background!) When you do, you’ll see that it correctly loads the bug you created back in from disk, but you’ll notice that it didn’t save the image.

Bug With No Picture

That of course is because we didn’t save it! So let’s take care of that next.

Saving and Loading Images

Ok so we have this big image and thumbnail image that we need to save to disk. How should we do that?

You may think that we should just encode it with NSCoding just like we did with the ScaryBugDoc data above. NSCoding does support this behavior (just convert it to NSData with UIImagePNGRepresentation and then use encodeObject:forKey, and then call decodeObjectForKey / UIImage initWithData on the other side), but it’s usually not the best way to go.

This is because you usually don’t want to save huge pieces of data inside your main document if you can avoid it.

Note how we load up all of the documents into a table view at startup, which access their data property and hence decodes the ScaryBugData from disk. If we included the images in this data, on startup it would load all of the big images into memory at once too. This would mean it would take longer for our app to start up, and we’d have a higher memory cost because we’d have to keep all of the images in memory at a time.

But we don’t need to do that, because we don’t even look at the full images until we go into the detail view! So it would be a lot better if we just saved the images to disk, and only loaded them into memory as they were accessed, and relased them from memory when we weren’t using them anymore.

So let’s do that and see what it looks like. Much like the way we saved the data of our ScaryBugDoc, we’re going to override the properties that get our images, and add a “saveImage” method as well.

Make the following mod to ScaryBugDoc.h:

// After @interface
- (void)saveImages;

Then add the following to ScaryBugDoc.m:

// Add to top of file
#define kThumbImageFile @"thumbImage.jpg"
#define kFullImageFile  @"fullImage.jpg"
 
// Add new functions
- (UIImage *)thumbImage {
 
    if (_thumbImage != nil) return _thumbImage;
 
    NSString *thumbImagePath = [_docPath stringByAppendingPathComponent:kThumbImageFile];
    return [UIImage imageWithContentsOfFile:thumbImagePath];
 
}
 
- (UIImage *)fullImage {
 
    if (_fullImage != nil) return _fullImage;
 
    NSString *fullImagePath = [_docPath stringByAppendingPathComponent:kFullImageFile];
    return [UIImage imageWithContentsOfFile:fullImagePath];
 
}

Here we check to see if we have the images loaded into memory already, and if we do we return them. Otherwise, we just load the images straight off the disk with imageWithContentsOfFile.

Note that we don’t cache the images in our instance variables. This is to avoid the situation where someone loads up the detail view for each of our images, and the images are still retained inside this class because they were loaded once, clogging up memory with images. However, the tradeoff is that images might have to be loaded from disk more often than necessary. If this gets to be problematic, one solution is to retain them in instance variables, but have a method to clear the cache that is called under low memory conditions.

Then add the following as well:

- (void)saveImages {
 
    if (_thumbImage == nil || _fullImage == nil) return;
 
    [self createDataPath];
 
    NSString *thumbImagePath = [_docPath stringByAppendingPathComponent:kThumbImageFile];
    NSData *thumbImageData = UIImagePNGRepresentation(_thumbImage);
    [thumbImageData writeToFile:thumbImagePath atomically:YES];
 
    NSString *fullImagePath = [_docPath stringByAppendingPathComponent:kFullImageFile];
    NSData *fullImageData = UIImagePNGRepresentation(_fullImage);
    [fullImageData writeToFile:fullImagePath atomically:YES];
 
    self.thumbImage = nil;
    self.fullImage = nil;
 
}

Here we just write both images out to disk and set our cached copies back to nil (for the same reasons discussed above).

Now we just need to call the code to save the images at the right spot. Modify EditBugViewController.m to make the following change:

// In imagePickerController:didFinishPickingMediaWithInfo, after _imageView.image = fullImage:
[_bugDoc saveImages];

That’s it! Complie and run your app, and change the picture for a bug. Afterwards you’ll see your document directory contains the images:

Scary Bug Document with Images

And now if you shut down and restart your app, you’ll see that the images have been persisted as well!

Our Bug Saved To Disk!

Pinned Message
HOTODOGO
The Founder and CEO of Infeca Technology.
Developer, Designer, Blogger.
Big fan of Apple, Love of colour.
Feel free to contact me.
反曲点科技创始人和首席执行官。
开发、设计与写作皆为所长。
热爱苹果、钟情色彩。
随时恭候 垂询