In Part 1 of this tutorial, we set up a simple UIDocument-based class that will save and read an array of objects containing names and birthdays. We also set up a pair of unit tests that verified that we could, indeed, read and write using this class. In particular, the unit tests were designed to handle the fact that UIDocument uses Grand Central Dispatch (GCD) to do some of the I/O in a background thread.

In the second part of this tutorial, we’re going to “harden” the UIDocument class so that it tolerates various things that might go wrong, as well as provide us more information about the process of loading data.

For those of you who may want to play with the code from the first part, I’m going to leave the DDFriendList.m file that we produced there alone, and begin by cloning that file into DDFriendList2.m. Thus, this file starts out as follows:

@implementation DDFriendList2
@synthesize friends = _friends;

- (id) initWithFileURL:(NSURL *)url
{
    self = [super initWithFileURL:url];
    if (self)
    {
        _friends = [NSMutableArray array];
    }
    return self;
}

#define kFriendListKeyArray @"array"

- (id) contentsForType:(NSString *)typeName error:(NSError *__autoreleasing *)outError
{
    NSMutableData *data = [[NSMutableData alloc] init];
    NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data];
    
    [archiver encodeObject:_friends forKey:kFriendListKeyArray];
    
    [archiver finishEncoding];
    return data;
}

- (BOOL)loadFromContents:(id)contents ofType:(NSString *)typeName error:(NSError **)outError
{
    NSData *data = (NSData *)contents;
    
    NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data];
    
    _friends = [unarchiver decodeObjectForKey:kFriendListKeyArray];
    
    return YES;
}
@end

Similarly, I’ve creating a DDFriendList2Tests.m unit test file that copies over the previous unit tests so that we can expand upon them.

One of the common rules related to protocols is to be strict when sending, but permissive when receiving. Adapting that principle to our case, this means we should be strict about the way we write the file, but we should try to anticipate just about anything that might come our way when we read the file. That doesn’t mean we always have to succeed at reading the file, but at least we should recover from errors.

No Such File

Let’s start out with the case where the file doesn’t exist. We would expect that the load process would fail, since there isn’t any data to load. So let’s write a quick unit test (see DDFriendList2Tests.m in the accompanying source code:

- (void) testLoadingWhenThereIsNoFile
{
    // given that the file does not exist
    
    // when we load a new document from that file
    __block BOOL blockSuccess;
    
    DDFriendList2 * objUnderTest = [[DDFriendList2 alloc] initWithFileURL:_unitTestFileUrl];
    [objUnderTest openWithCompletionHandler:
          ^(BOOL success)
          {
              blockSuccess = success;
              [self blockCalled];
          }
     ];
    
    // then the completion block should be called, but with a failure indication
    
    STAssertTrue([self blockCalledWithin:10], nil);
    STAssertFalse(blockSuccess, nil);
}

If you run this test, you will find it passes.  (Yes, I know the TDD folks say that tests should initially be written to fail, but this isn't a tutorial on TDD, so I'm just saving time.) Further, if you put a breakpoint in <code>DDFriendList2</code>'s <code>loadFromContents:ofType:error:</code>, you will see that it isn't called - iOS is smart enough to realize that the file doesn't exist so there's no point in loading data.  We do get a failure indication, however, so the code that is loading the data knows that it doesn't yet have a valid friend list.  I'll come back to this later.

<b><u>Invalid File Contents</u></b>

What if the file doesn't contain what we expected?  What if it's corrupt?  If you've never had to deal with a corrupt file in your life, I applaud you, but I'm sure you can imagine scenarios in which something could go wrong.  Let's start out with what happens if the file is present, but empty.  What would we expect in this case?  Personally, I would expect the load process to fail, so let's start out with that assumption:


- (void) testLoadingEmptyFileShouldFailGracefully
{
    // given that the file is present but empty
    NSMutableData *data = [NSMutableData dataWithLength:0];
    [data writeToFile:_unitTestFilePath atomically:YES];
    
    // when we load a new document from that file
    __block BOOL blockSuccess;
    
    DDFriendList2 * objUnderTest = [[DDFriendList2 alloc] initWithFileURL:_unitTestFileUrl];
    [objUnderTest openWithCompletionHandler:
         ^(BOOL success)
         {
             blockSuccess = success;
             [self blockCalled];
         }
     ];
    
    // then the completion block should be called, but with a failure indication
    
    STAssertTrue([self blockCalledWithin:10], nil);
    STAssertFalse(blockSuccess, nil);
}

We start this test by creating a zero-length file, and then trying to load it. You may be surprised to learn that this test fails – the block is called with YES instead of NO. The NSKeyedUnarchiver accepts the zero-length NSData, and simply returns nil when asked to read out the array. Along the way it does kick out a log message:

[NSKeyedUnarchiver initForReadingWithData:]: data is empty; did you forget to send -finishEncoding to the NSKeyedArchiver?

Thus, if we expect a zero-length file not to load, we need to code that test in ourselves, which we can do very simply:

- (BOOL)loadFromContents:(id)contents ofType:(NSString *)typeName error:(NSError **)outError
{
    NSData *data = (NSData *)contents;
    
    if ([data length] == 0)
    {
        return NO;
    }
    
    NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data];
    
    _friends = [unarchiver decodeObjectForKey:kFriendListKeyArray];
    
    return YES;
}

