Avoiding the Mega-Controller

20 November 2015
 

(A demo app accompanying this post is available, with reusable parts easily extractable.)

If you’ve ever written an iOS app, you’ll know just how easy it is for your View-Controllers to contain the bulk of your application.

Most of the interaction between app and user takes place via View-Controllers, so it’s tempting to park all your logic right there, and all your startup and "global" code in the App Delegate. Your View-Controllers are now massive and brittle. They are Mega-Controllers!

Writing my first apps for iOS was a struggle. The environment, APIs, and tools were alien and I was happy just to get something working. OK, my View-Controllers were big, and were responsible for everything that happened, but at least the apps kind of worked.

The tools that you’re given to work with don’t make it easy. Hard-coded dependences and chained View-Controllers make it difficult to split things up without a bit of effort. Want a new View-Controller? Well you’d better instantiate it, or rely on the Storyboard’s supplied instance, then pump it full of anything it needs to work.

I recently watched a great talk by Andy Matuschak on refactoring a Mega-Controller, and I’ve just finished my first app using Swift, so I thought I’d share some of the ideas I’ve used (stolen, bastardized, invented, ruined) to avoid creating Mega-Controllers and generally creating a more composable code-base.

NB: I’m not using Storyboards. I prefer to use Autolayout code and visual format, but most things here sill apply.

Container

This borrows some ideas from Inversion of Control to help remove the responsibility for providing dependencies from app logic.

The Container knows how to provide dependencies that your app needs. This means your app logic doesn’t have concern itself with how to make things that it needs to work, and can rely on abstractions.

For example, you may have a View-Controller that displays a to-do list from a database. A Mega-Controller would probably just start working with the database. A better approach would be for the View-Controller to be dependent on something that can provide a list of to-do items. It doesn’t matter what that thing is, as long as it matches what the View Controller needs.

View-Controllers are themselves indirect dependencies of other View-Controllers. As I’m not using Storyboards, View-Controllers are constructed by the Container.

A View-Controller (or other app logic part) exposes it’s dependencies, and the container sets those dependencies when it constructs the View-Controller.

Example in Pseudo-Code

The View-Controller is dependent on something to get to-do items:

class TodoListViewController {

    getItems: func

    func didLoad {
        items = getItems()
        showItems(items)
    }
}

At startup, the Container is configured to know how to create new instances of TodoListViewController:

container.register(TodoListViewController) {
    db = container.resolve(Db)
    vc = TodoListViewController()
    vc.getItems = db.fetchAllItemsOrderedByDueDate()
    return vc
}

The container also knows how to provide an instance of the thing that does database access, because it too has an entry in the container:

container.register(Db) {
    db = ... // vendor specific stuff
    return db
}

Who Knows the Container?

The only things that reference the Container are the Installers (see below), and the App Delegate. Everything else is just a graph of dependencies.

If your application parts expose their dependencies, and rely on them being set by their caller, then why do we need the Container at all? If you force the caller of a dependency to set sub dependencies, then those sub-dependencies become dependencies of the caller. The main entry to your app (e.g. root View-Controller) would have to have dependencies for everything in the app.

Lifestyle

The Container supports two "Lifestyles"

  • Transient – a new instance is constructed for every request for a particular dependency
  • Singleton – the same instance is reused for all requests for a particular dependency

View-Controllers as Indirect Dependencies of View-Controllers

In our previous example of the to-do list, how do we get from the list to a detail view of a specific item without the list View-Controller instantiating a detail View-Controller? One answer is to say that the list View-Controller doesn’t really care about the detail View-Controller; it only cares that something happens when a user taps on a particular item. That something is a function of the list View-Controller and the selected item. When we register the list View-Controller in the Container, we also specify how detail is shown.

So our list View-Controller now has two dependencies: the thing that gets a list, and a function to show detail.

