Scaling a Mature iOS Codebase with Tuist

Asana Engineering TeamEngineering Team
21 Februari 2023
9 menit baca
facebookx-twitterlinkedin
Inside Asana blog thumbnail image

Six months ago, it usually took 45 seconds to make a change to a feature in the Asana iOS app and rebuild it. Today, it usually takes 15 seconds.

The Mobile Foundations team achieved this by migrating our Xcode project to Tuist and splitting the code into several modules, enabled by some modest refactors. In this article, I’ll describe how we decided what path to take, and how you can make similar changes in your own codebase.

Where we were

Coming into 2022, we had about ten engineers maintaining about 300,000 lines of Swift code. Almost all of it was compiled into a single build target, Asana. The Swift compiler was doing a decent job of building it all; if you made a change, you could see it reflected in the simulator in just 45 seconds or so. (Unless you were using an Intel Mac, in which case it would be more like 90 seconds.)

Once in a while, two people would make changes to the Xcode project file that would cause a merge conflict. It would take time to sort these out each time they occurred, but we learned to live with it.

The decision to make a change

We expected to add more people to the team. Build times would affect more people, and we knew we’d keep writing more code, so the builds would get slower.

One way to make builds faster is to build less stuff each time, which in the Swift world means splitting code into modules. If code in module A depends on module B, and module A changes but module B does not, then module B does not need to be recompiled in order to get a working app binary.

Splitting the code into modules came with two challenges: the developer experience of creating the modules, and refactoring our code to allow different types to exist in different modules.

Choosing to use Tuist to make module creation trivial

Splitting the codebase into modules required either adopting a new tool, or choosing to deal with the native Xcode workflow. We spent time investigating four options.

While looking at these options, our most important criterion was the developer experience. We knew modules would make the build faster, but if we sacrificed developer experience in other ways, the gains would be useless. However, we also looked at the adoption cost of each tool, and whether they support remote build caching and selective test execution.

The option with the lowest starting cost was to use Xcode’s built-in GUI to manually add new build targets. While this approach is cheap to prototype and doesn’t require new dependencies, we’d have needed to write a graphical runbook to help people make new modules, and it would have still been error-prone and tedious. We wouldn’t be able to automate adding files to the project, or defining dependencies.

Another option was to use Xcode’s native Swift Package Manager support, and create local Swift packages. We prototyped this, but the developer experience still wasn’t very good, especially tracking down the root causes of compiler errors. Additionally, writing Package.swift files by hand was just about as error-prone and tedious as working with the Xcode GUI. The local modules we tried to create all had very similar and repetitive Package.swift files, and those files couldn’t share code.

Many companies use Bazel to manage their iOS builds. We investigated Bazel, but found serious issues with its developer experience. Bazel is a very heavyweight set of tools that requires new mental models, and we ran into workflow-breaking issues with breakpoints.

The last thing we looked at was Tuist, a relatively new project which generates Xcode projects from Swift source code. We found almost no downsides.

Tuist’s advantages

Tuist is a command-line tool that automates the creation of Xcode projects and workspaces. Swift code in a Tuist manifest has full access to the Foundation framework. With this power, we believed we could write a Tuist manifest that would automate nearly every task to be based on the filesystem, not manual configuration. There would be an onboarding process for people who did need to make changes for special cases, but the day-to-day experience of an iOS engineer would be that things just work, and they could use Xcode without thinking about Tuist. This all came true, with the help of  some light Python scripting to automate creating new modules.

A few aspects of Tuist reduce the risk of a migration going wrong. Tuist’s output is an Xcode project, so we could always go back to checking the project into source control and stop generating it with Tuist. Also, Tuist’s documentation is really good, so we wouldn’t need to do as much work writing our own internal docs. Finally, being written in Swift, we felt we could contribute code back to the project if we needed to fix a bug.

Migrating to Tuist

