To storyboard or not to storyboard?

Storyboards are known to "divide" iOS developers since their introduction in iOS 5. Almost five years have passed and the consensus about when to (not) use them still remains. It is probably because of the fact that using storyboards has as many benefits as drawbacks[1] so it is more about personal preference than anything else.

Since there is no undoubtful choice here, it is likely that soon you are going to face the same problem as I did a couple of weeks ago: which path do I go with in a new project? I hope that by presenting my thought process I will make it easier for you to make your choice.

Note: all of the considerations below are valid for Swift projects only.

Swift core concepts

Swift is built around two concepts that strengthen compile-time safety:

  • constants against variables,
  • required values against optionals.

Let's quickly recap both of those.

Constants / variables

Constants are the values that cannot be changed once they are set. They are declared using let keyword. They are an opposite to variables, which values can be modified (declared with var).

let waterBoilingPointCelsius = 100.0
// ...
// waterBoilingPointCelsius = 120.0 // compile-time error 

var currentWaterTemperature = 55.0
// ...
currentWaterTemperature = 75.0 // temperature has risen, perfectly fine

Remember that let stands for immutability. It is important for some of the not-that-obvious cases:

let immutableArray = ["A", "B"]
// immutableArray.append("C") // compile-time error 
// similar to NSArray & NSMutableArray

struct GameState {
    var currentPoints = 0
}

let gameState = GameState()
// gameState.currentPoints = 10 // compile-time error
// currentPoints is mutable, however changing its value...
// ...would mutate gameState object which is not permitted

Swift language docs made it obvious that you are supposed to use let over var whenever this is possible.

If a stored value in your code is not going to change, always declare it as a constant with the let keyword. Use variables only for storing values that need to be able to change.

Extract from The Swift Programming Language (Swift 2.2).

Optional / required values

As opposed to other popular objective oriented programming languages, Swift compiler does not allow to assign a nil value to a constant / variable by default. Swift introduces so called optional values which are allowed to be nullable.

var address = "565 Green St, SF"
// ...
// address = nil // compile-time error
// address is not optional so it cannot be nullified

var optionalAddress = "565 Green St, SF" as String?
// ...
optionalAddress = nil // fine, optionalAddress is optional

This implies two things:

  • your code should not use optionals unless absence of a value is possible,
  • you should always handle nil cases when handling optional values.

Swift protocols

Another concept introduced by Swift programming language is a protocol. Protocol defines a set of properties and methods that need to be implemented for a particular piece of functionality. Protocols should always be used instead of concrete types for specifying object dependencies.

class FileStorage {
    func store(data: NSData) throws -> String {
        // handle storage
    }
}

class ImagePersistenceService {
    let storage = FileStorage()

    func persistImage(image: UIImage) throws -> String {
        // compress the image
        // let imageData = ... 
        return try self.storage.store(imageData)
    }
}

There are a lot of caveats in a design above:

  • It is impossible to unit test ImagePersistenceService without actually storing files. It slows down the tests and forces you to write a cleanup code for every test.
  • Migration to DatabaseStorage requires changing implementation of ImagePersistenceService.

Compare this to the following:

protocol StorageProtocol {
    func store(data: NSData) throws -> String
}

class FileStorage: StorageProtocol {
    func store(data: NSData) throws -> String {
        // handle storage
    }
}

class ImagePersistenceService {
    let storage: StorageProtocol

    init(storage: StorageProtocol) {
        self.storage = storage
    }

    func persistImage(image: UIImage) throws -> String {
        // compress the image
        // let imageData = ...
        return try self.storage.store(imageData)
    }
}

A small change and now it is possible to replace FileStorage with any other object that conforms to StorageProtocol. Guess what? You can replace it with a mock object in unit tests too.

So much better! Such wow!

Injecting dependencies

ImagePersistenceService was a simple example of Dependency Injection pattern. And because of how Swift works it also happens to be a really beautiful piece of dependency injection:

  • It is impossible to construct the object without needed dependencies, since constructor parameters are not optional.
  • It is impossible to replace dependencies after instantiation (possibly leading to invalid object state), since they are marked as let constants.

Having above in mind I made a protocol-let-init a go to pattern for instantiating all of my Swift classes. I have also picked Swinject as a dependency container to support assembling my instances. So far, so good!

Storyboard view assembly

Here is where a problem with Storyboards kicks in. A protocol-let-init pattern is obviously limited to the places where you have a direct control over assembling object instances. Unfortunately, it is not the case with Storyboards. When instantiating views or controllers Storyboard calls init(coder: NSCoder) method. While it is totally acceptable for views (they should not contain any logic anyway ), it is a real problem when it comes to controllers.

Let's suppose we want to implement SendEmailViewController which prompts user to enter his/her e-mail. It always a good idea to validate the input, so implementation of EmailAddressValidatorProtocol has to be injected into a controller. Using protocol-let-init it is an obvious task.

