Wednesday, May 7, 2014

Bringing It All Together: Notifications

There are some routine activities in iOS development that involve spreading related code across multiple methods. In my early days, I just took this for granted as The Way Things Are Done. As my skills and experience grew, some of these aspects started to rub me the wrong way, and eventually I became irritated enough to come up with my own solutions. So over the next couple posts, I'd like to describe those solutions and explain how they came to be.

 

The first topic is notifications, specifically setting up a UIViewController subclass to listen for them. I was initially set along the path of "something needs to change" by this post. Here the author argues against the use of what I call "Nuclear Unregister" in viewDidDisappear:, like so:

 

- (void)viewDidDisappear:(BOOL)animated
{
   [super viewDidDisappear:animated];

   [[NSNotificationCenter defaultCenter] removeObserver:self];
}

 

The author states - and I wholeheartedly agree - that this should only be done in dealloc. He describes how doing this in a UIViewController subclass also winds up unsubscribing from any notifications that UIViewController would be otherwise listening for, such as memory warnings. I think Apple has made some internal changes since this was written 3 years ago, as I was indeed still able to get memory warning notifications after doing this in iOS 7, but the larger point remains. Maybe the superclass is depending on some other notifications that are still in play when the view is off screen. Maybe future subclasses will, too. Going with the nuclear option means you are turning off listeners that you are not responsible for and possibly have no control over, and this happens at a point when the object in question is very much still alive and will likely be used again in the future. At minimum it is lazy, but on the other end of the spectrum you are stepping on toes that shouldn't be stepped on. Sure, your listeners will be reactivated in viewWillAppear, but if sub/superclasses had them defined in -init, they won't be coming back on again.

 

Whether or not you agree that this is an issue - and by this point, I certainly hope that you do - the argument was convincing enough for me to revisit lots of code where I had been doing it wrong. And I then moved forward with a strict policy of only unregistering the specific notifications that I had originally registered.

 

This means that NSNotificationCenter code gets spread out to numerous locations. The vast majority of them will be added in viewWill/DidAppear:

 

- (void)viewDidAppear:(BOOL)animated
{
   [super viewDidAppear:animated];

   NSNotificationCenter *notificationCenter = ...;
   [notificationCenter addObserver: selector: name: object: ];
   [notificationCenter addObserver: selector: name: object: ];
   [notificationCenter addObserver: selector: name: object: ];
   [notificationCenter addObserver: selector: name: object: ];
}

 

And then you have to remember to turn them off in viewWill/DidDisappear:

 

- (void)viewDidDisappear:(BOOL)animated
{
   [super viewDidDisappear:animated];

   NSNotificationCenter *notificationCenter = ...;
   [notificationCenter removeObserver: name: object: ];
   [notificationCenter removeObserver: name: object: ];
   [notificationCenter removeObserver: name: object: ];
   [notificationCenter removeObserver: name: object: ];
}

 

And this is just for the ones that need to be active only when on screen. If you need more that will be active for the life of this object, then you have to handle init. But if this view controller is created in a XIB file, it won't run through init, so you have to cover -awakeFromNib too. And then of course you do the nuclear unregister in dealloc. So that is potentially 5 different methods where some code will be placed. This is spreading things out too much for me.

 

Worse, there is nothing inherently linking any of these locations. Say you change your mind, and one of these viewDidAppear ones needs to go to init instead. Now you have to remember to remove the corresponding line from viewDidDisappear, because if you don't it could potentially be shut off early. Are you going to remember to do that every time? Will the new person on the project even know that they should? It is related code in multiple methods that contains no additional documentation about what the intent should be.

 

Also it's annoying to have to type out the removeObserver: lines again. Even if you copy-paste from the addObserver: lines, you still have to edit each one. The 3 parameters in removeObserver: are exactly the same as 3-out-of-4 of the addObserver: parameters, but you don't get any code-completion help typing out the combination. It's a situation just begging for mistakes.

 

That pretty much covers all of my pain points with notifications, so let's move on to my attempt to address them. I was not able to come up with a single class or technique that solves all of these issues. They are similar-but-different method calls that have to happen. They will need to be called at different times. It's a tough nut to crack, but I came up with a system that I feel does a really good job of making this process as simple as it can be. The classes involved are part of my BTIKit project, so you can just hop over there and look at code if you don't care to read any more.

 

Step 1 is basically to refactor the addObserver/removeObserver methods. If it can be broken down to as simple as "startListening" and "stopListening" that would be great. Naturally these methods do not contain enough information, as we're dealing with 3 or 4 parameters ultimately, so I need an object that can store the appropriate values until they are needed. Then they can be used by these simpler methods.

 

Enter BTINotificationInfo.

 

This class has properties for storing the observer, the selector, the name, and the object. But those are all private as they shouldn't be messed with after this object is created, so let's talk just about the initializer:

 

+ (instancetype)infoWithObserver:(id)observer
   selector:(SEL)selector
   name:(NSString *)name
   object:(id)object
   lifespan:(BTINotificationInfoLifespan)lifespan;

 

One of these objects is created with the exact same parameters that would be involved with addObserver. There is an additional lifespan value that I'll discuss in a moment. Then we do indeed have our simpler methods:

 

- (void)startListening;
- (void)stopListening;

 

The initializer places the parameters into the appropriate properties, and then these methods reference them as needed to do the addObserver/removeObserver stuff. Right off the bat, one potential source of error has been removed: the largely-duplicated parameters in removeObserver. Instead of appearing twice (add + remove), they appear once. The values are defined in one place. Then using this object, you can start/stop listening as much as you want, repeatedly, without having to type those values again.

 

