NSOperation and NSRunLoop; a marriage of necessity

So, after reading the recent news about Dropbox deprecating their support of their Sync API (and corresponding SDK’s, this includes Datastore as well), and knowing that I’ve always found the Dropbox functionality of my Songbook Simple app to be less than ideal, I thought it would be a good point to change my implementation.

I should start with some philosophy.  Not that Socrates stuff.  Software Design philosophy.  So it goes, DON’T BLOCK THE MAIN THREAD.  The Main thread is for the UI.  Anything else you put on the main thread can only serve to hurt your UI’s responsiveness.  This goes to say that doing things to data models, like fetching and updating them, should be done on background threads.

So I found it disappointing when Dropbox makes a passing mention to this incredibly important detail in their documentation:

Make sure you call DBRestClient methods from the main thread or a thread that has a run loop. Otherwise the delegate methods won’t be called.

Now.  I don’t want any Dropbox involvement with my main thread.  In fact, I want all dropbox related, AND Core Data related work to be done in the background.  And the best technology to do this is hands down NSOperation and NSOperationQueue.  What a marvelous technology that is!

The problem is, what about these pesky run loops?  I had a bit of a challenge getting them going, so I’ll just post the code below so you can see how they work.


//  SBDropboxOperation.h
//  Songbook
//
//  Created by Stephen O'Connor on 26/04/15.
//  Copyright (c) 2015 Stephen O'Connor. All rights reserved.
//

#import 

typedef void(^SBDropboxCompletionBlock)(BOOL contextDidSave, id userInfo, NSError *error);
typedef void(^SBDropboxProgressBlock)(NSUInteger currentlyCompleted, NSUInteger totalInBatch);

@interface SBDropboxOperation : NSOperation
{
@protected
    
    BOOL _isExecuting;
    BOOL _isFinished;
    NSManagedObjectContext *_localContext;
}

@property (nonatomic, copy) SBDropboxCompletionBlock opCompletionBlock;

- (instancetype)initWithCompletionBlock:(SBDropboxCompletionBlock)completionBlock;

// override this to make your DBRestClient calls, then implement delegate methods
- (void)communicateWithDropboxServer;

// when you're finished retrieving what needs retrieving and modifying your data model, you call this to wrap up the operation
- (void)finishedServerCommunication;

@end

And then the implementation file


//  SBDropboxOperation.m
//  Songbook
//
//  Created by Stephen O'Connor on 26/04/15.
//  Copyright (c) 2015 Stephen O'Connor. All rights reserved.
//

#import "SBDropboxOperation.h"
#import 

NSString* boolString(BOOL arg)
{
    return arg ? @"YES" : @"NO";
}


@interface SBDropboxOperation()
{
    BOOL _stopRunLoop;
    NSTimer *_keepAliveTimer;
}
@property (nonatomic, assign) BOOL contextDidSave;
@property (nonatomic, strong) id userInfo;
@property (nonatomic, strong) NSError *error;

@property (nonatomic, strong) DBRestClient *restClient;

@end


@implementation SBDropboxOperation

- (instancetype)initWithCompletionBlock:(SBDropboxCompletionBlock)opCompletionBlock
{
    self = [self init];
    if (self) {
        self.opCompletionBlock = opCompletionBlock;
    }
    return self;
}

- (instancetype)init
{
    self = [super init];
    if (self) {
        _isExecuting = NO;
        _isFinished = NO;
        
    }
    return self;
}

- (NSString*)description
{
    return [NSString stringWithFormat:@"%@ - %@ - isReady: %@, isExecuting: %@, isFinished: %@, wasCancelled: %@",
            [super description],
            self.name,
            boolString(self.isReady),
            boolString(self.isExecuting),
            boolString(self.isFinished),
            boolString(self.isCancelled)];
}

- (BOOL)isExecuting
{
    return _isExecuting;
}

- (BOOL)isFinished
{
    return _isFinished;
}

- (void)setCompletionBlock:(void (^)(void))block
{
    DDLogError(@"You should never explicitly call setCompletionBlock: unless you are overriding in the subclass.  use setOpCompletionBlock: instead");
}

/* i.e. you can only set this property if you're writing a subclass and know what you're doing! */
- (void)_setCompletionBlock:(void (^)(void))block
{
    [super setCompletionBlock:block];
}

- (BOOL)isConcurrent
{
    return YES;
}

