View controller lifecycle behaviors

My pal Soroush recently floated the idea of implementing reusable behaviors that pertain to a view controller’s lifecycle – such as analytics logging – using additional view controllers. There’s a lot to like about this idea; since it’s easy to compose view controllers, you don’t need to worry about adding custom storage to each of your view controller subclasses or manually overriding and hooking into each their lifecycle methods.

His post ended up being a bit of a divisive one. While view controller containment seems universally liked, using a view controller to model logic that doesn’t have its own associated view can feel wrong. While I empathize with this sentiment, I find the concept of “lifecycle behaviors” so enticing that I’m willing to forgive their implementation perhaps feeling a bit unorthodox.

First, let’s define what exactly a lifecycle behavior would look like. Any of UIViewController’s standard lifecycle methods could theoretically be worth hooking into, which yields a protocol that looks something like the following:

protocol ViewControllerLifecycleBehavior {
    func afterLoading(viewController: UIViewController)

    func beforeAppearing(viewController: UIViewController)

    func afterAppearing(viewController: UIViewController)

    func beforeDisappearing(viewController: UIViewController)

    func afterDisappearing(viewController: UIViewController)

    func beforeLayingOutSubviews(viewController: UIViewController)

    func afterLayingOutSubviews(viewController: UIViewController)
}

In order for a behavior to optionally implement whichever lifecycle methods are pertinent, we can provide empty implementations by default:

extension ViewControllerLifecycleBehavior {
    func afterLoading(viewController: UIViewController) {}

    func beforeAppearing(viewController: UIViewController) {}

    func afterAppearing(viewController: UIViewController) {}

    func beforeDisappearing(viewController: UIViewController) {}

    func afterDisappearing(viewController: UIViewController) {}

    func beforeLayingOutSubviews(viewController: UIViewController) {}

    func afterLayingOutSubviews(viewController: UIViewController) {}
}

Let’s say that our app uses UINavigationController and that the navigation bar is visible by default, but the occasional view controller needs to hide it. Modeling this behavior in a concrete ViewControllerLifecyleBehavior is trivial:

struct HideNavigationBarBehavior: ViewControllerLifecycleBehavior {
    func beforeAppearing(viewController: UIViewController) {
        viewController.navigationController?.setNavigationBarHidden(true, animated: true)
    }

    func beforeDisappearing(viewController: UIViewController) {
        viewController.navigationController?.setNavigationBarHidden(false, animated: true)
    }
}

Now, how do we actually integrate these behaviors into our view controller’s lifecycle? Since view controllers forward their lifecycle methods to their children, creating a child view controller is the easiest way to hook in:

extension UIViewController {
    /*
     Add behaviors to be hooked into this view controller’s lifecycle.

     This method requires the view controller’s view to be loaded, so it’s best to call
     in `viewDidLoad` to avoid it being loaded prematurely.

     - parameter behaviors: Behaviors to be added.
     */
    func addBehaviors(behaviors: [ViewControllerLifecycleBehavior]) {
        let behaviorViewController = LifecycleBehaviorViewController(behaviors: behaviors)

        addChildViewController(behaviorViewController)
        view.addSubview(behaviorViewController.view)
        behaviorViewController.didMoveToParentViewController(self)
    }

    private final class LifecycleBehaviorViewController: UIViewController {
        private let behaviors: [ViewControllerLifecycleBehavior]

        // MARK: - Initialization

        init(behaviors: [ViewControllerLifecycleBehavior]) {
            self.behaviors = behaviors

            super.init(nibName: nil, bundle: nil)
        }

        required init?(coder aDecoder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        // MARK: - UIViewController

        override func viewDidLoad() {
            super.viewDidLoad()

            view.hidden = true

            applyBehaviors { behavior, viewController in
                behavior.afterLoading(viewController)
            }
        }

        override func viewWillAppear(animated: Bool) {
            super.viewWillAppear(animated)

            applyBehaviors { behavior, viewController in
                behavior.beforeAppearing(viewController)
            }
        }

        override func viewDidAppear(animated: Bool) {
            super.viewDidAppear(animated)

            applyBehaviors { behavior, viewController in
                behavior.afterAppearing(viewController)
            }
        }

        override func viewWillDisappear(animated: Bool) {
            super.viewWillDisappear(animated)

            applyBehaviors { behavior, viewController in
                behavior.beforeDisappearing(viewController)
            }
        }

        override func viewDidDisappear(animated: Bool) {
            super.viewDidDisappear(animated)

            applyBehaviors { behavior, viewController in
                behavior.afterDisappearing(viewController)
            }
        }

        override func viewWillLayoutSubviews() {
            super.viewWillLayoutSubviews()

            applyBehaviors { behavior, viewController in
                behavior.beforeLayingOutSubviews(viewController)
            }
        }

        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()

            applyBehaviors { behavior, viewController in
                behavior.afterLayingOutSubviews(viewController)
            }
        }

