Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

ReactiveCocoa sequencing with async network requests

I'm building a demonstration app and am trying to conform to the ReactiveCocoa design pattern as much as possible. Here's what the app does:

  • Find the device's location
  • Whenever the location key changes, fetch:
    • Current weather
    • Hourly forecast
    • Daily forecast

So the order is 1) update location 2) merge all 3 weather fetches. I've built a WeatherManager singleton that exposes weather objects, location information, and methods to manually update. This singleton conforms to the CLLocationManagerDelegate protocol. The location code is very basic, so I'm leaving it out. The only real point of interest is this:

- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations {
    // omitting accuracy & cache checking
    CLLocation *location = [locations lastObject];
    self.currentLocation = location;
    [self.locationManager stopUpdatingLocation];
}

Fetching the weather conditions is all very similar, so I've built a method to generate a RACSignal for fetching JSON from a URL.

- (RACSignal *)fetchJSONFromURL:(NSURL *)url {
    return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
        NSURLSessionDataTask *dataTask = [self.session dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
            if (! error) {
                NSError *jsonError = nil;
                id json = [NSJSONSerialization JSONObjectWithData:data options:kNilOptions error:&jsonError];
                if (! jsonError) {
                    [subscriber sendNext:json];
                }
                else {
                    [subscriber sendError:jsonError];
                }
            }
            else {
                [subscriber sendError:error];
            }

            [subscriber sendCompleted];
        }];

        [dataTask resume];

        return [RACDisposable disposableWithBlock:^{
            [dataTask cancel];
        }];
    }];
}

This helps me keep my methods nice and clean, so now I have 3 short methods that build a URL and return the RACSignal. The nice thing here is I can create side-effects to parse the JSON and assign the appropriate properties (note: I'm using Mantle here).

- (RACSignal *)fetchCurrentConditions {
    // build URL
    return [[self fetchJSONFromURL:url] doNext:^(NSDictionary *json) {
        // simply converts JSON to a Mantle object
        self.currentCondition = [MTLJSONAdapter modelOfClass:[CurrentCondition class] fromJSONDictionary:json error:nil];
    }];
}

- (RACSignal *)fetchHourlyForecast {
    // build URL
    return [[self fetchJSONFromURL:url] doNext:^(NSDictionary *json) {
        // more work
    }];
}

- (RACSignal *)fetchDailyForecast {
    // build URL
    return [[self fetchJSONFromURL:url] doNext:^(NSDictionary *json) {
        // more work
    }];
}

Finally, in the -init of my singleton, I set up the RAC observers on location, since every time the location changes I want to fetch and update the weather.

[[RACObserve(self, currentLocation)
 filter:^BOOL(CLLocation *newLocation) {
     return newLocation != nil;
 }] subscribeNext:^(CLLocation *newLocation) {
     [[RACSignal merge:@[[self fetchCurrentConditions], [self fetchDailyForecast], [self fetchHourlyForecast]]] subscribeError:^(NSError *error) {
         NSLog(@"%@",error.localizedDescription);
     }];
 }];

Everything works just fine, but I'm concerned that I'm straying from the Reactive way to structure my fetches and property assignments. I tried doing sequencing with -then: but wasn't really able to get that setup how I would like.

I was also trying to find a clean way to bind the result of an async fetch to the properties of my singleton, but ran into trouble getting that to work. I wasn't able to figure out how to "extend" the fetching RACSignals (note: that's where the -doNext: idea came from for each of those).

Any help clearing this up or resources would be really great. Thanks!

like image 442
rnystrom Avatar asked Sep 09 '13 19:09

rnystrom


1 Answers

It seems inappropriate for -fetch methods to have meaningful side effects, which makes me think that your WeatherManager class is conflating two different things:

  1. Network requests for getting the latest data
  2. The stateful storage and presentation of that data

This is important because the first concern is stateless, while the second is almost entirely stateful. In GitHub for Mac, for example, we use OCTClient to perform the networking, and then store the returned user data on a "persistent state manager" singleton.

Once you break it down like this, I think it'll be easier to understand. Your state manager can interact with the networking client to kick off requests, and then the state manager can subscribe to those requests and apply side effects.

First of all, let's make the -fetch… methods stateless, by rewriting them to use transformations instead of side effects:

- (RACSignal *)fetchCurrentConditions {
    // build URL
    return [[self fetchJSONFromURL:url] map:^(NSDictionary *json) {
        return [MTLJSONAdapter modelOfClass:[CurrentCondition class] fromJSONDictionary:json error:nil];
    }];
}

Then, you can use these stateless methods and inject side effects into them where it's more appropriate:

- (RACSignal *)updateCurrentConditions {
    return [[self.networkClient
        // If this signal sends its result on a background thread, make sure
        // `currentCondition` is thread-safe, or make sure to deliver it to
        // a known thread.
        fetchCurrentConditions]
        doNext:^(CurrentCondition *condition) {
            self.currentCondition = condition;
        }];
}

And, to update all of them, you can use +merge: (like in your example) combined with -flattenMap: to map from the location values to a new signal of work:

[[[RACObserve(self, currentLocation)
    ignore:nil]
    flattenMap:^(CLLocation *newLocation) {
        return [RACSignal merge:@[
            [self updateCurrentConditions],
            [self updateDailyForecast],
            [self updateHourlyForecast],
        ]];
    }]
    subscribeError:^(NSError *error) {
        NSLog(@"%@", error);
    }];

Or, to automatically cancel in-flight updates whenever currentLocation changes, replace -flattenMap: with -switchToLatest:

[[[[RACObserve(self, currentLocation)
    ignore:nil]
    map:^(CLLocation *newLocation) {
        return [RACSignal merge:@[
            [self updateCurrentConditions],
            [self updateDailyForecast],
            [self updateHourlyForecast],
        ]];
    }]
    switchToLatest]
    subscribeError:^(NSError *error) {
        NSLog(@"%@", error);
    }];

(Original reply from ReactiveCocoa/ReactiveCocoa#786).

like image 168
Justin Spahr-Summers Avatar answered Oct 04 '22 13:10

Justin Spahr-Summers