Killing controller bloat: divide and conquer

Published
9 November 2014
Tagged

Now, hot on the heels of a despised "tips and tricks" post[1], a problem that has been bugging me for a while.

MVC: Massive View Controllers

If you do much programming in the Cocoa environment, you'll very quickly run into Apple's preferred software architecture: Model-View-Controller. Every class that's part of the grand scheme of data manipulation and display falls into one of these three categories, depending on its purpose[2]:

  • Models represent the data objects of the program. They control objects' properties and notify other objects when these properties change.
  • Views represent the display of data to the user.
  • Controllers represent the logic that lets the user interact with the model, and can also manipulate the view based on the user's input.

Cocoa views are generally boxed up in their own .nib (or .xib) files, which are basically a series of descriptions of UI elements, bindings, geometries, and simple properties in XML format. The binding between models and views is easily simplified using KVO and KVC, and a large amount of the whole object manipulation and serialisation can also be handled easily, either using NSCoder or CoreData. This means that the bloat from large views is relatively well-contained and -displayed, and that models tend not to swell too much. Controllers, however, grow steadily as the UI and logic of the app grows steadily more complex. A typical Controller in a Cocoa program might have to deal with:

  • Actions to perform when the window initialises, minimises, closes.
  • Updating UI elements in response to user actions
  • Acting as a delegate or data source for tables, token fields, and other elements
  • Miscellaneous "getter" and "setter" functions

Basically, the overarching mantra in Cocoa programming seems to say:

If in doubt, stick it in the Controller

This leads to controller bloat: hundred-line classes with scattered miscellaneous "Oh, I'd forgotten about that" methods, that you no longer explore without a good guide, twenty foot of rope, and three days' iron rations. The general name, adopted by the community, for this anti-pattern is Massive View Controllers.

Spreading the bloat

In order to do away with this sort of controller bloat, you need some way of distributing the code. One way is to use a different architecture than MVC, effectively imposing different logic on the method-allocation process. Different architectures distribute this responsibility in different ways, with the same overall goal of reducing the controller to its logically-atomic component parts.

I found that I had the same problem in Missive, my slowly-being-developed email-sending app. My view controllers were starting to bloat considerably, as I added methods for various tasks on different views of a given window.

My solution: to delegate.

MVC+

There's nothing that says you can only have the one controller - in fact, Apple supplies a series of NSObjectController subclasses for the express purpose of easing the load on your View Controller. These Object Controllers (or their cousins, the Array, Dictionary and Preference Controllers) already come with a series of methods designed to help you in your quest to limit controller bloat. The main problem I've had with controllers previously is that they're yet another thing I have to memorise: at the time I should have been taking a step back to look at my controller critically and divide its responsibilities into small chunks, I was busy learning how to hook up bindings and implement KVO. There seemed no point to learn about Array Controllers when I could just set my View Controller to be a data source and delegate. After all, that's what controllers are for, right?

The nice thing about these controllers is that they handily divide up responsibilities: the NSArrayController is responsible just for a given array (and its visual representation in the nib file), for instance, while an NSObjectController only has to deal with its assigned object.

Now the view controller only has two things to deal with:

  • Managing the window
  • Delegating and coordinating between all the controllers

Which makes it much simpler.

Example: extracting an array

Missive's preferences window has an accounts pane, which lets you add, modify, and delete email accounts:

On the back-end, the accounts are stored in an NSArray. The controller acts as data source and delegate for the table view you can see here. As the data source, it must implement numberOfRowsInTableView: and tableView:objectValueForTableColumn:row: (since it's not editable, we don't care about tableView:setObjectValue:forTableColumn:row:). In addition, the controller contains a number of methods for adding and subtracting accounts, altering their properties, and updating elements when you select another account from the list box. Here's a full run-down of the JRPreferencesController.h header file[^fn3]:

@class JREmailAccount, JRSentMailWindowController;

[^fn3]: Some elements removed for clarity.

@interface JRPreferencesController : NSWindowController <NSTableViewDataSource,NSTableViewDelegate>; {
    IBOutlet NSTableView *accountsTable;
    IBOutlet NSButton *defaultButton;
    IBOutlet NSTextField *imapPortField, *smtpPortField;
    IBOutlet NSTextField *nameField;
}

@property JREmailAccount *currentAccount;

#pragma mark For the security popup box
-(const NSArray *)securityLabels;

#pragma mark Window management
-(void)setDefaultButton;
-(void)displayWarning;
-(void)refreshAccountProperties;

#pragma mark Callback functions
-(IBAction)plusButtonPressed:(id)sender;
-(IBAction)accountNameDidChange:(id)sender;
-(IBAction)accountParameterDidChange:(id)sender;
-(IBAction)accountMadeDefault:(id)sender;
-(IBAction)toolbarButtonClicked:(id)sender;

-(IBAction)sentMailboxButtonPressed:(id)sender;
-(IBAction)smSheetButtonPressed:(id)sender;

-(IBAction)accountSecurityDidChange:(id)sender;

-(IBAction)autoDetectPortBoxClicked:(id)sender;

