Splitting a storyboard in Objective-C/Swift mixed legacy projects

How would you split a storyboard into multiple smaller storyboards? 

Why would you?

Marius Waldal, senior software developer with Finn, explains the reasoning, examines the problems, and attempts an answer.

The problem: Large storyboards make iOS developers sad

As the FINN app has grown the last few years, so has our storyboard. Using storyboards can bring several useful features, as well as a visual conceptual overview of how your app works.

I say “can” because this is not always the case.

Storyboards have quirks that are not always appreciated when multiple developers are working on a single project. Small changes to a storyboard can mean several changes to the storyboard xml. In addition, XCode has an annoying tendency to recalculate coordinates for several storyboard items just because you opened the file!

Yet another annoyance is that the larger your storyboard, the slower it is to work with. We regularly waited five seconds for the file to open, and actions often lagged.

So, the iOS team decided it was time to split the storyboard; not an easy job, or small, or even fun. And the Jira task also stated “Beware of scope creep…”

Yeah. It crept.

The FINN app: Some history

The current FINN app was launched three years ago in August 2013 and has grown quite a bit since then. Several new features have been added using Swift, and many of the older Objective-C classes have been rewritten in Swift. However, the majority of the code base is still Objective-C. Interoperability between Swift and Objective-C is therefore crucial.

A quick file count says we have 171 Swift files and 349 .m files in our project (not counting third-party code, of course). These files contain over 44,000 lines of code, comprised of 30,000 lines of Objective-C code and more than 14,000 lines of Swift code (excluding comments and whitespace).

Starting out small

We started small, extracting the part of the app called “Min FINN” (My FINN) to its own storyboard. It’s quite an autonomous part of the app, most of the navigation within is internal to that feature, but there are some entry points from other parts of the app.

Some navigation is done by segues, but multiple places instantiate the scene’s viewcontroller via the storyboard directly, meaning code like this litters the codebase:


There are two problems with this:

  1. It refers to self.storyboard, which means it will only work as long as the viewcontroller exists on the same storyboard as the current viewcontroller
  2. It uses a hard-coded string to refer to the storyboard identifier resultListViewController, which is error-prone.

We needed a better way.

When we started our MainStoryboard_iPhone it looked like this:Main storyboard before split

Yeah, kinda messy and not necessarily super-manageable.

Not very informative either, conceptually. So, how did we go about splitting it up?

With XCode 7, we got a nice new feature: Refactor to Storyboard…
Refactor to storyboard

If you select all the scenes to extract the refactor feature will create a new storyboard for you, wiring up any connections between scenes in the old storyboard and the new storyboard.

Unfortunately it doesn’t work if you’re supporting iOS 8 and using relationship segues (i.e. segues from a UITabBarController).

Which was the case for us, of course.

But it did give us a handy shortcut for extracting scenes into a new storyboard: by deleting the resulting storyboard references. For non-relationship situations were on our own anyway.

Juggling two storyboards

OK, now we had a new storyboard, with just the “Min FINN” scenes: MinFINN storyboard

Most of the self.storyboard instantiateViewControllerWithIdentifier calls spread out around the code still worked, but not all. For example, in some areas of the Min FINN storyboard, we open up the FINObjectViewController that is still on the MainStoryboard, and there are scenes on the Min FINN storyboard accessed from scenes on the main storyboard.

Which storyboard was each scene located on?

There are many such invocations, and we would continue to split the main storyboard, making it even more fragmented. A common place was needed to handle this so the call site didn’t need to know where the scene was located.

Generating common code

Searching for existing tools that could help us, we found several, including Swiftgen, and tried them all.

Swiftgen is a very thorough and well-written tool for generating enums and structs that handle multiple storyboards. However, it only supports Swift and cannot be used for Objective-C, making it a no-go for us. It’s also quite elaborate with enums, structs, protocols and extensions, and outputs a fair amount of code.

Most other tools were either Swift-only or Objc-only, or they created only constants for the identifiers.

But we really liked the Swiftgen approach of creating functions that can be called directly and will return an instance of the correct class.

So, we decided to create our own generator.

The first iteration was a Swift-class that didn’t rely on Swift enums unusable in Objc, and could be called from both Swift and Objc. This seemed absolutely doable, and our first generated Swift-file had static functions like this:


Objective-C compiler complaining

Looks good, right? Except, it doesn’t work in Objective-C.

Why? The FINWebViewController is an Objective-C class, and therefore has that name on both sides of the table. Win!

The FrontPageSearchController, however, is a Swift class, so has the name FINFrontPageSearchViewController on the objc side of the table. Doh!