Of course, add/remove observer are called from different methods typically, so you will want to keep this object around somewhere. As I said, I was not able to come up with a single-class solution. But this does serve as the building block for the entire system. And this object can be used standalone. An example would be in Core Data, where you might start listening for a managed object context save, do the save, and then stop listening. Rather than type out the parameters twice, just create one of these objects. startListening before, save, stopListening when finished. Easy.

 

I mentioned the additional lifespan parameter. With view controllers, the vast majority of notification needs fall into 2 categories: 1) Listen only when the view is on screen, or 2) Listen the entire time this view controller is alive. If there have been other situations, they didn't sink into my mind enough to care about. And then I haven't really had too many consistent notification needs outside of view controllers that follow any particular pattern. So the rest of the solution is geared towards view controllers, and this lifespan parameter is how you define which category you fall into. The value is an enum, with pre-defined values of "lifetime", "visible", and "custom". While defining all of the notification listener stuff, you can also define the period during which this listener should be active.

 

This addresses another one of my pain points. Instead of relying on the addObserver code to be in a particular method to explain the intent, now you explicitly define it. And it is easy to change your mind. The case I mentioned previously about moving the addObserver from viewDidAppear to init goes away. Change the lifespan value, done. Nothing else needs to change.

The BTINotificationInfo object doesn't use the lifespan value itself. Actually this is the only property that is allowed to be public (read-only), so that other classes can make use of it. For the vast majority of my needs, "other classes" have been view controllers. And a view controller makes up the other half of this solution: BTIViewController.

 

The first thing that BTIViewController provides is a single place to define ALL (or at least "most", certainly) of your BTINotificationInfo objects:

 

- (void)populateNotificationInfos NS_REQUIRES_SUPER;

 

This is subclass-friendly, so any view controllers subclassing this can define their own notification infos. This method will be automatically called when the view controller is created.

 

BTINotificationInfo has that lifespan property, so you can define all of your "visible" and "lifetime" notifications right along side each other, in the same method. Since everything is defined in one place, that reduces the amount of effort required to investigate all of your notification listener setup.

 

Two simple methods make the magic happen:

 

- (void)startListeningForNotificationsWithLifespan:(BTINotificationInfoLifespan)lifespan;
- (void)stopListeningForNotificationsWithLifespan:(BTINotificationInfoLifespan)lifespan;

 

These can be called manually if desired, but there probably isn't much of a reason to do so. BTIViewController is already set up to register the "lifetime" ones in init/awakeFromNib, and handle the "visible" ones in viewWill(Dis)Appear. Pretty much the only reason to call these methods manually would be to address any "custom" ones, but I will make a guess that it won't make much sense to handle special-case ones along with the others. I could be wrong.

 

BTIViewController has a primary method for adding notification infos:

 

- (void)addNotificationInfo:(BTINotificationInfo *)info;

 

...and then a whole bunch of shorthand helper methods that reduce the amount of code required even further. Let's look at a typical example:

 

[[NSNotificationCenter defaultCenter] addObserver:self
   selector:@selector(keyboardWillShow:)
   name:UIKeyboardWillShowNotification
   object:nil];

 

To do this same thing with the generic methods would be:

 

BTINotificationInfo *notificationInfo = [BTINotificationInfo infoWithObserver:self
   selector:@selector(keyboardWillShow:)
   name:UIKeyboardWillShowNotification
   object:nil
   lifespan:BTINotificationInfoLifespanVisible];
[self addNotificationInfo:notificationInfo];

 

Ok, so it's a bit wordier. Here come the shorthand methods to save the day:

 

[self addVisibleNotificationInfoForName:UIKeyboardWillShowNotification
   selector:@selector(keyboardWillShow:)
   object:nil];

 

Boom, less code. The shorthand methods assume that the view controller - self - will be the observer. And the lifespan is incorporated in the name of the method.

 

One thing I haven't talked about yet is block-based notification handlers. For one thing, I never considered separate methods for notification responders to be a big deal, so they don't really solve a significant problem for me. For another, you have to keep track of the goofy observer object in order to removeObserver. So between solving a problem I didn't think I had and actually creating some work I didn't otherwise need to do, I didn't give them much thought.

 

Then I realized that with BTINotificationInfo I'm already keeping around an object for the purpose of unregistering. Since I'm doing that, that object can keep track of the observer object, too. And suddenly block-based notification handlers got more interesting, so I decided to support them and also included shorthand methods in BTIViewController. Like so:

 

[self addVisibleNotificationInfoForName:UIKeyboardWillShowNotification
   object:nil
   usingBlock:^(NSNotification *notification) {
      // Resize/reposition some views
   }];

 

We've pretty much got the holy grail here. Everything, and I mean everything, defined in one place.

 

So the steps that you need to take in order to experience this awesomeness are:

  • Acquire BTIKit.
  • Make your view controllers inherit from BTIViewController.
  • Override -populateNotificationInfos. Call super first.
  • Define the notifications that you care about.

 

That's it. BTIViewController will do the rest for you. No more typing essentially duplicated notification code in multiple methods. No more scrolling back and forth in your implementation file trying to find which notifications this class cares about. BTIViewController even does the nuclear unregister in dealloc, so you don't have to include it yourself anymore.

 

Enjoy.

No comments:

Post a Comment