Returning NO from loadFromContents:ofType:error: indicates a failure, which gets passed through to our completion block. With this change, both of our tests now pass.

What about a file that is non-zero in length, but doesn’t contain what we expected? What will the NSKeyedUnarchiver do then?

- (void) testLoadingSingleByteFileShouldFailGracefully
{
    // given that the file is present and contains a single byte
    NSMutableData *data = [NSMutableData dataWithLength:1];
    [data appendBytes:" " length:1];
    [data writeToFile:_unitTestFilePath atomically:YES];
    
    // when we load a new document from that file
    __block BOOL blockSuccess;
    
    DDFriendList2 * objUnderTest = [[DDFriendList2 alloc] initWithFileURL:_unitTestFileUrl];
    [objUnderTest openWithCompletionHandler:
         ^(BOOL success)
         {
             blockSuccess = success;
             [self blockCalled];
         }
     ];
    
    // then the completion block should be called, but with a failure indication
    
    STAssertTrue([self blockCalledWithin:10], nil);
    STAssertFalse(blockSuccess, nil);
}

Here we create a file that contains a single byte – not something that we would expect the NSKeyedUnarchiver to be happy with. Indeed, when we run this test, the document doesn’t load. What it does do, however, is blow up – the NSKeyedUnarchiver throws an NSInvalidArgumentException, complaining about an “incomprehensible archive”.

Now, this is A Bad Thing to have lurking in our code. If something in our code throws an uncaught exception, it could terminate our application. Thus, if the file got corrupted, our application could crash every time it tried to load the data. Even if the app didn’t completely terminate, it stands to reason our completion block wouldn’t get called, and we’d likely hang. (Think it can’t happen? Some time back, I somehow actually ended up in a situation in which the iCal app – yes, the one written by Apple – on my phone crashed a background thread every time it tried to access the calendar information from iCloud. This problem was eventually escalated all the way up the chain to Cupertino. I was never told exactly what the problem was, but fixing it required The Powers That Be to completely re-initialize my iCloud account. Want to bet that there was some subtle data corruption somewhere that wasn’t being caught correctly?)

Fortunately, now that we know about the problem, we can modify our code not to crash by adding a try/catch block:

- (BOOL)loadFromContents:(id)contents ofType:(NSString *)typeName error:(NSError **)outError
{
    NSData *data = (NSData *)contents;
    
    if ([data length] == 0)
    {
        return NO;
    }
    
    @try 
    {
        NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data];
        
        _friends = [unarchiver decodeObjectForKey:kFriendListKeyArray];
    }
    @catch (NSException *exception) 
    {
        NSLog(@"%@ exception: %@", NSStringFromSelector(_cmd), exception);
        return NO;
    }
    
    return YES;
}

