ReactiveCocoa II: Reacting to Events
In my [previous post][rac-intro], I introduced [ReactiveCocoa][rac] and went over the basic steps of creating a SignalProducer
. I showed how you can process the events via the pipe forward operator |>
, and teased a little at the end about the various functional compositions you can work with. What I did not go into was how to react to those events. Given that this is functional reactive programming that we’re talking about, perhaps a follow-up on that is not the worst idea.
Even if it’s three weeks overdue.
• • • • •
As I discussed, events come in four varieties: .Next(T)
, .Error(E)
, .Completed
, and .Interrupted
. Working with a Signal
(which remember, has neither beginning nor end), we listen for those events by observing them:
someSignal.observe(next: {
data in
displayData(data)
})
Signal
s typically don’t have errors and don’t terminate, so that’s all we need to do for most signals.
SignalProducer
s, on the other hand, can run the full gamut. And since they are started on demand, their API is a little different:
someSignalProducer.start(next: {
data in
displayData(data)
}, error: {
error in
displayError(error)
}, interrupted: {
handleInterruption()
})
The start
and observe
methods have very similar calling conventions, and all take the parameters next:
, error:
, interrupted:
, and completed:
, which have default values that do nothing. That’s why in the last snippet, I was able to do nothing on completion: if I don’t need to inform the user that a request finished, there’s no need to include a parameter for it.
• • • • •
Okay, so now we know how to create signals and signal producers, and how to listen to them. Are we done?
Theoretically, yes. But in our work with RAC, there have been many patterns that have come up over and over again that are supported, but that are not always obvious to implement. To help maybe pave the way for others, I’m going to go over a few common examples.
Let’s get back to Comic Cathy. The data stored in her local store is missing the cover images, which she wants to load from the server. If you remember, her producer function was:
func comicCollectionProducer()
-> SignalProducer<[Comic], RetrievalError> {
let localFetchProducer = SignalProducer(result:localComics())
|> mapError(retrievalErrorFromStoreError)
let networkFetchProducer = SignalProducer.try(networkComics)
|> mapError(retrievalErrorFromNetworkError)
return localFetchProducer |> concat(networkFetchProducer)
}
What she wants to do is this: take every group of comics returned by the producer, and for each comic, request the cover image. At this point, we need to start making things a little more realistic. In order to do this, what Cathy has written is a SignalProducer
that will fetch the image and send events with the data (or any errors). She has a function that will return it:
func producerToFetchImageForComic(comic:Comic)
-> SignalProducer<UIImage, NSError>
The first problem is that this function takes a single Comic
, but we will be getting an array of Comics
. What we want, then, is to use this function to create a new function:
func producerToFetchImagesForComics(comics:[Comic])
-> SignalProducer<[UIImage], NSError>
We can’t use a plain old for-loop, because SignalProducer
s are asynchronous by nature. Instead, we have to break down the task into steps:
- Create a stream of individual comics from the array.
- Replace each comic in the stream with the corresponding image.
- When the stream completes, recombine the entire stream into one array of images.
ReactiveCocoa has functions that let us do each of these things. First off, much like it has a convenience initializer for a Result
, it also has a convenience initializer for an array of values:
let individualComicProducer =
SignalProducer<Comic, NSError>(values:comics)
When started, this producer will emit the values one by one and then complete. So that’s step one. Step three is also easy. RAC has a function called collect
that waits for a signal to complete, and then forwards all the values in an array. In effect, it’s the reverse of the convenience initializer and would look like this:
let comics:SignalProducer<Comic, NSError>
= individualComicProducer |> collect
As before, the pipe forward operator is a shorthand for collect(individualComitProducer)
, and returns a SignalProducer
. This is important: there is no way to get the values put into a producer out of it without using the start
function; in effect, the producer adds a context that can’t be discarded. But that’s okay, because our producerToFetchImagesForComics
function returns a producer.
This leaves step two in our list. That’s the trickiest one. What we want to do is:
- Take every comic handed to us.
- Call the
producerToFetchImageForComic
function. - Return the image we receive as a result of the producer.
In type terms we have a producer of type SignalProducer<Comic, NSError>
. And we have a function that turns Comic
values into SignalProducer<UIImage, NSError>
values. What we want is a SignalProducer<UIImage, NSError>
.
Hmm. We’ve seen this pattern before. It’s like what we want to do with Result
sometimes:
result:Result<T, E>
COMBINED_WITH
f:T -> Result<U, E>
SHOULD_RETURN
newResult:Result<U, E>
That’s our old friend flatMap!
newResult = result.flatMap { value in f(value) }
So does RAC have flatMap
for signals? Of course it does! RAC loves flatMap
! It loves flatMap
so much, it has three versions of it, depending on what behavior you want out of your signal. I can’t go into too much detail about what each does,1 so I’ll stick with the one that will preserve the order of the comics: flatMap(.Concat)
. It works like this:
let signalOfImages = individualComicProducer
|> flatMap(.Concat, producerToFetchImageForComic)
And therefore, the complete function looks like this:
func producerToFetchImagesForComics(comics:[Comic])
-> SignalProducer<[UIImage], NSError> {
return SignalProducer(values:comics)
|> flatMap(.Concat, producerToFetchImageForComic)
|> collect
}
Again, step back for a second and realize that it took me multiple paragraphs to explain three lines of code — which exactly match the three conceptual steps we wanted to execute. So while there was a lot of explanation for the details, the mapping from concept to code is almost one-to-one. Once you become fluent with the various functions in the API, you might not even think of things in ambiguous English steps — the transformations might naturally map into collect
s and takeWhile
s and signalOn
s and flatMap
s until running across a problem that doesn’t match those fundamentals becomes rare, and a warning sign that the problem statement is unclear.
• • • • •
Now Cathy has her function to get the images. How will she integrate it in her app? Ideally, she wants a producer that will return both the images and the comics, together. This is a trickier thing to get right, because it involves creating a producer that calls producers, which always feels dirty to me. Here’s how Cathy goes about it:
func comicsCollectionDisplayProducer()
-> SignalProducer<([Comic], [UIImage]), NSError> {
return SignalProducer { sink, disposable in
let disp = comicCollectionProducer()
|> start(next: {
comics in
let disp2 = producerToFetchImagesForComics(comics)
|> start(next: {
images in
sendNext(sink, (comics, images))
}
disposable.add(disp2)
}
disposable.add(disp)
}
}
We’re now seeing the use of that disposable
parameter. It exists to ensure that if the enclosing SignalProducer
errors out or is terminated, no events from the inner producers are propagated along the computational chain. In other words, it’s a way way of doing manual computation management. I don’t love it, but I haven’t found a better way to do it, so I accept it. The rule is: whenever you observe
or start
something, you should have worked out how to manage the Disposable
that is returned. It’s similar to retain/release management. In this case, the initializer provides a CompositeDisposable
to which Cathy can add any disposables she creates. The initializer is responsible for managing the disposable it provides.
Cathy’s implementation works great. It does exactly what it needs to do. And yet it still looks ugly. There’s a lot of deep nesting going on, a lot of closures getting called back and forth, and it generally feels clunky.
There are ways around this. For one, none of the solutions so far change the fundamental nature of what the code is doing: the new functions are composed from previously written functions. Because RAC makes composition natural, that’s a tendency that can go too far; sometimes the solution is a different approach to the problem. For instance, instead of producerToFetchImageForComic
, we could have a similar function that returned a tuple:
func producerToFetchInfoForComic(comic:Comic)
-> SignalProducer<(Comic, UIImage), NSError>
The tuple pairs the comic with its corresponding images — a very useful pairing. That change would propagate up the chain, and lead us to a top-level function whose signature would be:
func comicsCollectionDisplayProducer()
-> SignalProducer<[(Comic, UIImage)], NSError>
See if you can work out how. This pairs each comic with its corresponding image explicitly and is decidedly cleaner.2
This is only one approach to resolving the nesting issue, and what I want to emphasize is that the old [Haskell joke][haskell-joke] applies equally well to RAC: “An hour of meditation, followed by the emission of a single ‘fold’ expression.” Just because what you’ve come up with works doesn’t mean that it’s the best or cleanest way to do it. Trust your instinct if something feels ugly. There may be better ways of doing it.
Update: [Justin Spahr-Summers][jss] was nice enough to provide a much better implementation of comicsCollectionDisplayProducer()
:
func comicsCollectionDisplayProducer()
-> SignalProducer<([Comic], [UIImage]), NSError> {
return comicCollectionProducer()
|> flatMap(.Concat) { comics in
producerToFetchImagesForComics(comics)
|> map { images in (comics, images)
}
}
He’s using the form of flatMap
that takes an explicit closure, which I hadn’t thought about. I like it much better because is avoids the use of disposables entirely. Since this pattern comes up over and over again, I recommend learning it; I’m probably going to go back and look through some of our code and see if we can use it to clean things up.
• • • • •
With these two posts, you should have the basics to start writing code that leverages RAC. We have found it great to work with, despite some annoying warts, like a nagging feeling that disposables should not exist. While there is a steep learning curve, the [issues page][rac-issues] on the ReactiveCocoa project is full of active, helpful, and knowledgeable users who try their best to help newcomers. As the popularity of the framework grows (and I expect it to keep growing), so will the quality and promptness of the help available.
So give RAC a try. If you’re already familiar with functional programming, it will be like seeing an old friend. If you’re not, it will teach you new ways of thinking about your software. And in both cases, it will lead to very expressive code that is far more testable than the code that typically finds its way in iOS apps.
1 I literally can’t. I am at this point not even positive that all three version of flatMap
obey the three monad laws, but I believe they do. The most interesting thing is that there is yet another method, mapError
that is technically a flatMap
, but on the error part of the Signal
. All in all, SignalProducers
and Signals
are much more complex than regular monads, and I’m not going to pretend I can tell you much more about their theoretical underpinnings, though I suspect that would be a fantastic topic for a post or even paper.↩︎
2 We could get the same result by operating on the arrays we get from comicsCollectionDisplayProducer
, but that would assume that they are of the same size and in the same order. Written properly, that would include all sorts of error checking that this approach sidesteps.↩︎
[haskell-joke]: http://www.quora.com/What-are-some-unofficial-mottos-of-programming-languages
[rac-intro]: http://nomothetis.svbtle.com/an-introduction-to-reactivecocoa
[rac-issues]: https://github.com/ReactiveCocoa/ReactiveCocoa/issues
[rac]: https://github.com/ReactiveCocoa/ReactiveCocoa
[jss]: https://twitter.com/jspahrsummers