Collect + combineLatest or zip

Collect in RAC is a relatively easy to understand operation. Its power lies in that it can complete complex business logic when combined with other operations. Before looking at the actual business code, take a look at the behavior pattern of this Pipeline with the following code. Collect is equivalent to the ToArray operation in Rx

Version 1

- (void)testCollectSignalsAndCombineLatestOrZip { RACSignal *numbers = @[@(0), @(1), @(2)].rac_sequence.signal; RACSignal *letters1 = @[@"A", @"B", @"C"].rac_sequence.signal; RACSignal *letters2 = @[@"X", @"Y", @"Z"].rac_sequence.signal; RACSignal *letters3 = @[@"M", @"N"].rac_sequence.signal; NSArray *arrayOfSignal = @[letters1, letters2, letters3]; [[[numbers map:^id(NSNumber *n) { return arrayOfSignal[n.integerValue];  }] collect] subscribeNext:^(NSArray *array) { DDLogVerbose(@"%@, %@", [array class], array);  } completed:^{ DDLogVerbose(@"completed"); }]; }Copy the code

This code is purely to demonstrate the behavior pattern of Collect:

  1. Construct an array of NSnumbers containing the digits 0, 1, and 2 and convert it to signal.
  2. In the same way, construct an array of three strings, convert them to signals, and place the three resulting signals into an array of signals.
  3. This forms a signal nesting, but unlike the previous approach, the flatten operation is not directly used in subsequent sections, but is first used in collect.
  4. Collect operation will collect all data sent by next in Pipeline into an NSArray, and then send it to the subsequent links through Next at one time.

This code is executed as follows:

2016-04-28 17:45:38:034 [com.ReactiveCocoa.RACScheduler.backgroundScheduler] __NSArrayM, (
    " name: ",
    " name: ",
    " name: "
    )
2016-04-28 17:45:38:034 [com.ReactiveCocoa.RACScheduler.backgroundScheduler] completed
Copy the code

As you can see, the array contains three signals. Also, because signal is already nested, sooner or later flatten is required, so how to flatten?

Version 2

Since there are three signals in an array, you can construct a Pipeline that merges the three signals into a single signal and then flatten the merged signal. When merging, there can be different strategies, starting with the following code:

- (void)testCollectSignalsAndCombineLatestOrZip { RACSignal *numbers = @[@(0), @(1), @(2)].rac_sequence.signal; RACSignal *letters1 = @[@"A", @"B", @"C"].rac_sequence.signal; RACSignal *letters2 = @[@"X", @"Y", @"Z"].rac_sequence.signal; RACSignal *letters3 = @[@"M", @"M"].rac_sequence.signal; NSArray *arrayOfSignal = @[letters1, letters2, letters3]; [[[[numbers map:^id(NSNumber *n) { return arrayOfSignal[n.integerValue];  }] collect] flattenMap:^RACStream *(NSArray *arrayOfSignal) { return [RACSignal combineLatest:arrayOfSignal reduce:^(NSString *first, NSString *second, NSString *third) { return [NSString stringWithFormat:@"%@-%@-%@", first, second, third]; }]; }] subscribeNext:^(NSString *x) { DDLogVerbose(@"%@, %@", [x class], x);  } completed:^{ DDLogVerbose(@"completed"); }]; }Copy the code

After receiving the array sent by Collect, this code performs a combineLatest operation on the signal in the array. At this time, the original three signals are reduced into one signal. This signal is continued by Flatten once, and then eventually received by Pipeline subscribers.

The result of this code execution is as follows (or completely different, which is normal, as is the case with combineLatest operations) :

2016-04-28 18:48:14:453 [com.ReactiveCocoa.RACScheduler.backgroundScheduler] NSTaggedPointerString, A-Z-N
2016-04-28 18:48:14:453 [com.ReactiveCocoa.RACScheduler.backgroundScheduler] NSTaggedPointerString, B-Z-N
2016-04-28 18:48:14:454 [com.ReactiveCocoa.RACScheduler.backgroundScheduler] NSTaggedPointerString, C-Z-N
2016-04-28 18:48:14:455 [com.ReactiveCocoa.RACScheduler.backgroundScheduler] completed
Copy the code

Version 3

In addition to combineLatest, the ZIP operation can reduce multiple Signals into one, but the ZIP strategy is different.