Now our code catches any exception that the NSKeyedUnarchiver throws during either setup or unarchiving our data, and returns NO from loadFromContents:ofType:error: so that openWithCompletionHandler: will, in turn, indicate that the document failed to load. Wrapping both the creation and use of NSKeyedUnarchiver is important, because it’s possible that the unarchiver might be created properly, but then crash during the unarchive process. To test this, we can create a DDExplodingObject that always throws an exception when unarchived by having it implement initFromCoder: as follows:

- (id) initWithCoder:(NSCoder *)decoder
{
    self = [super init];
    
    if (self)
    {
        [NSException raise:@"DDExplodingObjectException" format:@"goes bang when unarchived"];
    }
    
    return self;
}

We can then create a file containing an array that includes such an object and test that trying to load it is also caught:

- (void) testExceptionDuringUnarchiveShouldFailGracefully
{
    // given that the file contains an object that will throw when unarchived
    
    DDExplodingObject *exploding = [[DDExplodingObject alloc] init];
    NSArray *array = [NSArray arrayWithObjects:exploding, nil];
    NSMutableData *data = [[NSMutableData alloc] init];
    NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data];    
    [archiver encodeObject:array forKey:@"array"];
    [archiver finishEncoding];
    [data writeToFile:_unitTestFilePath atomically:YES];
    
    // when we load a new document from that file
    __block BOOL blockSuccess;
    
    DDFriendList2 * objUnderTest = [[DDFriendList2 alloc] initWithFileURL:_unitTestFileUrl];
    [objUnderTest openWithCompletionHandler:
         ^(BOOL success)
         {
             blockSuccess = success;
             [self blockCalled];
         }
     ];
    
    // then the completion block should be called, but with a failure indication
    
    STAssertTrue([self blockCalledWithin:10], nil);
    STAssertFalse(blockSuccess, nil);
}

Now we have an implementation of loadFromContents:ofType:error: that is robust against accidental issues related to the format of the saved data.

File Versioning

What about non-accidental issues with respect to the format? Assuming our application is a success, there is every possibility that we may need to change our data format in the future. If we do this, the first time after the user updates to our Version 2.0, the data stored on the device will still be of the older version. We need to detect this so that we can take whatever steps are required to migrate between the Version 1.0 format and the Version 2.0 format. So, we can enhance things as follows:

#define kFriendListKeyVersion   @"version"
#define kFriendListKeyArray     @"array"

#define kFriendListCurrentVersion   1

- (id) contentsForType:(NSString *)typeName error:(NSError *__autoreleasing *)outError
{
    NSMutableData *data = [[NSMutableData alloc] init];
    NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data];
    
    [archiver encodeInt:kFriendListCurrentVersion forKey:kFriendListKeyVersion];
    [archiver encodeObject:_friends forKey:kFriendListKeyArray];
    
    [archiver finishEncoding];
    return data;
}

- (BOOL)loadFromContents:(id)contents ofType:(NSString *)typeName error:(NSError **)outError
{
    NSData *data = (NSData *)contents;
    
    if ([data length] == 0)
    {
        return NO;
    }
    
    @try 
    {
        NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data];
        
        int version = [unarchiver decodeIntForKey:kFriendListKeyVersion];
        switch(version)
        {
            case kFriendListCurrentVersion:
                _friends = [unarchiver decodeObjectForKey:kFriendListKeyArray];
                break;
                
            default:
                return NO;
        }
    }
    @catch (NSException *exception) 
    {
        NSLog(@"%@ exception: %@", NSStringFromSelector(_cmd), exception);
        return NO;
    }
    
    return YES;
}