- (void)start
{
    [self willChangeValueForKey:@"isExecuting"];
    DDLogVerbose(@"BEGINNING: %@", self.description);
    _isExecuting = YES;
    [self didChangeValueForKey:@"isExecuting"];
    
    if (![DBSession sharedSession].isLinked) {
        self.error = [NSError errorWithDomain:@"SongbookSimple" code:100 userInfo:@{NSLocalizedDescriptionKey : @"You tried running a sync operation before you've linked your Dropbox account"}];
        [self finish];
        return;
    }
    
    self.restClient = [[DBRestClient alloc] initWithSession:[DBSession sharedSession]];
    self.restClient.delegate = self;
    
    if (!_localContext) {
        NSManagedObjectContext *mainContext  = [NSManagedObjectContext MR_rootSavingContext];
        _localContext = [NSManagedObjectContext MR_contextWithParent:mainContext];
    }

    // RUN LOOP MAGIC
    NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
    
    // run loops don't run if they don't have input sources or timers on them.  So we add a timer that we never intend to fire.
    _keepAliveTimer = [NSTimer timerWithTimeInterval:CGFLOAT_MAX target:self selector:@selector(timeout:) userInfo:nil repeats:NO];
    [runLoop addTimer:_keepAliveTimer forMode:NSDefaultRunLoopMode];
    
    [self communicateWithDropboxServer];
    
    NSTimeInterval updateInterval = 0.1f;
    NSDate *loopUntil = [NSDate dateWithTimeIntervalSinceNow:updateInterval];
    while (!_stopRunLoop && [runLoop runMode: NSDefaultRunLoopMode beforeDate:loopUntil])
    {
        loopUntil = [NSDate dateWithTimeIntervalSinceNow:updateInterval];
    }
}

- (void)communicateWithDropboxServer
{
    // normally you would override this implementation, do your asynchronous stuff
    // then call finishedServerCommunication
    [self finishedServerCommunication];
}

- (void)finishedServerCommunication
{
    // this removes (presumably still the only) timer from the NSRunLoop
    [_keepAliveTimer invalidate];
    _keepAliveTimer = nil;
    
    // and this will kill the while loop in the start method
    _stopRunLoop = YES;
    
    // this saves your local context if you are using Core Data like I am.
    __weak SBDropboxOperation *weakself = self;
    [_localContext MR_saveToPersistentStoreWithCompletion:^(BOOL success, NSError *error)
     {
         if (error) {
            weakself.error = error;  // we check for error because it might have been set by a previous error already.
         }
         
         weakself.contextDidSave = success;
         [weakself finish];
     }];
}

- (void)timeout:(NSTimer*)timer
{
    // this method should never get called.
    self.error = [NSError errorWithDomain:@"UselessDomain" code:-99 userInfo:@{NSLocalizedDescriptionKey : @"NSRunLoop timed out"}];
    [self finishedServerCommunication];
}

- (void)finish
{
    // if it was cancelled, it's because we want to pretend it was never in the queue and have it do nothing.
    if (self.isCancelled) {
        DDLogWarn(@"Command was cancelled.  If you need a completion block to be called, you need to do it yourself somehow.  Default behaviour is to 'pretend the command never happened.'");
        [self endOperation:nil];
        return;
    }
    
    // if it already failed
    if (self.error) {
        DDLogError(@"status code: %i, error: %@", (int)self.error.code, self.error.localizedDescription);
    }
    // to avoid retain cycles and the like we do a bit of memory management here
    SBDropboxCompletionBlock strongBlock = nil;
    
    if (self.opCompletionBlock) {
        strongBlock = [self.opCompletionBlock copy];
    }
    BOOL successful = self.contextDidSave;
    id strongInfo = self.userInfo;
    NSError *strongError = self.error;
    [self _setCompletionBlock:^{
        
        // be sure to call completion block on main thread
        dispatch_async(dispatch_get_main_queue(), ^{
        
            if (strongBlock) {
                strongBlock(successful, strongInfo, strongError);
            }
        });
    }];
    
    [self endOperation:nil];
}

- (void)endOperation:(NSTimer*)timer
{
    // generate the KVO necessary for the queue to remove him
    [self willChangeValueForKey:@"isExecuting"];
    [self willChangeValueForKey:@"isFinished"];
    
    _isExecuting = NO;
    _isFinished = YES;
    
    [self didChangeValueForKey:@"isExecuting"];
    [self didChangeValueForKey:@"isFinished"];
    
}

@end

I really need to get a better code formatter for this blog. 😀

If you need more info as to how to do concurrent NSOperations and also how Core Data fits into this (I left all that out of my implementation, which I like to extend NSOperation to have completion blocks of my choosing, amongst other things…), please get in touch. This blog is my memory as much as it is (hopefully) your means of finding help.

Advertisements

2 thoughts on “NSOperation and NSRunLoop; a marriage of necessity

    • I’ll update the code listing, but doing the calls to dropbox are up to you. It depends on what you’re trying to do with Dropbox.

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 )

Twitter picture

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

Facebook photo

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

Google+ photo

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

Connecting to %s