Tuist has one limitation we needed to overcome: lack of first-party support for CocoaPods. So first, we needed to migrate all our CocoaPods dependencies to Swift Package Manager or Carthage. Fortunately, all our dependencies supported one or the other.

Then, we needed to migrate all our build settings to .xcconfig files using tuist migration settings-to-xcconfig. This was straightforward.

With the prep work done, we were able to write a Tuist “manifest” (project definition) from scratch. This part took a couple weeks, and we did need to keep tweaking this for months as we made new modules and discovered subtle errors we had made during the migration, but none of those were really Tuist’s fault.

Finally, once the Tuist manifest was in a state where engineers could reasonably be asked to use its output, we wrote git hooks to run tuist generate on every git checkout, and removed the old Xcode project from source control.

Refactoring to support modules

When all our code was in a single module, we had never needed the public keyword, so we never used it. Thousands of classes, methods, and properties needed to be made public. We eventually resolved this by writing a Python script that could parse Swift code and mark every internal symbol in a file as public, and running it on various files as needed.

Our other big problem was circular dependencies. Consider Asana’s project view and task view: a project needs to open a task, and you can then open a project from within that task. We needed a way for ProjectViewController and TaskDetailsViewController to instantiate each other. We solved this problem using something resembling Tuist’s µFeatures architecture, which puts a feature’s public interface in its own module. Refactoring code to use this pattern was the most arduous and manual step, because in most cases we were cutting dependency boundaries for the first time, often in very old code.

Both of these changes were applied to different parts of the codebase at different times over months. Just a little at first, then a lot as we broke out new modules, then just a little again when the dust settled.

Actually making the modules

The first thing we did after making the Tuist manifest was to put almost all of the code except AppDelegate into a big module called AsanaCore. We wanted to discover and fix workflow issues as soon as possible. We chose to use static libraries.

The main workflow issues we encountered after creating AsanaCore were:

  1. We use NSKeyed[Un]Archiver to store unsynced user actions. We needed to tell NSKeyedUnarchiver how to find types that had been saved using a different module name so user data wasn’t lost.

  2. Our XLIFF string exports from Xcode had different metadata, which made a huge diff in our translation backend. We fixed this and other issues by writing Python code to patch the XLIFF files after export and before import.

  3. Importing XLIFFs back into Xcode didn’t work at first, because although Xcode was happy to export strings in AsanaCore, it didn’t recognize them for some reason on import, so it would discard all of them. We fixed this in a really silly way: manually generate a Swift file containing all strings, add it to the app target (using Tuist of course), and then delete it after import. String lookups continue to work just fine.

The localization stuff was hairy, but the challenges were within our reach and the solutions didn’t need to be touched after being written. Teams that use different approaches to localization than we do might have an easier time. And the localization challenges weren’t Tuist’s fault—after all, it was just generating projects the way we asked it to.

With the workflow issues fixed, our build graph still looked pretty simple. We had a “module,” but no build time savings. Really the worst possible world to be in, though it wasn’t perceptibly worse than before.

From here, our approach was to “lift” features upward out of AsanaCore, and “push” non-feature code downward out of AsanaCore into more granular frameworks. The first big feature module we pulled out of AsanaCore was Inbox:

The reason we chose Inbox is because Inbox calls other code a lot, but not much other code calls Inbox.  It was a perfect test of whether our modules-make-good-build-times hypothesis was correct.

Well, it was correct. Incremental build times for changing a line of Inbox code went from 30-50 seconds to 4 seconds.

Once we had our first feature module, we began creating more, ranging from 5,000 to 30,000 lines of code. At the same time, we found ways to carve other parts of AsanaCore out into framework-level modules. Eventually, AsanaCore contained just 21% of the code in the app. Pushing code up from AsanaCore into feature modules reduced time spent rebuilding feature code an engineer wasn’t actively working on, while pushing code “down” from AsanaCore into a framework reduced time spent rebuilding code that rarely changed at all during feature development.