Note that it’s a good idea to start our version number at 1, not 0, since decodeIntForKey: returns 0 when the key is not present. We can verify that this works as expected:


- (void) testUnexpectedVersionShouldFailGracefully
{
    // given that the file contains an unexpected version number
    
    NSArray *array = [NSArray array];
    NSMutableData *data = [[NSMutableData alloc] init];
    NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data]; 
    [archiver encodeInt:-999 forKey:@"version"];
    [archiver encodeObject:array forKey:@"array"];
    [archiver finishEncoding];
    [data writeToFile:_unitTestFilePath atomically:YES];
    
    // when we load a new document from that file
    __block BOOL blockSuccess;
    
    DDFriendList2 * objUnderTest = [[DDFriendList2 alloc] initWithFileURL:_unitTestFileUrl];
    [objUnderTest openWithCompletionHandler:
     ^(BOOL success)
     {
         blockSuccess = success;
         [self blockCalled];
     }
     ];
    
    // then the completion block should be called, but with a failure indication
    
    STAssertTrue([self blockCalledWithin:10], nil);
    STAssertFalse(blockSuccess, nil);
}

We can verify that things behave as expected by putting a breakpoint in the default case in the switch statement and seeing that we hit it as expected.

Oops! When we run our tests, our previous testExceptionDuringUnarchiveShouldFailGracefully now falls into the default case in the switch statement and never unarchives the array. Well, that’s one way of testing a missing version number, but it does point out that you need to double check that changes don’t cause your tests to take different paths than expected. We can fix this test by adding a line:

- (void) testExceptionDuringUnarchiveShouldFailGracefully
{
    // given that the file contains an object that will throw when unarchived
    
    DDExplodingObject *exploding = [[DDExplodingObject alloc] init];
    NSArray *array = [NSArray arrayWithObjects:exploding, nil];
    NSMutableData *data = [[NSMutableData alloc] init];
    NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data];    
    [archiver encodeInt:1 forKey:@"version"];
    [archiver encodeObject:array forKey:@"array"];
    [archiver finishEncoding];
    [data writeToFile:_unitTestFilePath atomically:YES];

This is something we would have (hopefully) caught if we were checking code coverage, but that’s yet another topic.

What about our DDFriend object? Should we version that as well? That really depends. Certainly, we could be paranoid and do that. Alternately, we can rely on the fact that if we attempt to unarchive a key that doesn’t exist, we know what value will be returned (nil for objects, and 0 for non-object types). Thus, if and when we change our structure, we may still be able to deduce what version was saved by looking for the presence or absence of objects.

Tell Me More

At this point, we’ve pretty well bullet-proofed our class. There’s just one final item. We can tell when the file loaded correctly and when it didn’t, but when it fails we can’t tell why it didn’t. We may want to take different action depending on why the loading failed. For example, the absence of the file might indicate that this is the first time the app has run, and thus we may want to take some special first-time path through the user interface. More seriously, suppose we’re using iCloud. We could run into the following scenario:

  1. The user has Version 1.0 of our application on, say, their iPhone and iPad.
  2. The user updates to Version 2.0 on the iPhone.
  3. The user runs the updated version. This causes the file to be updated to the Version 2.0 format.
  4. iCloud, as it’s supposed to, syncs this file over to the iPad.
  5. Forgetting that Version 2.0 is out, the user now invokes Version 1.0 on the iPad.
  6. The iPad finds that it doesn’t know how to read the file.

If a file is truly corrupted, your application may want to offer the user the opportunity to erase the corrupt file and either re-initialize it, or continue without it. If, however, the file has been updated to a newer version, the last thing you probably want to do is to delete or destroy the updated file just because the user hasn’t updated every copy of the app. Instead, you probably want to prompt the user to upgrade the app on his or her second device. Covering these cases gracefully requires knowing why our document couldn’t be loaded.

