To be honest, I started using ReactiveCocoa because it is fashionable. I hear iOS developers talking about this framework all the time, and I can hardly remember iOS Meetup without mentioning ReactiveCocoa.
When I first started learning ReactiveCocoa I didn’t know what it was. “Reactive” sounds really great, and “functional” sounds clever. But after I succumbed to the temptation to master Reactive Cocoa, I can no longer imagine writing code without using it.
ReactiveCocoa is a framework that opens a window into the world of functionally reactive programming. It allows you to benefit from the practical application of this paradigm, without even requiring a deep theoretical knowledge of FRP.
I mastered ReactiveCocoa on a real project, doing some simple things, at first I used it to solve two problems, which I will tell you in this article. I will say “what to do” and not “how to do” so that you can get a practical understanding of the framework.
')
1. Links
Understanding ReactiveCocoa usually starts with connections. After all, they are the easiest thing a newbie can understand.
The links themselves are only a supplement to the existing KVO mechanism in Objective-C. Is there anything new that ReactiveCocoa brings to KVO? This is a more convenient interface, it also adds the ability to describe the rules for linking the state of the model and the state on the UI in a declarative style.
Let's look at the connections on the example of a table cell.
Typically, a cell is attached to the model and displays its visual state (or ViewModel state for MVVM adepts). Although, ReactiveCocoa is often viewed in a single context with MVVM and vice versa, it really does not matter. Communication is simply a way to make your life easier.
- (void)awakeFromNib { [super awakeFromNib]; RAC(self, titleLabel.text) = RACObserve(self, model.title); }
This is a declarative style. “I want the text of my label to always equal the value of the
Title of my model” - in the
-awakeFromNib method. It doesn't really matter when the
title or model changes.
When we look at how it works internally, we find that
RACObserve is a macro that takes a path ("
mode.title " from the
self object in our case) and converts it to
RACSignal .
RACSignal is an object of the ReactiveCocoa framework that represents and provides future data. In our example, it will deliver data from
model.title each time the
title or model changes.
We will talk about signals a little later, since there is no need to go into details at this stage. Currently, you can simply “link” the state of the model with the interface and enjoy the result.
Quite often, you will need to transform the state of a model to display its state on the UI. In this case, you can use the
-map operator:
RAC(self, titleLable.text) = [RACObserve(self, model.title) map:^id(NSString *text) { return [NSString stringWithFormat:@”title: %@”, text]; }]
All operations with UI must be performed in the
main thread . But, for example, the
title field can be changed in the background stream (i.e., during data processing). Here is what you need to add in order for the new
title value to be delivered to the subscriber on the main stream:
RAC(self, titleLabel.text) = [RACObserve(self, model.title) deliverOnMainThread];
RACObserve is an extended macro
-rac_valuesForKeyPath: observer: But here’s a trick - this macro always captures
self as an observer. If you use
RACObserve inside a block, you must make sure that you do not create link cycling and use a weak link. ReactiveCocoa has convenient
@weakify and
@strongify macros for these needs.
One more detail about which you need to warn about connections is the case when your model state is tied to some significant changes in the user interface, as well as to frequent changes in the model state. This can adversely affect the performance of the application and, to avoid this, you can use the
-throttle operator
: - it accepts
NSTimeInterval and sends the
“next” command to the subscriber after a specified time interval.
2. Operations on collections (filter, map, reduce)
Operations on the collections were as follows, which we investigated in ReactiveCocoa. Working with arrays takes a lot of time, isn't it? While your application is running, data arrays come to you from the network and require modification so that you can present them to the user in the required form.
Raw data from the network must be converted to object or View Models, and displayed to the user.
In ReactiveCocoa, collections are represented as a class of
RACSequence . There are categories for all types of Cocoa collections that transform Cocoa collections into ReactiveCocoa collections. After these transformations, you will get several functional methods, such as
map, filter and reduce .
Here is a small example:
RACSequence *sequence = [[[matchesViewModels rac_sequence] filter:^BOOL(MatchViewModel *match) { return [match hasMessages]; }] map:^id(MatchViewModel *match) { return match.chatViewModel; }];
First, we filter our view models to select those that already have posts (
- (BOOL) hasMessages ). After that we have to turn them into other view models.
After you have finished the sequence, it can be converted back to NSArray:
NSArray *chatsViewModels = [sequence array];
Have you noticed that we again use the
-map: operator? This time, though, this applies to
RACSequence , not
RACSignal , as it was with connections.
The most remarkable thing about the RAC architecture is that it has only two main classes -
RACSignal and
RACSequence , which have one parent -
RACStream . All the flow, and the signal is the impetus driving the flow (new values ​​are pushed to the subscribers and cannot be output), and the sequence is a retractable flow drive (provides values ​​when someone asks for them).
Another thing worth noting is how we link operations together. This is a key concept in
RAC , which also applies to
RACSignal and
RACSequence .
3. Work with the network
The next step in understanding the features of the framework is to use it for networking. When I talked about connections, I mentioned that RACObserve creates RACSignal, which represents the data that will be delivered in the future. This object is ideal for representing a network request.
Signals send three types of events:
- next is the future value / meanings;
- error - NSError * value, which means that the signal cannot be successfully completed;
- completed - indicates that the signal was completed successfully.
The service life of a signal consists of any number of
next events, and then one
error or
completed (but not both).
This is very similar to how we wrote our network requests using blocks. But what is the difference? Why replace conventional blocks with signals? Here are some reasons:
1) You get rid of the callback!This nightmare in the code occurs when you have several subqueries and each subsequent uses the result of the previous one.
2) You process the error in one place.Here is a small example:
Suppose you have two signals
- loginUser and
fetchUserInfo . Let's create a signal that “logs in” the user, and then receives its data:
RACSignal *signal = [[networkClient loginUser] flattenMap:^RACStream *(User *user) { return [networkClient fetchUserInfo:user]; }];
The
flattenMap block will be called when the
loginUser signal sends the
next event, and this value is passed to the block via the user parameter. In the
flattenMap block
, we take this value from the previous signal and produce a new signal as a result. Now, let's subscribe to this signal:
[signal subscribeError:^(NSError *error) {
It is worth noting that the
subscribeError block will be called if at least one of the signals does not work. If the first signal fails, the second signal will fail.
3) The signal has a built-in recycling mechanism (cancel).For example, often the user leaves the screen during its loading. In this case, the download operation must be canceled. You can implement this logic directly in the signal, and not store a link to this download operation. For example, a signal to load user data can be created as follows:
[[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) { __block NSURLSessionDataTask *task = [self GET:url parameters:parameters completion:^(id response, NSError *error) { if (!error) { [subscriber sendNext:response]; [subscriber sendCompleted]; } else { [subscriber sendError:error]; } }]; return [RACDisposable disposableWithBlock:^{ [task cancel]; }]; }]];
I intentionally simplified this code to show the idea, but in real life you shouldn’t have to grab
self in a block.
You can determine when a signal should be canceled with a signal reference:
[[networkClient loadChats] takeUntil:self.rac_willDeallocSignal];
or
[[networkClient loadChats] takeUntil:[self.cancelButton rac_signalForControlEvents:UIControlEventTouchUpInside]];
The most remarkable thing about this approach is that you should not keep references to the operations that you will use, only when you need to complete them. Therefore, the code looks more declarative, since there is no need to save the intermediate state.
Of course, you can also cancel the signal manually - just store the reference to the RACDisposable object (which is returned from the
subsribeNext / Error / Completed method) and call the
-dispose method directly when necessary.
Implementing a network client using signals is quite a broad topic of discussion. You can look at
OctoKit - a great example of how Reactive Cocoa is used to solve network issues. Ash Furrow also covered this topic in his book
Functional Reactive Programming for iOS .
4. Signals in action
In solving some problems, we combined parts of data and events from different parts of the application. Data appears and changes asynchronously. If we think about them imperatively, we try to anticipate which additional connections and variables should come in the code and, more importantly, how to synchronize it all in time.
When we have formulated an approximate chain of actions that need to be completed, we begin to write code, and various parts of a class or even several classes are contaminated with new lines of code, if statements, useless states that “roam” around our project like gypsy caravans.
You know how hard it is to parse such code! And sometimes the only way to figure out what is going on is to debug step by step.
After some time working with Reactive Cocoa, it came to me that the basis for solving all the problems mentioned above (linking, collection operations, networking) represents the application life cycle as a data stream (RACStream). Then the data coming from the user or from the network must be converted in a certain way. It turns out you can solve the tasks much easier!
Let's look at two examples.
Task # 1This is an example from a real project that we recently completed.
We had the ability to exchange messages and one of the tasks was to display the correct number of unread messages on the application icon. An ordinary task, isn't it?
We had a class of
ChatViewModel from which stored logical property
unread .
@interface ChatViewModel : NSObject @property(nonatomic, readonly) BOOL unread // other public declarations @end
And somewhere in the code, we had an array of dataSourc containing these view models.
What do we want to do? We want to update the number of unread messages every time the unread property changes. The number of elements must be equal to the number of YES values ​​in all models. Let's do this with a signal:
RACSignal *unreadStatusChanged = [[RACObserve(self, dataSource) map:^id(NSArray *models) { RACSequence *sequence = [[models rac_sequence] map:^id(ChatViewModel *model) { return RACObserve(model, unread); }]; return [[RACSignal combineLatest:sequence] map:^id(RACTuple *unreadStatuses) { return [unreadStatuses.rac_sequence foldLeftWithStart:@0 reduce:^id(NSNumber *accumulator, NSNumber *unreadStatus) { return @(accumulator.integerValue + unreadStatus.integerValue); }]; }]; }] switchToLatest];
This may look a little tricky for beginners, but it is fairly easy to understand.
First, we observe changes in the array:
RACObserve(self, dataSource)
This is important because it is assumed that new chats can be created and old ones can be deleted. Since there is no RAC KVO for variable collections, the DataSource is an immutable array every time an object is added / deleted from / to the dataSource. RACObserv will return a signal that will return a new array each time a new value is added to the dataSource.
Well, we got a signal ... But this is not the signal we wanted, so we have to transform it. Operator
-map: perfect for this task.
[RACObserve(self, dataSource) map:^id(NSArray *models) { }]
We got a lot of models in the map block. Since we want to know about every change in the
unread property of all models, it seems that we still need a signal, or even an array of signals - one signal for each model:
RACSequence *sequence = [[models rac_sequence] map:^id(ChatViewModel *model) { return RACObserve(model, unread); }];
There is nothing new here.
RACSequence, map, RACObserve .
Note: In this case, we will convert our sequence of values ​​into a sequence of signals.In fact, we do not need so many signals, we need one, but significant, as constantly changing data must be processed together. There are several ways to combine signals in RAC, and you can choose the one that suits your needs.
+ merge , will forward the values ​​from our signals to a single stream. This does not exactly meet our needs, in the next block we will see only the last value (in our case,
YES or
NO ).
Since we want to know all the values ​​(in order to get their sum), let's use
+ combineLatest : It will monitor the change of signals, and then send the last value of all signals when a change occurs. In the next block, we can see a “snapshot” of all our unread values.
[RACSignal combineLatest:sequence];
Now we can get an array of recent values ​​each time a single value changes. Almost over! The only task left is to calculate how many times the value
YES occurs in this array. We can do this with a simple loop, but let's be functional to the end and use the reduce operator. reduce is a well-known function in functional programming that converts data collection into a single atomic value by a predetermined rule. In RAC, this function is
-foldLeftWithStart: reduce: or
-foldLeftWithStart: reduce : .
[unreadStatuses.rac_sequence foldLeftWithStart:@0 reduce:^id(NSNumber *accumulator, NSNumber *unreadStatus) { return @(accumulator.integerValue + unreadStatus.integerValue); }];
The last thing that remains unclear is, why do we need
switchToLatest ?
Without it, we get the signal of the signals (since we convert the value of the array into a signal), and if you subscribe to unreadStatusChanged, you will receive the signal in the next block, not the value. We can use either
-flatten or
-switchToLatest (which is
flattened , but with a slight difference) to fix this.
flatten means that the subscriber who is currently
flattened will receive the values ​​sent using the signal that is returned from the transformation. While -flatten receives a signal from a signal and combines them together with the following values ​​sent to any of them,
-switchToLatest does the same, but only redirects values ​​from the last signal.
In our case, this works better since we do not want to receive changes from the old version of our
dataSource . The signal is ready and we can use it, let's make a side effect:
RAC([UIApplication sharedApplication], applicationIconBadgeNumber) = unreadStatusChanged;
Have you noticed how the problem statement is related to the code? We just wrote declaratively what we want to do in one place. We do not need to maintain an intermediate state.
Sometimes you have to go into the framework documentation to find the right operators to formulate a custom signal, but it's worth it.
Task # 2Here is another task that demonstrates the capabilities of the framework. We had a chat list screen, and the task was: with the screen open with the chat list, display the latest chat message. Here is what the generated signal looks like:
RACSignal *chatReceivedFirstMessage = [[RACObserve(self, dataSource) map:^id(NSArray *chats) { RACSequence *sequence = [[[chats rac_sequence] filter:^BOOL(ChatViewModel *chat) { return ![chat hasMessages]; }] map:^id(ChatViewModel *chat) { return [[RACObserve(chat, lastMessage) ignore:nil] take:1]; }] ; return [RACSignal merge:sequence]; }] switchToLatest];
Let's see what it consists of.
This example is very similar to the previous one. We observe an array, the map operator converts values ​​into a signal, and taking into account only the latest signals.
First we filter our
dataSource in the transform block, because we are not interested in chats that have messages.
Then we convert the values ​​into signals, again using
RACObserve .
return [[RACObserve(chat, lastMessage) ignore:nil] take:1];
Since the signal generated by
RACObserve will start with an initial property value that is nil, we must ignore it. -Ignore :. The operator is what we need.
The second part of the task is to consider only the first incoming message
-Take:. Will take care of this. The signal will be completed (and deleted) immediately after receiving the first value.
Just to clarify everything. There are three new signals that we have created in this code. The first one was created by the
RACObserve macro, the second on the
-ignore call
: the operator on the first newly created signal, and the third on the
-take call
: on the signal created by
-ignore:As in the previous example, we need one signal based on the generated signals. We use
-merge: to create a new merged stream, since we do not care about the values, as in the previous example.
Side effect time!
[chatReceivedFirstMessage subscribeNext:^(id x) {
Note: We do not use the values ​​that come in the signal. However, x will contain the received message.Now let's talk a little about the impressions of Reactive Cocoa.
What I really like about Reactive Cocoa1. It's easy to start using in projects. The framework is documented like crazy. There are many examples on GitHub, with a detailed description of each class and method, a large number of articles, videos and presentations.
2. You do not need to completely change your programming style. First, you can use existing solutions for problems, such as UI bindings, network wrappers, and other solutions with GitHub. Then, step by step, you can understand all the features of Reactive Cocoa.
3. It really changes the way to solve problems from imperative to declarative. Now that the functional programming style is becoming more and more popular in the IOS community, it is difficult for many to radically change their way of thinking. Reactive Cocoa helps you make changes because it has many tools that can help you communicate in “what to do” style, not “
how to ”.
What I don't like about Reactive Cocoa1. Extensive use of macros RAC () or RACObserve ().
2. Sometimes it can be difficult to debug the code, since using RACSignals leads to deep stack traces.
3. Not
type-safe (you never know what type to expect in the
subscribeNext block). The best solution in this case is to document the signals in the public interface, as an example:
-(RACSignal *)loginUser;
I also can not fail to mention SwiftReactive Cocoa is written in Objective-C and specifically for Objective-C. But, of course, now, when Swift is gaining popularity, framework developers do not sit idle. They actually write the Swift API, for use with Reactive Cocoa (Great Swiftening Close). Modestly, we see the new version 3.0 with blackjack and whores, generics and operator overloading.
I'm sure after this the RAC will get even more fans. Soon, perfectionists who curse macros and non-security types will have no arguments to protect themselves and not use Reactive Cocoa.
Conclusion
A functional reactive approach can simplify solutions for your daily tasks. Perhaps, first, the concept of RAC may seem too complicated, its decisions are too cumbersome, and huge, and the number of operators will confuse you greatly. But later, it will become clear that all this has a simple idea.
You present the data (input or output) as a stream.
Flow, as a pipe of events (data, errors, verification). The signal and sequence are stream. Signal controlled flow: when it has something, it will transfer it to you, user input, asynchronous booting from disk, for example. The sequence is a pull-out drive: when you need something, you will draw it from the sequence — collections, for example. All other transformation and union operators all of these things together (pre-formation, filter, union, switchToLatest, choke, etc.).In addition, you should remember that all events are delivered to subscribers in the stream in which they were created. If you need to specify that, apply this RACScheduler (a class similar to GCD queues, but with the option to cancel) using the -deliverOn operatorA:As a rule, you only need to explicitly specify [RACScheduler mainThreadScheduler] to update the interface, but you can write your own implementation of RACSceduler when you are dealing with something specific, like CoreData .