We spent about 3 months incrementally moving code into modules. We’d establish a beachhead with just a few files, then migrate code into the new module a bit at a time. We also wrote a command line script that could both move files between modules and update their imports.

Where we are now

Our Tuist manifest is about 1,400 lines. A lot of this is intrinsic complexity, doing different things depending on environment variables related to different build types (debug, TestFlight, nightly, etc). Some of it is automation, so it just takes a few lines to add a new module. There’s very little code that doesn’t have a critical role in the project definition.

Our median build time is 15 seconds. We do wish it were even lower, and we expect to get there eventually using build caching, but for now we’re very happy with our progress. We log all our build times to Scalyr, so I can show you the distribution:

Distribution of build times, evenly distributed on a log scale between zero and 200 seconds, with a spike at 15 seconds.

X axis: seconds to build on a log scale. Y axis: number of builds during the period.

We’d love to know whether the type of module modified in a given build affects that build’s duration, but unfortunately we haven’t been able to get trustworthy data out of Xcode build logs to find out.

Our module graph now looks something like this:

wp block image

Splitting the frameworks below AsanaCore like this doesn’t affect build times much, but down the road, we expect it will let us skip running test suites for code that changes rarely unless that code has been changed.

Here’s the breakdown of module size. We’re roughly where we expected to be, although we hope to get even more code out of AsanaCore, since it mostly represents old features we haven’t chosen to refactor yet, or code that doesn’t have a clear home at the moment.

Pie chart showing the percentage of code that each module represents. AsanaCore is 21%, CommonUI is 15%, AsanaData is 14%, and then about ten smaller modules take up the other half.

Was it worth it? Should you do the same?

With the benefit of hindsight, everyone on the team agrees Tuist has been a hugely positive change. While the migration brought new issues, the benefits are well worth the cost of dealing with those issues.

New problems

Tuist is a complex new tool that only a minority of our iOS engineers are comfortable with. We’ve found that having 3+ Tuist experts is fine in practice, but we’re always working to help everyone become comfortable with tuist edit. On the bright side, it’s very rare for anyone to need to edit the Tuist manifest unless they’re working on some build performance or developer experience improvement.

git checkout takes 5-15 seconds longer than it used to. We wish this were faster, but we also appreciate that we don’t need to deal with project file merge conflicts at all anymore.

Using modules has forced us to write a bit more boilerplate code to separate interfaces from implementations. However, in some ways this was a positive change, forcing us to be more explicit about API boundaries between objects.

New benefits

  1. Autocomplete works better with modules. Xcode seems much more capable of dealing with many small modules rather than a single big one, perhaps because it can cache its index per target instead of constantly having the current target’s index invalidated by trivial changes.

  2. Treating project configuration as code makes sense when your app is complex. It’s easy to leave comments on changes to the Tuist manifest on a GitHub pull request or in the code, which wasn’t true at all when we’d change the Xcode project in the GUI. Project-file-related merge conflicts are all gone.

  3. Repetitive changes are easy to make. Making a new module requires running one command on the command line and then adding the implementation.

  4. Being able to completely rely on the filesystem as the arbiter of a file’s target membership has prevented many errors and drastically reduced the number of clicks it takes to move a group of files between targets. Xcode often guesses wrong which target a new file should belong to, but our handwritten Tuist rules are always right.

  5. I’ve said it many times in this article, but it’s worth saying again: builds are much faster!

There are additional benefits we haven’t yet reached for, particularly in the realm of CI. We’re looking forward to running our tests in parallel, caching build artifacts more aggressively, and running only tests affected by changed code in a PR, rather than running all tests on every PR.

Personally, if I were starting a new iOS project today, even as a solo developer, I’d use Tuist from day one. Any team that can deal with the extra complexity should make the switch. Building your own Project.swift from scratch will teach you enough to understand and maintain it.

We love Tuist so much that we sponsored the project this year. The maintainers have always been responsive and helpful. Thank you for everything, Tuist maintainers!