Article summary
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.
What if the centralManager was a singleton ?
Calvin,
I don’t think that would make any difference. The important part is that the signal returned from
bluetoothStateSignal
property never completes. And because of that, if you use one of the replay operators you’ll introduce a memory leak if you don’t have atakeUntil
before the replay.