- (void)testCollectSignalsAndCombineLatestOrZip { RACSignal *numbers = @[@(0), @(1), @(2)].rac_sequence.signal; RACSignal *letters1 = @[@"A", @"B", @"C"].rac_sequence.signal; RACSignal *letters2 = @[@"X", @"Y", @"Z"].rac_sequence.signal; RACSignal *letters3 = @[@"M", @"M"].rac_sequence.signal; NSArray *arrayOfSignal = @[letters1, letters2, letters3]; [[[[numbers map:^id(NSNumber *n) { return arrayOfSignal[n.integerValue];  }] collect] flattenMap:^RACStream *(NSArray *arrayOfSignal) { return [RACSignal zip:arrayOfSignal reduce:^(NSString *first, NSString *second, NSString *third) { return [NSString stringWithFormat:@"%@-%@-%@", first, second, third]; }];  }] subscribeNext:^(NSString *x) { DDLogVerbose(@"%@, %@", [x class], x); } completed:^{ DDLogVerbose(@"completed"); }];  }Copy the code

The result of this code execution is something like this, unlike the previous combineLatest, zip operation, which can only occur in the following unique case:

2016-04-28 18:55:01:208 [com.ReactiveCocoa.RACScheduler.backgroundScheduler] NSTaggedPointerString, A-X-M
2016-04-28 18:55:01:209 [com.ReactiveCocoa.RACScheduler.backgroundScheduler] NSTaggedPointerString, B-Y-N
2016-04-28 18:55:01:209 [com.ReactiveCocoa.RACScheduler.backgroundScheduler] completed
Copy the code

Save the profile picture of the contact

The previous code is very abstract. Can this Pipeline be used in a business? Of course it can, as in this code:

- (RACSignal *)savaAvatar:(UIImage *)image withContact:(FMContact *)contact { NSParameterAssert(image ! = nil); NSParameterAssert(contact.contactItems.count > 0); RACSignal *addrs = [[contact.contactItems.rac_sequence map:^(FMContactItem *contactItem) { return contactItem.email;  }] signal]; return [[[[addrs map:^id(NSString *emailAddr) { return [[[[FMAvatarManager shareInstance] rac_setAvatar:emailAddr image:image] map:^id(id value) { return RACTuplePack(value, nil);  }] catch:^RACSignal *(NSError *error) { return [RACSignal return:RACTuplePack(nil, error)]; }];  }] collect] flattenMap:^RACStream *(NSArray *arrayOfSignal) { return [[RACSignal zip:arrayOfSignal] map:^id(RACTuple *tuple) { return [tuple allObjects]; }]; }] map:^id(NSArray *value) { return value; }]; }Copy the code

This code is a little bit complicated. What it does is to associate all email addresses in FMContact with an image and save it on the server side. The key points are:

  1. Convert all emails in contact.contactItems to send signal.
  2. Every time I map, I get an email address, Call [[FMAvatarManager shareInstance] rac_setAvatar:emailAddr image:image] to associate the email address with the image. This interface also returns a signal, On success, next sends a value (business does not care about the value, only the success), and if it fails, an error is sent. If there is no special processing for error, the whole Pipeline will be error when an error is encountered. Some businesses need this default way to handle error (error occurs in any one of n small tasks, the whole Pipeline will be error). However, our business here does not want this effect. If an operation on an email fails, we do not want the whole Pipeline to end due to this error. Instead, we want the rest of the email addresses to continue performing their own small tasks. All errors are handled by the Pipeline subscribers together, where a catch operation is required.Rac_setAvatar needs to pass in an image and an email address each time, and then call the server interface to save the image. This kind of interface is not very elegant. For each email address, you have to send the image again. This is a historical problem. A better solution would be to upload the image to the server and get a unique value for the image, such as id, and then in this case, just associate the id of the image with the email. However, this does not affect the design of the Pipeline, whether image or ID, the Pipeline shape is the same.
  3. In catch, replace the old signal with a new signal. Because the error needs to be saved for last processing, it is packaged with RACTuple and sent.
  4. [[FMAvatarManager shareInstance] rac_setAvatar:emailAddr image:image] It is also a reasonable design to wrap the data sent by next with error in RACTuple (in case you need to use this value later), error is nil when received to Next, error is nil when error occurs, next is nil. So in this case, RACTuplePack(value, nil) was returned, whereas in the previous 3, RACTuplePack(nil, error) was returned.
  5. Use the collect operation. Note that map returns a signal, signal next sends an RACTuple, and Collect sends next NSArray.
  6. Collect collects the nested signal from the map and collects the nested signal from the array. Therefore, merge the signal from the array into one and then flatTen it out. The ZIP operation fits our requirements.
  7. Instead of using the + (instancetype)zip:(id)streams reduce:(id (^)())reduceBlock interface as demonstrated in the previous code, start with + (instancetype)zip:(id)streams, Map, since the input parameter streams(array) contains a known number of elements, can list all the parameters in reduce. The number of elements in arrayOfSignal is not fixed, so you have to use the original ZIP interface, and then work on the Zip sent RACTuple in the map.
  8. The RACTuple in this map is returned by the ZIP operation. Each data contained in this tuple is the RACTuple returned by the previous 4. The RACTuple in this tuple also contains the RACTuple, so don’t get confused. If you’re not sure where the data came from, you can go back and look at the previous steps. To facilitate subsequent processing, the data from the outer RACTuple can be put into an NSArray and then returned to the next link. [tuple allObjects] does just that.
  9. Return value directly to the Pipeline’s subscribers to get the final result. I’m not doing anything extra here, just to show you that this is an NSArray. You can add some logs here for easy debugging. It is possible not to perform this map operation.