loadFromContents:ofType:error: has the ability to return an NSError if the loading process failed. Unfortunately, Apple, in its wisdom, decided not to expose this information to the completion block that openWithCompletionHandler: calls. I consider this a minor defect in the design – ideally the value should have been passed through, or saved and made accessible through a lastError property. Fortunately, we can code our way around this as follows:

First, let’s add a property to our friend list that will provide the result:

typedef enum eDDFriendListLoadResult
{
    DDFLLR_SUCCESS,
    DDFLLR_ZERO_LENGTH_FILE,
    DDFLLR_CORRUPT_FILE,
    DDFLRR_UNEXPECTED_VERSION
} DDFriendListLoadResult;

@interface DDFriendList2 : UIDocument
@property (nonatomic, strong) NSMutableArray *friends;
@property (nonatomic, readonly) DDFriendListLoadResult loadResult;
@end

and modify our implementation as follows

@implementation DDFriendList2
@synthesize friends = _friends;
@synthesize loadResult = _loadResult;

- (id) initWithFileURL:(NSURL *)url
{
    self = [super initWithFileURL:url];
    if (self)
    {
        _friends = [NSMutableArray array];
    }
    return self;
}

#define kFriendListKeyVersion   @"version"
#define kFriendListKeyArray     @"array"

#define kFriendListCurrentVersion   1

- (id) contentsForType:(NSString *)typeName error:(NSError *__autoreleasing *)outError
{
    NSMutableData *data = [[NSMutableData alloc] init];
    NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data];
    
    [archiver encodeInt:kFriendListCurrentVersion forKey:kFriendListKeyVersion];
    [archiver encodeObject:_friends forKey:kFriendListKeyArray];
    
    [archiver finishEncoding];
    return data;
}

- (BOOL)loadFromContents:(id)contents ofType:(NSString *)typeName error:(NSError **)outError
{
    NSData *data = (NSData *)contents;
    
    if ([data length] == 0)
    {
        _loadResult = DDFLLR_ZERO_LENGTH_FILE;
        return NO;
    }
    
    @try 
    {
        NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data];
        
        int version = [unarchiver decodeIntForKey:kFriendListKeyVersion];
        switch(version)
        {
            case kFriendListCurrentVersion:
                _friends = [unarchiver decodeObjectForKey:kFriendListKeyArray];
                break;
                
            default:
                _loadResult = DDFLLR_UNEXPECTED_VERSION;
                return NO;
        }
    }
    @catch (NSException *exception) 
    {
        NSLog(@"%@ exception: %@", NSStringFromSelector(_cmd), exception);
        _loadResult = DDFLLR_CORRUPT_FILE;
        return NO;
    }
    
    _loadResult = DDFLLR_SUCCESS;
    return YES;
}

- (void) openWithCompletionHandler:(void (^)(BOOL))completionHandler
{
    _loadResult = DDFLLR_NO_SUCH_FILE;
    [super openWithCompletionHandler:completionHandler];
}
@end

We have to overload openWithCompletionHandler: because loadFromContents:ofType:error: won’t be called if the file doesn’t exist.

At this point, the reason for any load failure is available in each of our cases, and our user interface can react accordingly. For completeness, of course we should go back and add the appropriate asserts to our unit tests. I’ve done that in the code on GitHub.

So there you have it – a robust UIDocument class that is tolerant to Things That Go Bump In The Night, and which not only tells you when something goes wrong, but is more specific about what. If there are any further improvements you can think of, please don’t hesitate to suggest them.

Closing comments:

  • In production code, I would probably have refactored out some of the setup code in the unit tests. I didn’t bother in this case because this wasn’t a tutorial about good unit tests, and it made the individual tests more “standalone.”
  • The idea of putting version numbers in your data format is hardly new with me – this is commonly advocated. Dealing with corrupt files and other unexpected errors, however, is something that I’ve rarely seen in UIDocument tutorials, which leads me to suspect that there may be many fragile applications out there…

You can download the code for this tutorial from https://github.com/SilverBayTech/Defensive-UIDocument.