Future-Proof Dependency Injection for Storyboard-Based View Controllers

One of the issues introduced by storyboards is that they make it impossible to pass dependencies to view controllers in initializers. I proposed an API modification in past that would allow for exactly that, but alas, it doesn’t seem to be high on the priority list for UIKit. I recently came up with a new approach leveraging code generation which I’m excited to show you today.

Initialization Recap

Since it’s not possible to use initializers to pass dependencies, in most cases developers rely on implicitly unwrapped optionals to indicate properties that should be set immediately after a view controller was created, e.g.:

final class ProfileViewController: UIViewController {
    var viewModel: ProfileViewModel!
}

Then at a call site, a view controller is initialized as follows:

let storyboard = UIStoryboard(name: "Profile", bundle: Bundle.main)
let viewController = storyboard.instantiateViewController(withIdentifier: "profileViewController") as! ProfileViewController
viewController.viewModel = viewModel

We can use a third-party tool like SwiftGen to get rid of strings, leaving us with:

let viewController = StoryboardScene.Profile.instantiateProfileViewController()
viewController.viewModel = viewModel

The Problem

While observing a lifetime of a few codebases relying on storyboards I noticed that at a time a new view controller is added everything is fine, no bugs here. The bugs start appearing when a view controller is modified and not all dependencies are set properly in each call site. In case of implicitly unwrapped optionals (IUO), that leads to runtime crashes.

We can leverage the Swift compiler to warn us about those issues if we make a few assumptions:

  1. Properties defined as implicitly unwrapped optionals (excluding @IBOutlets) are dependencies that should be passed in from the outside.
  2. We’re fine with code generation.
  3. We’ll always use the same generated methods to initialize or set up (in case of segues) view controllers.

Knowing these assumptions it becomes clear (maybe only when you’re standing under a shower, though 😀) that all we need is an additional method that accepts all objects we need to initialize IUO properties. So for our ProfileViewController we should have:

extension ProfileViewController {
    static func makeFromStoryboard(
        viewModel: ProfileViewModel
    ) -> ProfileViewController {
        // magic here
    }
}

What’s important, if we add a new dependency to our view controller, e.g.:

final class ProfileViewController: UIViewController {
    var viewModel: ProfileViewModel!
    var analyticsManager: AnalyticsManager!
}

that method should be automatically updated to:

extension ProfileViewController {
    static func makeFromStoryboard(
        viewModel: ProfileViewModel,
        analyticsManager: AnalyticsManager
    ) -> ProfileViewController {
        // magic here
    }
}

We definitely don’t want to have to update this method manually each time we change a view controller. Code generation to the rescue!

Code Generation

SourceKit provides us with metadata about types in our Swift project. Based on that metadata we can generate additional code each time our project changes. There’s already a good tool doing exactly that – Sourcery – that we’ll use.

Let’s start by declaring a new protocol:

protocol StoryboardInitializable where Self: UIViewController {
    static func instantiateFromStoryboard() -> Self
}

and mark our ProfileViewController as conforming to it. instantiateFromStoryboard() is a method using instantiateViewController(withIdentifier:) under the hood:

extension ProfileViewController: StoryboardInitializable {
    static func instantiateFromStoryboard() -> ProfileViewController {
        let storyboard = UIStoryboard(name: "Profile", bundle: Bundle.main)
        return storyboard.instantiateViewController(withIdentifier: "profileViewController") as! ProfileViewController
    }
}

So, for this view controller we want the following methods to be generated:

// MARK: - ProfileViewController - StoryboardDependencyInjection
extension ProfileViewController {

  static func makeFromStoryboard(
    viewModel: ProfileViewModel
  ) -> ProfileViewController {
    let viewController = ProfileViewController.instantiateFromStoryboard()
    viewController.setDependencies(
      viewModel: viewModel
    )
    return viewController
  }

  func setDependencies(
    viewModel: ProfileViewModel
  ) {
    self.viewModel = viewModel
  }
}

I prepared a template for Sourcery which does exactly that (it's also on GitHub):

<% types.implementing.StoryboardInitializable.forEach(function(type){ -%>
// MARK: - <%= type.name -%> - StoryboardDependencyInjection
extension <%= type.name -%> {

<%
var allProperties = [];
var currentType = type;

while (currentType) {
  allProperties.push.apply(allProperties, currentType.storedVariables);
  currentType = currentType.supertype;
}
var properties = allProperties.filter(function(property) {
  return property.isImplicitlyUnwrappedOptional &&
  !property.attributes.IBOutlet &&
  ["internal", "public", "open"].indexOf(property.writeAccess) > -1
})
-%>
    static func makeFromStoryboard(
<% properties.forEach(function(property, index){ -%>
        <%= property.name -%>: <%= property.typeName.unwrappedTypeName -%><% if (index !== properties.length - 1) { %>,<% } %>
<% }) -%>
    ) -> <%= type.name -%> {
        let viewController = <%= type.name %>.instantiateFromStoryboard()
        viewController.setDependencies(
<% properties.forEach(function(property, index){ -%>
            <%= property.name -%>: <%= property.name -%><% if (index !== properties.length - 1) { %>,<% } %>
<% }) -%>
        )
        return viewController
    }

    func setDependencies(
<% properties.forEach(function(property, index){ -%>
        <%= property.name -%>: <%= property.typeName.unwrappedTypeName -%><% if (index !== properties.length - 1) { %>,<% } %>
<% }) -%>
    ) {
<% properties.forEach(function(property){ -%>
        self.<%= property.name %> = <%= property.name %>
<% }) -%>
    }
}

<% }) %>

Having these methods, each time we want to create ProfileViewController, we should do it this way:

let profileViewController = ProfileViewController.makeFromStoryboard(viewModel: viewModel)

If we add a new IUO property to ProfileViewController, the build will fail with an error because there will be a new parameter in makeFromStoryboard() method:

ProfileViewController.swift:19:98: Missing argument for parameter 'analyticsManager' in call

Even Better Code Generation

What we have here is nice and will help us avoid bugs when we modify our view controllers in the future. We can go a step further, though.

If we use SwiftGen, it will generate a file with constants for us, e.g.:

enum StoryboardScene {
  enum Profile: String, StoryboardSceneType {
    static let storyboardName = "Profile"

    case profileViewControllerScene = "profileViewController"
    static func instantiateProfileViewController() -> ProfileViewController {
      guard let vc = StoryboardScene.Profile.profileViewControllerScene.viewController() as? ProfileViewController
      else {
        fatalError("ViewController 'profileViewController' is not of the expected class ProfileViewController.")
      }
      return vc
    }
  }
}

Instead of implementing instantiateFromStoryboard() in each view controller, we can let Sourcery analyze the contents of StoryboardScene enum and put a correct initialization in our generated file:

  static func makeFromStoryboard(
    viewModel: ProfileViewModel
  ) -> ProfileViewController {
    let viewController = StoryboardScene.Profile.instantiateProfileViewController()

    viewController.setDependencies(
      viewModel: viewModel
    )
    return viewController
  }

We got rid both of stringly-typed API and future-proofed our code. You can find this template on GitHub here. I think this is pretty cool!

Conclusion

Bridging external resources with code is hard. Code generation seems to be a temporarily approved way (see code generation for NSManagedObject subclasses or Codable protocol) of working around the design issues in iOS APIs. We showed how to use code generation to create a safer API for view controllers when using storyboards. You can find templates described in this article on GitHub.