The form page

Here’s another real business:

This is a page for editing contacts, the whole is implemented with UITableView, can dynamically add and delete fields, there is a requirement, only when at least one field has data, the upper right corner “save” button can be used. This requirement is easy to implement if the page does not need to dynamically add or subtract fields, but if you do not need to use UITableView, even if you need to dynamically add or subtract fields, this requirement is not difficult to implement. But now the problem is, it’s a little bit more complicated to implement on top of a UITableView, UITableViewCell is reusable, so you can’t rely directly on the UITextField inside the UITableViewCell to determine if the save button is available, We must strictly follow the MVC approach, first map all operations on the UI (add, subtract, edit fields) to the Model, and then calculate whether the “save” button is available. The UITableView code is a mix of traditional code and RAC code. RAC doesn’t do much, except send the contents of the UITextField to the signal, because it’s not complicated. So I will not discuss it in detail here, but mainly look at the Pipeline based on model construction:

- (void)initPipline { @weakify(self); RACSignal *emailsIsNil = [[RACObserve(self.contact, contactItems) flattenMap:^id(NSMutableArray *items) { if (items.count == 0) { return [RACSignal return:[NSNumber numberWithBool:YES]];  } return [[[items.rac_sequence.signal map:^id(FMContactItem *item) { return [[RACObserve(item, email) distinctUntilChanged] map:^id(NSString *email) { return [NSNumber numberWithBool:(email.length == 0)]; }];  }] collect] flattenMap:^id(NSArray *arrayOfBoolSignal) { return [[[RACSignal combineLatest:arrayOfBoolSignal] map:^id(RACTuple *tuple) { BOOL b = YES; for (NSUInteger i = 0; i < tuple.count;  i++) { NSNumber *n = [tuple objectAtIndex:i]; b = b && n.boolValue; } return [NSNumber numberWithBool:b];  }] distinctUntilChanged]; }]; }] distinctUntilChanged]; RACSignal *phonesIsNil = [[RACObserve(self.contact, telephone) map:^id(NSMutableArray *phones) { if (phones.count == 0) { return [NSNumber numberWithBool:YES];  } for (NSString *phone in phones) { if (phone.length > 0) { return [NSNumber numberWithBool:NO];  } } return [NSNumber numberWithBool:YES]; }] distinctUntilChanged]; RACSignal *addressIsNil = [[RACObserve(self.contact, familyAddress) map:^id(NSMutableArray *addrs) { if (addrs.count == 0) { return [NSNumber numberWithBool:YES];  } for (NSString *addr in addrs) { if (addr.length > 0) { return [NSNumber numberWithBool:NO];  } } return [NSNumber numberWithBool:YES]; }] distinctUntilChanged]; RACSignal *customInfosIsNil = [[RACObserve(self.contact, customInformations) flattenMap:^id(NSMutableArray *infos) { if (infos.count == 0) { return [RACSignal return:[NSNumber numberWithBool:YES]];  } return [[[infos.rac_sequence.signal map:^id(FMCustomInformation *info) { RACSignal *nameSignal = [[RACObserve(info, name) distinctUntilChanged] map:^id(NSString *name) { return [NSNumber numberWithBool:(name.length == 0)]; }];  RACSignal *infoSignal = [[RACObserve(info, information) distinctUntilChanged] map:^id(NSString *i) { return [NSNumber numberWithBool:(i.length == 0)]; }];  return [RACSignal combineLatest:@[nameSignal, infoSignal] reduce:(id)^id(NSNumber *name, NSNumber *info){ return [NSNumber numberWithBool:(name.boolValue && info.boolValue)]; }];  }] collect] flattenMap:^id(NSArray *arrayOfBoolSignal) { return [[[RACSignal combineLatest:arrayOfBoolSignal] map:^id(RACTuple *tuple) { BOOL b = YES; for (NSUInteger i = 0; i < tuple.count;  i++) { NSNumber *n = [tuple objectAtIndex:i]; b = b && n.boolValue; } return [NSNumber numberWithBool:b];  }] distinctUntilChanged]; }]; }] distinctUntilChanged]; RACSignal *nickIsNil = [[RACObserve(self.contact, nick) map:^id(NSString *nick) { @strongify(self);  if (self.contact.nick == nil || [self.contact.nick isEqualToString:@""] == YES) { return [NSNumber numberWithBool:YES];  } return [NSNumber numberWithBool:NO]; }] distinctUntilChanged]; RACSignal *markIsNil = [RACObserve(self.contact, mark) map:^id(NSString *mark) { @strongify(self);  if (self.contact.mark == nil || [self.contact.mark isEqualToString:@""] == YES) { return [NSNumber numberWithBool:YES];  } return [NSNumber numberWithBool:NO]; }]; RACSignal *birthdayIsNil = [RACObserve(self.contact, birthday) map:^id(NSString *birthday) { @strongify(self);  if (self.contact.birthday == nil || [self.contact.birthday isEqualToString:@""] == YES) { return [NSNumber numberWithBool:YES]; } return [NSNumber numberWithBool:NO]; }]; NSArray *allSignal = @[nickIsNil, emailsIsNil, markIsNil, phonesIsNil, addressIsNil, birthdayIsNil, customInfosIsNil]; self.contactHasNoPros = [[[[RACSignal combineLatest:allSignal] map:^id(RACTuple *tuple) { BOOL b = YES;  for (NSUInteger i = 0; i < tuple.count; i++) { NSNumber *n = [tuple objectAtIndex:i]; b = b && n.boolValue;  } return [NSNumber numberWithBool:b]; }] distinctUntilChanged] deliverOnMainThread]; }Copy the code

