Better completion handling when dealing with many UIDocuments

Hi there, in this article, I am going to illustrate two different methods for better completion handling of UIDocuments.

The openWithCompletionHandler: method will try to open the document on a background thread, which can help prevent blocking, but some times it can create problems. Let me show you what I mean.

I first create the documents using the following code:

- (void)createDocuments{
    for (int i=0; i<10; i++) {
        NSURL* url = [[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0];
        url=[url URLByAppendingPathComponent:[[NSString alloc] initWithFormat:@"file_%d.ext",i]];
        GYDocument* document = [[GYDocument alloc]initWithFileURL:url];
        [document setData:[[[NSString alloc] initWithFormat:@"%d",i] dataUsingEncoding:NSUTF8StringEncoding]];
        [document saveToURL:document.fileURL forSaveOperation:UIDocumentSaveForCreating completionHandler:^(BOOL success) {
            NSLog(@"save success:%d",success);
        }];
    }
}

And GYDocuments is implemented simply as following:

@synthesize data=_data;
-(BOOL)loadFromContents:(id)contents ofType:(NSString *)typeName error:(NSError *__autoreleasing *)outError{
    self.data=contents;
    return true;
}
-(id)contentsForType:(NSString *)typeName error:(NSError *__autoreleasing *)outError{
    return self.data;
}
-(NSData*)data{
    return _data;
}
-(void)setData:(NSData*)data{
    _data=data;
}

The naive way to open multiple UIDocuments is like following:

- (void)readDocuments{
    NSURL* rootURL =[[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0];
    NSArray* urls = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:rootURL includingPropertiesForKeys:nil options:0 error:nil];
    for (NSURL* url in urls) {
        GYDocument* doc = [[GYDocument alloc] initWithFileURL:url];
        [doc openWithCompletionHandler:^(BOOL success) {
            if (success) {
                NSLog(@"content of %@, is %@",[url path],[[NSString alloc] initWithData:doc.data encoding:NSUTF8StringEncoding]);
            }else{
                NSLog(@"opening not success: %@",[url path]);
            }
        }];
    }
}

After creating the Documents, several runs of readDocuments creates the following results:

content of file_4.ext, is 4
content of file_7.ext, is 7
content of file_9.ext, is 9
content of file_0.ext, is 0
content of file_2.ext, is 2
content of file_8.ext, is 8
content of file_6.ext, is 6
content of file_1.ext, is 1
content of file_5.ext, is 5
content of file_3.ext, is 3
content of file_4.ext, is 4
content of file_0.ext, is 0
content of file_7.ext, is 7
content of file_3.ext, is 3
content of file_2.ext, is 2
content of file_1.ext, is 1
content of file_6.ext, is 6
content of file_9.ext, is 9
content of file_5.ext, is 5
content of file_8.ext, is 8

As we can see, the order in which files are opened are completely non-deterministic, this can pose trouble if you do want your files to be read sequentially. (simplified for readability)

Another problem may rise if you want to do something that assumes all documents are loaded right after you tell them to load, like so:

- (void)viewDidLoad {
    [super viewDidLoad];
    [self readDocuments];
    //do something that assumes documents are all loaded
}

If there is only one document to load, you can easily put that code in the completion handler and pass it to the openWithCompletionHandler:, but you can’t do that in this case simply because the completion of one document doesn’t mean the completion of all documents.

As it turns out there is a way to ensure that, you need something like this:
In the private field of the ViewController, add a property that keeps track of how many documents are not opened:

@property (atomic) NSUInteger documentCount;

And re-write readDocuments like this:

- (void)readDocumentsWithCompletionHandler:(void(^)())handler{
    NSURL* rootURL =[[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0];
    NSArray* urls = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:rootURL includingPropertiesForKeys:nil options:0 error:nil];
    self.documentCount = urls.count;
    for (NSURL* url in urls) {
        GYDocument* doc = [[GYDocument alloc] initWithFileURL:url];
                NSLog(@"content of %@, is %@",[url path],[[NSString alloc] initWithData:doc.data encoding:NSUTF8StringEncoding]);
            }else{
                NSLog(@"opening not success: %@",[url path]);
            }
            self.documentCount--;
            if (self.documentCount==0 && handler!=NULL) {
                handler();
            }
        }];
    }
}

The logic is simple, every time you open something, you decrease the counter, when the counter is finally zero, you execute the completion handler.

But that doesn’t guarantee sequential access neither. If for some reason, you want all files to be open in a certain order, you can try something like this:

- (void)readDocumentsWithCompletionHandler:(void(^)())handler{
    NSURL* rootURL =[[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0];
    NSArray* urls = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:rootURL includingPropertiesForKeys:nil options:0 error:nil];
    [self readDocumentsRecursivelyFromIndex:0 ofArray:urls withCompletionHandler:handler];
}

- (void)readDocumentsRecursivelyFromIndex:(NSUInteger)index ofArray:(NSArray*)array withCompletionHandler:(void(^)())handler{
    GYDocument* doc = [[GYDocument alloc] initWithFileURL:[array objectAtIndex:index]];
    if (index==array.count-1) {
        [doc openWithCompletionHandler:^(BOOL success) {
            if (handler!=NULL) {
                handler();
            }
        }];
    }
    else{
        [doc openWithCompletionHandler:^(BOOL success) {
            [self readDocumentsRecursivelyFromIndex:index+1 ofArray:array withCompletionHandler:handler];
        }];
    }
}

This approach uses recursion. If it has more documents to read, then read the next one, if it doesn’t have any document to read, then execute the completion handler. Unlike the previous one, this approach guarantees sequential access to the files in the order they appear in the array.

One would expect the second way to be a lot slower than the first way, but that’s not true (at least based on the several trials I’ve done). When there is only 10 documents, the recursive approach end up running faster on average. When there is 200 documents, the recursive approach is only about 0.05 seconds slower than the counting approach. So, performance is not something you should worry about when using the recursive approach.

Posted in iOS

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s