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.
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.
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.
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 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.
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.
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.
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:
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.
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.
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.
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:
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:
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.
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.
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.
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.
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.
Repetitive changes are easy to make. Making a new module requires running one command on the command line and then adding the implementation.
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.
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!