class SendEmailViewController: UIViewController {
    let emailAddressValidator: EmailAddressValidatorProtocol

    init(emailAddressValidator: EmailAddressValidatorProtocol) {
        self.emailAddressValidator = emailAddressValidator
        // call to super initializer
    }

    // self.emailAddressValidator.validate() // somewhere later
} 

But what to do with init?(coder aDecoder: NSCoder) initializer? Let's recap some points about Swift's constants and initializers:

  • Constant objects have to be created before super initializer is called.
  • By the time initializer is finished, super initializer has to be called.

This means that there is no way to inject emailAddressValidator in Storyboard init! Well, that is a huge disappointment. But surely there is another way to do it, isn't there? Now we are back to a drawing board and our DI pattern article to look for an inspiration. Oh yes, we can use setter injection! Let's do that[2].

class SendEmailViewController: UIViewController {
    var emailAddressValidator: EmailAddressValidatorProtocol?

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
}

Not only did we make emailAddressValidator mutable, but also optional. And there is no other option. This is really, really bad. With protocol-let-init it was impossible to create an object in an invalid state. Now it is definitely possible and as easy as not setting a single view controller's property.

I can hear you asking: why is it so bad? I have just implemented this view controller, so now I am going to instantiate it and there is no way in hell I could forget to set all of the properties to be injected. You are right, provided that there are no changes to be made to that controller. And we both know that there will be some in future. So let's go ahead and add a name that needs to be validated, too.

// using protocol-let-init
class SendEmailViewController: UIViewController {
    let emailAddressValidator: EmailAddressValidatorProtocol
    let nameValidator: NameValidatorProtocol

    init(emailAddressValidator: EmailAddressValidatorProtocol,
         nameValidator: NameValidatorProtocol) {
        self.emailAddressValidator = emailAddressValidator
        self.nameValidator = nameValidator
        // call to super initializer
    }

    // ...
}

// using Storyboard (setter injection)
class SendEmailViewController: UIViewController {
    var emailAddressValidator: EmailAddressValidatorProtocol?
    var nameValidator: NameValidatorProtocol?

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
}

What is the difference between the two?

  • The protocol-let-init version will refuse to compile at this point, because NameValidatorProtocol requirement is missing where view controller is instantiated.
  • The Storyboard version will continue to compile just fine. You will not get any reminder to set nameValidator in every place SendEmailViewController is created (hopefully you got it right and there is just one).

Forgetting to set an injected property has just got much more likely, hasn't it? To workaround that issue it might be tempting to try to convert those injected properties to implicitly unwrapped optionals instead. Now, if nameValidator is not set the application will crash as soon as the property is referenced.

class SendEmailViewController: UIViewController {
    var emailAddressValidator: EmailAddressValidatorProtocol!
    var nameValidator: NameValidatorProtocol!

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    // self.nameValidator.validate() // application crash
}

This gives a false belief that everything is wired up properly as soon as you are able to navigate through such a view controller. You might be wondering why this is so misleading. Let's get back to our SendEmailViewController example. Name and e-mail are sensitive data, therefore we should hide/clear them before app snapshot is taken when going into background.

class SendEmailViewController: UIViewController {
    var emailAddressValidator: EmailAddressValidatorProtocol!
    var nameValidator: NameValidatorProtocol!
    var sensitiveDataManager: SensitiveDataManagerProtocol!

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        self.observeApplicationDidEnterBackground()
    }

    func observeApplicationDidEnterBackground() {
        let nc = NSNotificationCenter.defaultCenter()
        nc.addObserver(self,
                       selector: #selector(applicationDidEnterBackground),
                       name: UIApplicationDidEnterBackgroundNotification,
                       object: nil)
    }

    // ...

    func applicationDidEnterBackground(notification: NSNotification) {
        self.sensitiveDataManager.hideSensitiveData()
    }

    // ...
}

You should already get the point I am trying to make here - not every dependency is used in the simplest flow of the application. Moving application to a background on the particular view controller is an example of such an uncommon flow in development. By unwrapping dependencies implicitly you give your user immediate feedback that something went wrong. But definitely not in the way you would like to.

Conclusions

Storyboards are sexy, but they are not that Swifty.

Are Storyboards worth it? The short answer is no. I came to the conclusion that trading off a lot of Swift's compile-time safety for Storyboards is simply not worth it. Yes, protocol-let-init can still be used in other parts of the application, but taking into account that any iOS application is somewhat view controller centric it is important to take every precaution step possible while implementing them. Swift language gives a lot of powerful tools to enforce that safety, but Storyboard does not build up on them.


  1. Example lecture on the topic can be found here. ↩︎

  2. It is a recommended way to go by a Swinject team. ↩︎

Show Comments