        // MARK: - Private

        private func applyBehaviors(@noescape body: (behavior: ViewControllerLifecycleBehavior, viewController: UIViewController) -> Void) {
            guard let parentViewController = parentViewController else { return }

            for behavior in behaviors {
                body(behavior: behavior, viewController: parentViewController)
            }
        }
    }
}

Lastly, adding this behavior to one of our custom view controllers is as simple as:

override func viewDidLoad() {
    super.viewDidLoad()

    addBehaviors([HideNavigationBarBehavior()])
}

And that’s all there is to it. Sure, the implementation might not be using view controllers in exactly the way that UIKit intends, but when has that stopped us before? Perhaps future enhancements to the language and SDK will allow our addBehaviors(behaviors: [ViewControllerLifecycleBehavior]) method to be more idiomatically implemented (increased dynamism could better facilitate aspect-oriented programming). But we won’t need to change all of our individual UIViewController subclasses if and when this happens.

Profiling your Swift compilation times

UPDATE: It’s possible to do this entirely from the command line, without modifying your Xcode project at all. Simply run the following (thanks to Mike Skiba and Eric Slosser for their help with this):

xcodebuild -workspace App.xcworkspace -scheme App clean build OTHER_SWIFT_FLAGS="-Xfrontend -debug-time-function-bodies" | grep .[0-9]ms | grep -v ^0.[0-9]ms | sort -nr > culprits.txt

I had a problem. The new iOS application that I’m working on – written 100% in Swift – was noticeably taking much longer to compile than should, given its size (~200 files). More concerning, it was suddenly a lot slower than only a couple of weeks prior. I needed to get to the root of the problem as soon as possible, before it got any worse.

The first step was to add -Xfrontend -debug-time-function-bodies to my Swift compiler flags:

This causes the compiler to print out how long it takes to compile each function (thanks to Kevin Ballard for clueing me into these). The debug logs are visible in Xcode’s report navigator, but only by manually expanding each individual file:

The next step was to aggregate all of these logs together in one place in order to make sense out of them.

Rather than building via Xcode itself, using the xcodebuild command line tool results in the logs being printed to standard output, where we can massage them to our liking:

# Clean and build, capturing only lines containing `X.Yms` where X > 0, sorting from largest to smallest
xcodebuild -workspace App.xcworkspace -scheme App clean build | grep [1-9].[0-9]ms | sort -nr > culprits.txt

At this point, the question was whether I’d actually be able to derive actionable insights from the output, and I most certainly was. Thought my culprits file highlighted any function that took over a millisecond to compile, I actually had 1,200+ cases in which a function took more than a second, with many taking over three seconds. Thankfully, these 1,200+ lines were actually all the same three functions repeated many times over again (I don’t know enough about compilers to understand why this is the case, but the inclusion of “closure” in the output sample below does shed a bit of light).

3158.2ms	/Users/Bryan/Projects/App/FileA.swift:23:14	@objc get {}
3157.8ms	/Users/Bryan/Projects/App/FileA.swift:23:52	(closure)
3142.1ms	/Users/Bryan/Projects/App/FileA.swift:23:14	@objc get {}
3141.6ms	/Users/Bryan/Projects/App/FileA.swift:23:52	(closure)
3139.2ms	/Users/Bryan/Projects/App/FileA.swift:23:14	@objc get {}
3138.7ms	/Users/Bryan/Projects/App/FileA.swift:23:52	(closure)
3128.3ms	/Users/Bryan/Projects/App/FileB.swift:27:22	final get {}
3109.9ms	/Users/Bryan/Projects/App/FileA.swift:23:52	(closure)
3052.7ms	/Users/Bryan/Projects/App/FileA.swift:23:14	@objc get {}
3052.6ms	/Users/Bryan/Projects/App/FileA.swift:23:14	@objc get {}
3052.2ms	/Users/Bryan/Projects/App/FileA.swift:23:52	(closure)
3052.1ms	/Users/Bryan/Projects/App/FileA.swift:23:52	(closure)
3049.0ms	/Users/Bryan/Projects/App/FileB.swift:27:22	final get {}
3026.1ms	/Users/Bryan/Projects/App/FileB.swift:27:22	final get {}