This section of code is a bit long, but fear not, there is a large chunk of code in the middle that does similar things. Just look at one of them. Take the email field for example:

  1. The contact field is divided into several parts, such as the email array, the phone number array, the remarks field, and so on. The processing logic for each part is similar.
  2. When adding or deleting an email, the UITableView part of the code has already done the corresponding action on the FMContact.contactItems array. Here we KVO the model with RACObserve. You get an array of FMContactItem.
  3. If the user deleted all email addresses (the FMContactItem array has 0 elements), emailsIsNil should be YES, indicating that the currently entered email has no value.
  4. If the FMContactItem array has a non-zero number of elements, convert the FMContactItem array to signal and send it.
  5. The UI module updates the FMContactitem. email field in real time, so use RACObserve to monitor the email field.
  6. The distinctUntilChanged operation acts as a filter, only forwarding the data sent by next to subsequent sections if the data sent by next differs from the data sent by the previous next.
  7. When given an email address, as long as the length of the email is greater than 0, the field is considered valid (there is no email validity check, and the “Save” button still works even if the email is not valid, except when the “Save” button is clicked, To check if the email is reasonable and valid, as the product requirements are designed to be).
  8. Use the collect. Collect collects all the signals in an array. Collect collects all the signals in an array. Collect collects all the signals in an array.
  9. Once you have an array of signals, you combine them into a single signal, and combineLatest meets that requirement.
  10. Specific product requirements are realized here. For example, if there are n email input boxes, email is considered to have no value only when all the input boxes have no content. As long as any email input box has content, email is considered to have value.
  11. The use of distinctUntilChanged in each of these places is intended to avoid unnecessary transmission of Signal data.
  12. There are several signals here, all with similar thinking and implementation.
  13. Put the different *IsNil signals into an array and combine them into one using combineLatest.
  14. Similar to 10, the contact has no value when all input fields are empty (pass the Bool via the sell.contacthasnopros signal) to fulfill the product’s agreed requirement.

The next signal subscriber can set the status of the button as Bool. The next signal subscriber can set the status of the button as Bool.

@weakify(self); [[self.contactEditView.contactHasNoPros not] subscribeNext:^(NSNumber *x) { @strongify(self);  self.navigationItem.rightBarButtonItem.enabled = x.boolValue; }];Copy the code

When contactHasNoPros send YES, it means that all the contact fields have no values, and the “Save” button should be unavailable if there are no values. Then set the Enabled status of the button.