Managing a bunch of NSOperation with dependencies

Romain Pouclet picture Romain Pouclet · Sep 18, 2013 · Viewed 11.3k times · Source

I'm working on an application that create contents and send it to an existing backend. Content is a title, a picture and location. Nothing fancy.

The backend is a bit complicated so here is what I have to do :

  • Let the user take a picture, enter a title and authorize the map to use its location
  • Generate a unique identifier for the post
  • Create the post on the backend
  • Upload the picture
  • Refresh the UI

I've used a couple of NSOperation subclasses to make this work but I'm not proud of my code, here is a sample.

NSOperation *process = [NSBlockOperation blockOperationWithBlock:^{
    // Process image before upload
}];

NSOperation *filename = [[NSInvocationOperation alloc] initWithTarget: self selector: @selector(generateFilename) object: nil];

NSOperation *generateEntry = [[NSInvocationOperation alloc] initWithTarget: self selector: @selector(createEntry) object: nil];

NSOperation *uploadImage = [[NSInvocationOperation alloc] initWithTarget: self selector: @selector(uploadImageToCreatedEntry) object: nil];

NSOperation *refresh = [NSBlockOperation blockOperationWithBlock:^{
    // Update UI
    [SVProgressHUD showSuccessWithStatus: NSLocalizedString(@"Success!", @"Success HUD message")];
}];

[refresh addDependency: uploadImage];

[uploadImage addDependency: generateEntry];
[generateEntry addDependency: filename];
[generateEntry addDependency: process];

[[NSOperationQueue mainQueue] addOperation: refresh];
[_queue addOperations: @[uploadImage, generateEntry, filename, process] waitUntilFinished: NO];

Here are the things I don't like :

  • in my createEntry: for example, I'm storing the generated filename in a property, which mees with the global scope of my class
  • in the uploadImageToCreatedEntry: method, I'm using dispatch_async + dispatch_get_main_queue() to update the message in my HUD
  • etc.

How would you manage such workflow ? I'd like to avoid embedding multiple completion blocks and I feel like NSOperation really is the way to go but I also feel there is a better implementation somewhere.

Thanks!

Answer

Justin Spahr-Summers picture Justin Spahr-Summers · Sep 19, 2013

You can use ReactiveCocoa to accomplish this pretty easily. One of its big goals is to make this kind of composition trivial.

If you haven't heard of ReactiveCocoa before, or are unfamiliar with it, check out the Introduction for a quick explanation.

I'll avoid duplicating an entire framework overview here, but suffice it to say that RAC actually offers a superset of promises/futures. It allows you to compose and transform events of completely different origins (UI, network, database, KVO, notifications, etc.), which is incredibly powerful.

To get started RACifying this code, the first and easiest thing we can do is put these separate operations into methods, and ensure that each one returns a RACSignal. This isn't strictly necessary (they could all be defined within one scope), but it makes the code more modular and readable.

For example, let's create a couple signals corresponding to process and generateFilename:

- (RACSignal *)processImage:(UIImage *)image {
    return [RACSignal startEagerlyWithScheduler:[RACScheduler scheduler] block:^(id<RACSubscriber> subscriber) {
        // Process image before upload

        UIImage *processedImage = …;
        [subscriber sendNext:processedImage];
        [subscriber sendCompleted];
    }];
}

- (RACSignal *)generateFilename {
    return [RACSignal startEagerlyWithScheduler:[RACScheduler scheduler] block:^(id<RACSubscriber> subscriber) {
        NSString *filename = [self generateFilename];
        [subscriber sendNext:filename];
        [subscriber sendCompleted];
    }];
}

The other operations (createEntry and uploadImageToCreatedEntry) would be very similar.

Once we have these in place, it's very easy to compose them and express their dependencies (though the comments make it look a bit dense):

[[[[[[self
    generateFilename]
    flattenMap:^(NSString *filename) {
        // Returns a signal representing the entry creation.
        // We assume that this will eventually send an `Entry` object.
        return [self createEntryWithFilename:filename];
    }]
    // Combine the value with that returned by `-processImage:`.
    zipWith:[self processImage:startingImage]]
    flattenMap:^(RACTuple *entryAndImage) {
        // Here, we unpack the zipped values then return a single object,
        // which is just a signal representing the upload.
        return [self uploadImage:entryAndImage[1] toCreatedEntry:entryAndImage[0]];
    }]
    // Make sure that the next code runs on the main thread.
    deliverOn:RACScheduler.mainThreadScheduler]
    subscribeError:^(NSError *error) {
        // Any errors will trickle down into this block, where we can
        // display them.
        [self presentError:error];
    } completed:^{
        // Update UI
        [SVProgressHUD showSuccessWithStatus: NSLocalizedString(@"Success!", @"Success HUD message")];
    }];

Note that I renamed some of your methods so that they can accept inputs from their dependencies, giving us a more natural way to feed values from one operation to the next.

There are huge advantages here:

  • You can read it top-down, so it's very easy to understand the order that things happen in, and where the dependencies lie.
  • It's extremely easy to move work between different threads, as evidenced by the use of -deliverOn:.
  • Any errors sent by any of those methods will automatically cancel all the rest of the work, and eventually reach the subscribeError: block for easy handling.
  • You can also compose this with other streams of events (i.e., not just operations). For example, you could set this up to trigger only when a UI signal (like a button click) fires.

ReactiveCocoa is a huge framework, and it's unfortunately hard to distill the advantages down into a small code sample. I'd highly recommend checking out the examples for when to use ReactiveCocoa to learn more about how it can help.