Even crazier, each of these three functions was only a single line of code. Rewriting just these three lines of code caused my entire project to build 60% faster. I could see this enraging many, but honestly I was just so happy to have figured out the sources of the bottleneck, as well as to now know how to troubleshoot the next time I found myself in a similar situation.

You might be wondering what in the world these three lines looked like. All were (perhaps unsurprisingly) very similar, taking a form like:

return [CustomType()] + array.map(CustomType.init) + [CustomType()]

I can’t speak to whether or not the crux of the problem was the appending, the mapping, or the combination of the two. Rather than try and hone in on the smallest change I could make that would yield suitable performance, I simply rewrote these functions to be as naively imperative as I could: mutable intermediate variables, ostensibly unnecessary type definitions, the works. I’m hardly the first to discover that array appending is slow, but it took experiencing the pain firsthand to realize the ease with which one can find themselves having a bad time thanks to an elegant and seemingly innocuous line of code.

Swift is still an incredibly young language and it shouldn’t be surprising that there are still rough edges like this for us to, as a community, collectively find and help sand down. As we learn more about how to instrument our code, and in turn, what the compiler’s pain points are, we’ll become better at helping one another avoid such pitfalls, and helping those working on Swift itself prevent them from happening in the first place.

Designing for change

The Wikipedia entry for technical debt starts by defining it as:

Work that needs to be done before a particular job can be considered complete or proper. If the debt is not repaid, then it will keep on accumulating interest, making it hard to implement changes later on.

And goes on to state that:

Analogous to monetary debt, technical debt is not necessarily a bad thing, and sometimes technical debt is required to move projects forward.

This is a great way to put it; debt isn’t inherently bad if it yields important short-term benefits that wouldn’t have otherwise been achievable. Ensuring those benefits are reaped by choosing the right kinds of debt is crucial, however.

Over the past two months, I’ve been working on a brand new company. At this juncture, it’s easy to think that one shouldn’t be concerned about accumulating technical debt for quite some time. That for a while, raw speed is more important than doing everything “correctly.” That to be burdened by technical debt would be a good problem to have, implying that you’ve found a meaningful foothold after launching. That if your 1.0 is too technically sound, you weren’t moving fast enough.

I do think there’s some truth to this, but of course it isn’t so black and white. You absolutely shouldn’t concern yourself with certain kinds of technical debt while racing towards your initial launch, with so many questions still swirling around value proposition and product/market fit. Maybe you have a class whose implementation consists solely of one giant method. It works, seemingly, but its logic is hard to understand and even harder to modify. Test coverage isn’t great and you’re sure there’re edge cases that don’t work as expected, that’d be apparent if you just spent some time breaking it down into a number of smaller methods.

This is the kind of technical debt that I have no problem introducing into my codebase at this early stage. Technical debt might be worth introducing early only if it’s going to make you faster in the short-term. But specific types of debt do exactly the opposite.

One common form of technical debt is building components that are more tightly coupled together than they should be. Perhaps your view controllers each know about the next view controller that is to be displayed1. Maybe various parts of the codebase are all intimately aware that Core Data is being used for your persistence layer. This makes it prohibitively difficult to make rapid changes, specifically at the time when codebases tend to undergo the most: during their infancy.

Maybe we’re just using NSUserDefaults for now, since it’s so easy. Now we’ve graduated to a simple NSCoding-based cache. Soon we’re ready for a full on database like YapDB or Realm or Core Data. Let’s say that your onboarding flow is comprised of six different screens. Now it’s comprised of five. Now those same five in a different order. This type of churn is to be expected in a young codebase. Today you’re using Alamofire but tomorrow you might not be.

These are logical progressions for a new application to go through, and developing “correctly” by keeping these boundaries loosely coupled will facilitate making changes when it’s most important to do so. Established codebases don’t switch persistence mechanisms three times over the course of two months, but a new one very well might. As such, a decision that’d make a change like this overly difficult is exactly the type of debt that I won’t tolerate no matter how fast I‘m supposed to be moving.

Properly architecting may seem like it’s going to be more time consuming, but in practice, it won’t, as long as you’re investing in the right approach. For a new codebase, I can’t think of a tradeoff more important than keeping your components decoupled rather than worrying too much about their implementations. Design for change when your code is going to be at its most volatile.

  1. If afflicted by this particular problem, meet coordinators.