ReactiveCocoa and object lifetimes
I’ve been doing more and more with ReactiveCocoa. It’s changing a lot of how I’m thinking about code in iOS, and making some things much simpler.
There’s more to be said there, but I’ll start today with what I learned about object lifetimes.
I’ve got an app that plays a short sound when the user hits a button. Because
AVAudioPlayer
is a pain in the behind, I wrapped it in a class called
SoundPlayer
to try and hide some of the complexity. In particular, I don’t
care if the sound ends prematurely. The money method in the class is this:
- (void)playWithCompletionBlock:(void(^)())completionBlock
andProgressBlock:(void(^)(NSTimeInterval current, NSTimeInterval duration))progressBlock;
Tell me when it’s done and what the progress is, otherwise leave me alone.
Given that method signature, this was a natural to wrap in a RACSignal
:
- (RACSignal *)playSoundSignal
{
RACSignal *soundSignal = [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
SoundPlayer *soundPlayer = [[SoundPlayer alloc] initWithURL:self.meow.soundFileURL];
[soundPlayer playWithCompletionBlock:^{
[subscriber sendCompleted];
} andProgressBlock:^(NSTimeInterval current, NSTimeInterval duration) {
[subscriber sendNext:@(current / duration)];
}];
return nil;
}];
return soundSignal;
}
All well and good. The signal sends the progress information, and completes
when the sound’s done playing. Since the SoundPlayer
class doesn’t have any
way to cancel, I figure I may as well just return nil
for the disposable.
Compile, build, run, boom. Weird behavior - the completion block never gets
called, sometimes it crashes, etc. Fire up Instruments with the Zombies
template - and that SoundPlayer
object is getting sent signals after it’s
been deallocated. Huh-wha?
Poking around, it appears that the SoundPlayer object gets released at the
end of the block passed to createSignal:
. At that point, the only thing with
a reference to it is the timer it uses internally to decide when to send
progress updates. When the sound finishes playing and I invalidate the
timer - boom, whole object goes away, right in the middle of that method!
So okay - how do I add a reference? Playing around a bit, I change the subscriber block so it returns a disposable:
return [RACDisposable disposableWithBlock:^{
[soundPlayer description];
}];
Problem goes away! But wait - the code that subscribes to this signal doesn’t hang on to the disposable. Why did this work?
The answer I found deep in the memory management documentation.
When the signal is created, the ReactiveCocoa library itself keeps a reference
to the RACDisposable
created at subscription time. When the signal finishes
completing, it calls dispose
on it for you.
To my mind, this changes the semantics of RACDisposable
a little bit. It’s
not just a place to clean up after your signal, or to let users of the signal
cancel an operation in process. It’s also a way to ensure that resources
your signal uses aren’t disposed of until everything’s done.