Scaling iOS at Bumble: Part 3/3 — Results and Developer Quality of Life
Recap
This is the third and final post in a series on our 2022 & 23 build and project management overhaul. We investigated whether one (or none!) of Swift Package Manager (SPM), Tuist or Bazel would be the tool to overcome our project scaling challenges.
In the first post we explained the state-of-play coming into this project. In the second we discussed the results of our proofs of concept. In this post we will explain our conclusions, and talk about the migration project itself.
The decision
We chose Tuist
It shouldn’t be much surprise that we didn’t choose SPM. In fact, we found it to be entirely unsuitable for our use case. This isn’t to say that SPM is a poor tool in general — far from it. Initially we would have preferred to adopt it, however we couldn’t ignore the fact that, at least regarding its Xcode-integration, SPM exhibits unacceptably poor performance for our use case, and at our current scale; and this is before we consider our expected future scaling! For those starting new, or smaller projects, SPM seems to provide a nice user experience. However, it also deserves to be regarded as a “first-party CocoaPods”. It serves a very similar use-case, and may lead to similar performance drawbacks.
Bazel was a different matter. It is undoubtedly a powerful tool, but is a high-investment choice; compared to both SPM and Tuist it diverges much from the traditional iOS development experience, increasing disruption for developers. Also, since Bazel experience is still quite uncommon, this adds extra onboarding load for new team members. We also need to support and maintain infrastructure internally; we have small infrastructure teams and Bazel presents concerns about poor bus factor for a tool that is a single point of failure. During our proof of concept it became clear that in spite of its power, Bazel would be costly for us in the short term.
In comparison, Tuist hit a sweet spot between the two. We could solve our immediate problems and do so while maintaining a familiar core development experience. Everything is written in Swift so the tool feels approachable by everyone in the team. Our analysis of its performance didn’t reveal any concerns. It even has a somewhat feasible long-term roll-back path (stop using tuist generate and commit the generated projects).
We’ll certainly remain interested in Bazel for the future. Since we’d be moving away from cumbersome Xcode projects and towards Tuist’s declarative manifests, we knew that a future migration would be simpler. But the time was not yet right for us.
The migration
Having finalised our decision in Q1 2023, we set about planning the migration itself. Details will differ for any team that executes a project like this, so we’ll concentrate on points that might be interesting to the reader. Our migration project had the following milestones:
- Finalise conversions for all apps and modules: polish the conversions from the PoC, convert all other projects. Aim for rough pass of majority of tests on CI.
- Supporting tooling: remove all dependency on Xcode projects; read all module information from tuist graph. Since we did not want to couple ourselves too tightly to Tuist, this would be implemented through an abstraction layer.
- Prepare for migration: begin merging backwards compatible changes, including Project.swift manifests. Tuist becomes available for local workflow.
- Execute the migration itself: delete all production app .xcodeproj. Tuist becomes the default. Support the team and address any issues that come up.
- Follow-up: mitigate tech debt and make post-migration improvements.
We were able to complete the migration in Q2 2023, with the follow-up milestone executed in the first part of Q3. Overall the migration was a resounding success. Although we encountered some breakages in our non-primary workflows, we did not lose significant velocity and we were eventually able to find satisfactory solutions to all unforeseen challenges.
Post Migration
It has now been some time since the migration, so we’re able to look back and assess how we’re finding things.
Easier Modularisation
The clear winner in terms of immediate benefits following migration is simplicity in modularisation. Even with the use of xcconfigs to share settings, creating and adjusting modules when using full .xcodeproj was cumbersome. Even with supporting tooling, that tooling needed to be maintained and could seem very opaque to users. All the effort of migration begins to feel worthwhile as soon as modularisation patches start looking like this:
Simplification & Uniformity
Going into this project, we were already benefiting from a decent state of module uniformity. This helped a great deal in the initial migration, and even more so once we’d stabilised the first versions of our ProjectDescriptionHelpers (aka. PDH). Tuist’s full Project API can be rather verbose, but much of this can be DRYed out. Project.swift for a typical feature module of ours is often as simple as:
More than that, following migration further improvements become immediately obvious: editing modules as simple text operations is very powerful, and templating new modules is also supported (in fact now the previous way of doing things feels frankly horrifying!) However, this convenience is significantly reduced if modules require a lot of outboard configuration files; we had been using a lot of xcconfigs to share and override basic module settings:
- Shared settings
- Module-type specific settings (app settings, tests settings, module settings)
- (Module specific) project settings
- Target settings
Each module required a handful of these stubs, which totalled up to almost 4000 files! Following the migration, some parts of this hierarchy were immediately redundant — many of our project settings files were simply a parent #include and PRODUCT_BUNDLE_IDENTIFIER definition, and these became empty when bundle identifiers were necessarily moved to Project.swift. Some modules had a handful of custom build settings, but there was no reason why these shouldn’t also move. We were able to drastically reduce these down to just the core shared configs; from almost 4000 down to just 39; a nice 99% reduction. We still had some other supporting files but these will also soon be removed, making sure each module is solely described in Project.swift. Eventually we’ll also move our shared configurations into SettingsDictionary and be completely rid of xcconfig.
However, we remain conscious that while ProjectDescriptionHelpers give us great power, they must also be used with care. Both Bazel and SPM place intentional limitations on functionality in module manifests; SPM allows import of only a small subset of core frameworks, and Bazel’s use of Starlark bakes simplicity into the language itself. Tuist is less limited out-of-the-box, and much more power is available in Project.swift and especially the PDH. This can make it tempting to introduce (overly-)elaborate behaviour, leading to complexity, slowing down the generate process of negatively affecting maintainability. From talking to other developers in the community, we also heard this as a common warning. To combat this we have begun tracking generate performance on our CI and enforcing limits of percentage growth for each change set.
It is also important to understand how tuist generate actually functions:
- tuist generate runs the Swift REPL, linking the ProjectDescription framework and passing the — tuist-dump command line argument [1]
- ProjectDescription’s dumpIfNeeded(…) method is called via the Project initializer [2]
- Here the — tuist-dump argument is picked up [3]. If passed, the Project instance is serialised to JSON and output to stdout
- The caller — generate — then reads the JSON serialisation from stdout and deserializes back to a Project instance in memory [4]
- Importantly: the JSON serialisation is the cacheable artefact for each module
This means, therefore, that the state of each module must be fixed at compile time, an invariant that would be broken by, for example, reading files from disk in Project manifests. This is one example of an important gotcha we learnt after migration.
Consistency in Code Generation
Following the migration, a particular focus in the team has been leveraging our new flexibility in order to modularise more, and better. However, a side-effect of this is it is now also easier to introduce bad modularisation. Examples of this would be importing modules from higher architectural layers, importing large modules intended for tests and demos into production targets, etc.
We protect against these by using our own custom static linting, but we’ve considered that greater integration into the Tuist workflow could be possible:
- Executing the static linter when tuist generate is executed. This is particularly powerful since Tuist caches each manifest as its JSON dump. This means we can guarantee linting is only run when the manifest is modified
- Leveraging the type system in our PDH, whereby illegal architectural relationships don’t even compile. This would require completely wrapping the Project API and adding type requirements for all legal targets/dependency relationships. We are yet to proof-of-concept this idea, and it may prove to be unrealistic in practice, but it remains an interesting concept
Needless to say, in both cases we need to be mindful of performance regressions!
Build & Test Performance
It is important to note that, unlike Bazel, Tuist does not promise improved build performance out-of-the-box. Tuist generates projects, and can leverage a lot of workflow power from this. But Tuist uses the same Xcode/xcodebuild toolchain as before.
Tuist provides the cache command and the Cloud project. Both leverage local/remote caching in order to improve build performance. So far we have only experimented with cache, where we noted that the implementation is more like a helping hand for the standard DerivedData-based mechanism. Tuist certainly cannot offer Bazel’s hermetically-sealed caching guarantees! It’s currently unclear if this command will be useful for us.
Test result caching, which we discussed in the previous post, feels like an unsung hero in the Tuist arsenal. However we don’t yet use tuist test on CI, instead we’re bound to our existing Fastlane run_tests-based solution. Perhaps this will change in the future.
Overall, while Tuist doesn’t promise automatic benefits in this area, it is important not to forget how much build performance gain can be achieved through better modularisation; improving parallelisation and breaking apart God modules. We certainly have a few of these in our sights and these tasks are far easier after migration.
Onboarding & Collaboration
While Tuist has a learning curve, it is mild. And in comparison to our internal tools and scripts it benefits from a much larger community. The Swift-everything familiarity factor is at least already there, and the development experience in Xcode is of course almost identical to before. We wrote a short Getting Started guide before migration, but this was a really quick reference to make sure the most critical information was available at a glance.
Community Support
We’re happy to report that our experiences with the Tuist developer community have generally been positive.
Often abandonware is a concern when adopting open source tooling. We’ve had the opposite problem; keeping up with the pace of releases has been a task! In fact, the recent addition of multi-platform support is currently a migration challenge for us, since we have some unfortunate historical inconsistencies in iPhone/iPad support. More recently, the abandonment of tuistenv as version manager has been another surprise.
On one hand, it is positive that the Tuist community is moving forward, but on the other hand these changes require us to invest more into the tooling. This has been in line with our expectations; SPM would have been less problematic in this regard; Bazel we would expect the same challenges keeping up with the project’s evolution.
Of course no-one knows what might emerge in the future from the notoriously secretive Apple! There is always the danger/promise of an Xcode replacement, although this seems rather unlikely… The .xcodeproj format will likely become sidelined in the future as more and more smaller projects leverage SPM. At that point it might be interesting to consider what will come next. Of course, this we cannot know right now.
In Conclusion
This was a very rewarding and successful project to work on. Such a large shift of workflow is always difficult, but did not take us long to stabilise. With its obvious immediate benefits, we had no trouble with buy-in from the development team and, other than some teething issues we fixed relatively quickly, we’ve had overwhelmingly positive feedback.
We recently marked the migration with a visit to a familiarly named London tourist attraction 🤩
Now is also a great time to shout-out our colleagues who actively collaborated on this project. Most notably Radek Cieciwa, Damir Davletov, Igor Savelev and many others who helped find and squash issues in the iOS platform team here. Thanks everyone!
We hope the information in this series will be useful to you, especially if your team is in a similar position to ours. Perhaps you’ll opt for SPM, perhaps Tuist, or perhaps you’ll want to go all-in and invest in Bazel. We’ll certainly envy your build times if you do, and perhaps we’ll join you there as well in a few years.
Happy building, modularising and migrating!