When the instantiateFrontPageSearchViewController was called from objc it didn’t work, because the expected class was FINFrontPageSearchViewController and the returned class was FrontPageSearchViewController.

After A LOT of trial and error (I’ll spare you the painful details here) we decided to generate separate instantiator classes for objc and Swift. Win!

Or was it?

SSome of the Swift view controllers are used from both objc and Swift, so we annotated them with the objc name:


That’s all well and good, but the Python script parsed the storyboard files and extracted the storyboard identifiers and their respective custom class names (if any), so there were class names with the prefix (objc classes) and class names without the prefix (Swift classes).

This enabled us to check for this prefix while generating.

When generating Objc code we added the prefix to the Swift classname, and, when generating Swift code, we left it as it was. Remember, in the storyboard, the non-prefixed Swift class name was used.

This resulted in compiler warnings like: Incompatible pointer types

WTF? Even though the UserAdListViewController class was annotated with the prefixed name, this didn’t work. We practise zero-tolerance for warnings in our project so we had to fix this. What if we cast it to the class it’s supposed to return…?Casting to prefixed classname

Warning gone! Now it has to work, yes?

No.

Although we’re instantiating a viewcontroller listed in the storyboard as a UserAdListViewController and annotated as a FINUserAdListViewController this does not return a FINUserAdListViewController objc instance.

What does it return? A UIViewController

(these aren’t even half the hurdles and dead ends we met. I don’t even remember them all anymore. Which is probably a good thing.)

“Why,” I kept asking myself, “as a newcomer to the team, did I pick this as my first task?”

What if we defined this view controller as FINUserAdListViewController (the objc annotated name) in the storyboard scene? That had to work in Objc, right?

Lo and behold, it did! Now Objective-C recognized the class.

Swift compiler complaining

Oh, wait. Now the generated Swift class didn’t work.User of undeclared type

Of course, in the Swift realm, there’s no such class – it should refer to UserAdListViewController.

No problem, we just removed the prefix when generating the Swift code. But wait – we knew a class was an Objc class or a Swift class by checking for this prefix, right? So how would we know if a class in the storyboard was a Swift class when they ALL had prefixes?

OK. What do Objc classes have that Swift classes don’t that’s easily accessible from a script? Header files!

So, we created a Python function that would crawl through all the files in the project and collect all header-filenames in a Set. Then, when generating the Swift code, we tested every class name (with an added “.h”) against this Set. Does the Set contain an entry with this name? Yes -> Objc class. No -> Swift class.

Was it possible to do that for Swift files instead? No, because there isn’t necessarily a 1-1 correlation between Swift classes and filenames.

Believe it or not, this was the last hurdle: we’d generated code for Objc and Swift.

The Objc generated code looked like this: Objective-C function

And at call site: Objective-C call site

The Swift generated code looked like this: Swift function

At call site: Swift call site

That’s about as simple as you could get.

Well, almost – the storyboard identifier still had a Swift enum.

We could have skipped the enum and just used the identifier string directly in the instantiate method. But the enums could be useful for something else later. We still might remove them. Maybe.

As you can see, Swift/Objective-C in(ter)operability has some rough edges and this wasn’t a straightforward task.

To wrap up, here’s a list of steps to follow, a link to the Python script and a demo project, if you face the same challenges we did.

The script is not optimized and generic, so you can plug and play, but it should be fairly easy to adjust to your own needs. Feel free to generify it and create a pull request to enhance the script’s usefulness for others!

Setting up your project for generation

1. Choose a small, reasonably autonomous part of your app to get the first separate storyboard.

2. Select the necessary scenes, go to Editor -> Refactor to Storyboard

3. Name your new storyboard. Not using relationship segues? Then the generated storyboard references should work for you – leave them!

4. Create a Run script under Buile Phases that will trigger the Python script (follow the guide in the GitHub project for this).

As you can see in the demo project, the MWStoryboardScenes.py file should be placed in your project somewhere. Our projects have a Scripts folder for these, not added to the XCode project (but handled by Git as a part of the project, of course).

This script needs to run every time the project is built, before building source files. See the GitHub repository readme for a guide to setting this up. You also need to set up paths to your storyboards.

After running this script for the first time, you’ll have the necessary functions to instantiate your view controllers.

5. Find all places where view controllers are instantiated via the storyboard (and not via segues) and change these invocations so that they use the generated storyboard functions.

As mentioned, a more thorough explanation on how to use the generator is provided in the README of the GitHub project.

Good luck splitting your storyboard 🙂

Read more from the Software engineering category
SUBSCRIBE TO OUR UPDATES
Menu