container.register(TodoListViewController) {
    db = container.resolve(Db)
    vc = TodoListViewController()
    vc.getItems = db.fetchAllItemsOrderedByDueDate()
    vc.showDetail = { selectedItem in
        detailVc = container.resolve(DetailViewController)
        detailVc.item = selected
        vc.showViewController(detailVc)
    }
    return vc
}

The list View-Controller maintains ignorance of the detail View-Controller

Bootstrapping the Container

The Container is set up through a number of Installers. Each Installer is responsible for registering components for a particular area of app logic, and implements a simple protocol.

protocol ContainerInstaller {
    func install(container: Container)
}

A Startup Step (see below) enumerates a number of Installer instances, passing each the Container.

Startup Steps for leaner App Delegate

This pattern is so simple, it’s almost not a pattern.

The App Delegate is your way into app life-cycle logic, so it’s pretty easy to end up with all your things that happen on startup crammed in here. App appearance, core data initialization, logging, all in one file.

A Startup Step is a entity that is responsible for affecting one area, and is executed once at application startup. Easy!

protocol ApplicationStartupStep {
    func performWithApplication(application: UIApplication, container: Container)
}

For instance, a step to set the app’s appearance:

struct AppearanceConfigurator: ApplicationStartupStep {
    
    func performWithApplication(application: UIApplication, container: Container) {
        
        application.setStatusBarStyle(.LightContent, animated: false)
        
        let navFgColor = UIColor.whiteColor()
        let navBar = UINavigationBar.appearance()
        navBar.barTintColor = UIColor.darkGrayColor()
        navBar.tintColor = navFgColor
        navBar.titleTextAttributes = [
            NSForegroundColorAttributeName : navFgColor
        ]
    }
}

I define the steps instances in the App Delegate class, then execute them on app launch:

private static let startupSteps: [ApplicationStartupStep] = [
    AppearanceConfigurator(),
    RunContainerInstallers(),
    ...
]
    
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
    
    let container = Container.defaultContainer
    for startupStep in AppDelegate.startupSteps {
        startupStep.performWithApplication(application, container: container)
    }
    
    ...
}

Navigation

I’m less sold on this, and I think it needs some more work.

It’s usual for a View-Controller to specify navigation buttons directly by accessing it’s navigationItem property. This seems like it is a bit of a smell; why does a View-Controller have any knowledge of the enclosing Navigation-Controller (which may not even exist)?

The following approach is an attempt to:

  • reduce the knowledge a View-Controller has on navigation
  • re-use buttons across all View-Controllers in a particular Navigation-Controller, for instance to have a persistent "Done" button regardless of what View-Controllers have been pushed.

What buttons appear where is encapsulated in an instance of an implementation of NavigationSpec:

protocol NavigationSpec {
    func applyToContext(ctx: NavigationContext)
}

struct NavigationContext {
    let navigationItem: UINavigationItem
    let viewController: UIViewController
}

An extension to UIViewController to allow instances to kick off the button creation process:

extension UIViewController {
    
    func exposeNavigation() {
        
        if let nv = self.navigationController as? NavigationSpec {
            let ctx = NavigationContext(navigationItem: self.navigationItem, viewController: self)
            nv.applyToContext(ctx)
        }
    }
}

See above that an attempt is made to convert the UINavigationController to a NavigationSpec. This only works because a special subclass of UINavigationController is used that implements the protocol. Instances are created via a convenience method on registration.

container.register(AddScene) { // here the "Scene" is a bundling of Navigation-Controller and child View-Controller
    todoVc = c.resolve(TodoViewController)
    navSpec = CancelDoneNavigation(onCancel: todoVc.cancel, onDone: todoVc.commit)
    navController = UINavigationController.navigationControllerWithRootViewController(todoVc, spec: nav)
    return AddScene(navController, todoVc)
} 

Feedback welcome!

Search

Categories

Archives

Subscribe to Email Updates

Subscribe
 

We are a digital transformation consultancy. We help our clients succeed.

View Services