For the last couple months I’ve been doing a lot of ReactiveCocoa for different iOS projects and let me tell you; it is pretty fucking awesome!!!. I know it’s been around for a couple years … yeah yeah Swift is the big thing now, Objective-C is dead and the Swift version of ReactiveCocoa is around the corner (maybe).
I don’t actually think Objective-C is dead and I am probably still going to use it, at least until the libraries get a little more mature and XCode stops crashing 10 times per day.
This post is NOT about how happy I feel using ReactiveCocoa or how it feels so right using it to implement the MVVM pattern. This post is about something I learned about RACCommand’s internals.
As you may know RACCommand
is an abstraction that models a command, a user initiated action that may have some side effects. You can create a new RACCommand
object using the initWithEnabled:signalBlock:
initializer method. This method receives a signal as the first parameter and a block that receives an input and returns a signal as the second parameter.
The block will be called every time the execute:
method is invoked on the RACCommand
object. The object that receives the block is the object that is passed to execute:
. The signal returned by the block will be the signal returned by execute:
.
The signal is used to decide whether the command is enabled or disabled. This is pretty useful because when you do something like self.button.rac_command = self.viewModel.someCommand
the enabled property of the button is automatically changed when the command is enabled or disabled, avoiding all the boilerplate code to keep the button state synced.
Assuming we have the following interfaces
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
a possible implementation for the login command could be
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
Based on this implementation, unless the username has 5 characters and the password has 4 characters the login command will not be enabled. Executing a disabled command (calling its execute:
method) will result in a signal that will error with domain RACCommandErrorDomain
and code RACCommandErrorNotEnabled
.
Now lets analyze a different example of how to use a RACCommand
. Lets take for instance pagination. Most of the apps nowadays have some kind of newsfeed or activity stream. A very simple implementation of this view could fetch all the required data and display it on a UITableView
. This could work pretty well if the data that needs to be displayed is not really big. But if we are talking about something like the Twitter’s newsfeed doing just one query to the backend service to display all the user’s newsfeed could result in a DOS or at least it would take a lot of time to answer. This is good situation to apply pagination.
We can implement a simple view model that knows how to display a paginated list and later we can bind that view model against a UITableViewController
. We can call that view model TableViewModel
and a naive implementation of that view model could be
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
|
As you can see the implementation of TableViewModel
is pretty simple, the important part is in the private performFetch
method that is called inside the signal block associated with the fetchNextPage
command. We are using replay before returning the signal in performFetch
to cache the result of the map and
avoid the execution of the side effects (to increase the nextPage
counter) in case several subscriptions get created to this signal.
Now that we have implemented TableViewModel
it’s time to test it and in order to do that I use Specta + Expecta matchers + OCMockito. For the purpose of this blog post I am only going to show a reduced version of the TableViewModelSpec
.
The following spec asserts that after calling fetchNextPage
the page counter gets increased. In this case we are calling fetchNextPage
3 times thus making the last value of requestPage
equal to 2 (because we have requested page 0, 1 and 2). We only want to fetch a page after the previous page was successfully fetched. That is why we are using concat:
because it will subscribe to the concatenated signal after the first signal has completed. completionSignal
is a signal that will first fetch page 0 and after it’s completed it will fetch page 1 and after it’s completed it will fetch page 2 and then will complete. If any of the concatenated signals errors completionSignal
, will error immediately.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
|
Unfortunately if you run the previous spec you will get the following error
failed to invoke done() callback before timeout (10.000000 seconds)
meaning that for some reason the subscribed block never got executed and the only way that that could’ve happened is if one
of the concatenated signals has failed. To verify this theory we can subscribeError:
instead of subscribeCompleted
.
When I did this I realized that indeed the signal was sending an error and the error code was RACCommandErrorNotEnabled
.
This is super weird because the fetchNextPage
command is enabled/disabled based on the consumedAllPages
property and
the only way this could be set to NO
is if the fetcher’s signal returns an empty array and that is impossible because
we are using a fake fetcher that always returns a non-empty array.
Digging a little bit inside the internals of RACCommand
I realized that execute:
does not actually use the given signal to
decide if the command can be executed or not. (Check this line and also this line). When the execute:
method is invoked, it first gets a value from immediateEnabled
which is a combination of the provided enabled signal
and another signal which is basically based on allowsConcurrentExecution
. immediateEnabled
sends YES
if
the enabled signal sends YES
and if allowsConcurrentExecution
is NO
(which is the default) the executing
property must
be NO
.
What is happening and causing the test to fail is that when execute:
gets invoked for the second time the change on
the executing
signal has not been propagated yet and although the first invocation of execute:
has finished the
internal state of the RACCommand
does not reflect that.
In a real-case scenario this is virtually impossible to happen. At least if the RACCommand
is bound to an event
triggered by the user, because this would probably happen in two different run loops and by that time the change
on the executing
property would be propagated.
Finally the easy fix to make the test pass is to add the following statement in the beforeEach
block
tableViewModel.fetchNextPage.allowsConcurrentExecution = YES;
Which I think is a valid trade-off to be made. What do you guys think? Do you have a better solution?