2 Comments

ReactiveCocoa – Cleaning Up after replay, replayLast, and replayLazily

A while back, I wrote a post comparing replay, replayLast, and replayLazily. Thanks to some investigating by Brian Vanderwal, I recently learned that one needs to be careful when using a replay operator (or multicast/connect directly) with an infinite signal as its source.

This blog post refers to the older ReactiveCocoa 2.x Objective-C library. I’m guessing that the newer Swift versions have the same behavior, but I don’t actually know for sure.

The Problem

Using a replay operator with an infinite signal as its source can become a problem when there is a screen that creates a new signal each time it is visited. When the user leaves the screen, all of the subscribers to the replayed signal will be disposed of normally (assuming proper use of the RAC macro or the takeUntil: operator). But anything from the replay back to the source, the signal, and whatever is being cached and replayed will stick around.

By definition, an infinite signal will never complete. When one of the replay operators is used on an infinite source signal, that “replayed” subscription will never be released. It doesn’t matter if all of the current subscribers unsubscribe or if there is an error downstream–for the lifetime of the app, that subscription will be hanging around.

Here’s an example of some code that would have this problem:


RACSignal *textSignal = [[centralManager.bluetoothStateSignal
    map:^id(NSNumber *state) {
        if (state.integerValue == CBCentralManagerStatePoweredOn) {
            return @"Powered On";
        } else {
            return @"Offline";
        }
    }]
    replayLast];


RAC(label1, text) = textSignal;
RAC(label2, text) = [textSignal map:^id(NSString *text) {
    return [text uppercaseString];
}];

The centralManager.bluetoothStateSignal is an infinite signal, always reporting the current state of Bluetooth on the device. That state is then mapped to an appropriate NSString. The replayLast operator tacked onto the end allows the subscriptions for both label1 and label2 to get the value without having to do the “work” twice (not really a big deal in this example, but you get the idea).

In this case, the RAC macro takes care of unsubscribing from the assigned signals when the label1 and label2 objects are deallocated.

But unlike a signal without a replay operator, the signal disposal stops when it gets back to the replayLast.

The Solution

The solution is actually quite simple (as long as you remember to do it): Never use replay, replayLast, or replayLazily on an infinite signal.

Fortunately, any signal can be made finite with the addition of the takeUntil: operator. Most of the time, I use takeUntil: with the self.rac_willDeallocSignal method available on any NSObject.

Here’s an update to the code snippet above, strategically using takeUntil: to make sure that the replayed subscription is properly disposed.


RACSignal *textSignal = [[[centralManager.bluetoothStateSignal
    map:^id(NSNumber *state) {
        if (state.integerValue == CBCentralManagerStatePoweredOn) {
            return @"Powered On";
        } else {
            return @"Offline";
        }
    }]
    takeUntil:self.rac_willDeallocSignal]
    replayLast];


RAC(label1, text) = textSignal;
RAC(label2, text) = [textSignal map:^id(NSString *text) {
    return [text uppercaseString];
}];

Conclusion

It makes sense that a signal resulting from one of the replay operators would not be disposed just because all of its subscribers dispose of their subscriptions. If that were to happen, the next subscriber that came along wouldn’t be able to get the replayed values. But this wasn’t obvious to me, and because I wasn’t careful enough with the replay operators, my app had a number of memory leaks over time.

So remember, always know how your subscriptions are going to finish. And when it comes to replay, replayLast, or replayLazily, that means making sure you know how the source signal is going to finish, as well.