//Private - don't actually appear in the header, but are implemented in the body:
-(NSInteger)numberOfRowsInTableView:(NSTableView *)tableView;
-(id)tableView:(NSTableView *)tableView objectValueForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row;
-(void)tableViewSelectionDidChange:(NSNotification *)notification;
-(void)refreshAccountsTableSelection;

-(void)windowDidLoad;
@end

It's quite a bit - and this is just the header. There's obviously a lot of methods here that deal with account management, and it's tempting to just say "well, yeah, that's what controllers are for".

OK, let's make a big diagram of all the things this controller is doing:

Wow, when we put it like that, a lot of these are about accounts. How many? This many:

By taking all of these methods out of the window controller, and putting them in their own dedicated controller, we can remove most of the methods from our JRPreferencesController (which really becomes a JRPreferencesWindowController), making our code all the prettier:

We've provided a link from the window controller to the accounts controller so that methods can still access the controller. In reality, you can provide a link to the account controller by adding it to the xib itself:

This way, your window controller doesn't even have to deal with dispatching calls: elements can call the accounts controller as they see fit.

My accounts controller actually ended up being a plain vanilla NSArrayController. This class is ideally suited to display in tables (almost like you're expected to do this sort of thing), which means you don't need all that NSTableViewDataSource or NSTableViewDelegate code: you can hook it up to your controller through the "table content" bindings in the xib.

The last mile

While trying to extract these methods from my controller, I ran across the method -(void)setDefaultButton, which I was using to change the value of the "Set as default account" button. If the selected account isn't the default, the button is enabled and displays the text "Make default". If the selected account is the default, then the button is disabled and just says "Default". The button's enabled state can easily be bound to the Accounts Controller (its method selection allows you to get the selected account, and each account has a boolean defaultAccount property), but in order to properly set the button's label we need to occasionally refresh, which is what setDefaultButton is for.

There's two problems here: first, the button's label feels like it should be bound to something, and second, it'd be nice to extract the method out of the window controller[3]. It seems like overkill to make a whole new controller just for this button, but I don't really like putting this method (which is more tightly "bound" to the preferences .xib file than the account itself) on the model.

My solution is to bind the label to the account's defaultAccount property, but pass the value through a custom value transformer. A value transformer is a class that takes one type of object as input, and produces another as output. In this case, the JRDefaultButtonValueTransformer (as I called it) takes a boolean ("Is this account default?") as its input, and produces a NSString ("What should the button's label be?"). Subclassing from NSValueTransformer, the code is very simple:

@interface JRDefaultButtonValueTransformer : NSValueTransformer
@end

@implementation JRDefaultButtonValueTransformer

+(Class)transformedValueClass { return [NSString class]; }
+(BOOL)allowsReverseTransformation{ return NO; }
-(id)transformedValue:(id)value { return (value ? @"Default" : @"Make default"); }

@end

That's it! Now I can bind the button's "title" property, running it through my new transformer:

Custom controllers

In the previous example, I was able to use a stock object controller - but sometimes you need a little extra in terms of helper methods. For example, the "Email" .xib in Missive has an object controller for the email object - it would normally be an NSObjectController, but because I need some extra oomph I've made a subclass of NSObjectController for the purpose.

The email window

The email window

As an example, I like to update Missive's menu icon whenever an email's subject changes. I've set the "Subject" text field's delegate to the Email controller (which is also an NSTextFieldDelegate), and on the object controller side I can implement the following:

-(void)controlTextDidChange:(NSNotification *)obj {
    //Identify the controller
    if ([[obj.userInfo[@"NSFieldEditor"] superview] superview] == subjectField) {
      // Notification code goes in here.
    }
}

# To conclude

None of this is really new: it's basically applying the [single responsibility principle][] (or I guess, more accurately, [separation of concerns][]) to MVC. This whole exercise is an excellent way to see why you need these patterns: especially for someone without a formal computer science background, ideas like these can often seem very abstract without a good foundation. I've already experienced the benefits of the MVC+ model in Missive - my code is much easier to maintain now it's divided up, and I can dive into my window controllers without fear of getting completely lost. My goal now is to continue using these principles - to implement them as I introduce new features, and to recognise when I need to step back and refactor - and keep my code as easy-to-maintain as possible.

[single responsibility principle]: https://en.wikipedia.org/wiki/Single_responsibility_principle
[separation of concerns]: https://en.wikipedia.org/wiki/Separation_of_concerns

  1. Ugh. ↩︎

  2. I think every discussion I've had with someone regarding MVC, we've always never-quite-seen eye-to-eye on eaxactly what each part of the triangle does. I don't know whether that's just me, an ignorant non-computer-scientist, blatantly appropriating CompSci culture, or whether the many implementations of MVC have clouded the domain. Regardless, take the following definitions with a pinch of salt. ↩︎

  3. My reasoning for this goes: the window controller shouldn't have to care which account is currently selected, and the state of the button depends on which account is currently selected. Therefore, the state of the button isn't the window controller's concern. ↩︎