Our Journey in Reducing Traveloka iOS App’s Build Time by 90%
Today, we hear from Hendy on the journey he and his iOS team took to modularize Traveloka’s app, including the challenges they had to overcome to streamline build time, and the high-performance build tool that helped them to reach such a milestone.
Hendy Christianto is a Senior iOS Engineer in the iOS Infra Team. The team serves as a foundation/central team that maintains and improves iOS infrastructure, performance, along with other technical excellence aspects in the Traveloka iOS app.
As we move towards a lifestyle super-app, projects will get bigger and more complex. Traveloka started with just flight and hotel booking, and now you can also book transportation, buy movie tickets, order food delivery, even get financing/insurance services, and many more.
During the transition, we were using a single xcodeproj file with a monolithic app architecture until we encountered an exponentially pressing build time issue. The more features we provide to the end-users, our codebase grows, and consequently, build time also increases to compile our code. Debugging and team agility became slower every month and were the two most painful processes that impeded our daily development.
In this article, we will share our journey that started in 2019 on how we reduced build time from a monolithic app, what problems we encountered during implementation, and how we tackled them.
- Modularising your huge app is beneficial despite the long to-do list of heavy tasks. It will increase your development velocity and make your app more scalable and reusable. For the Traveloka app, we have reduced build time by up to 90% using a modular compile approach.
- Always start with the module dependency graph design, set the basic module principle, and plan the timeline. Modularising your app blindly will produce more (unintended/unexpected) problems than solutions along the way.
- Buck is a tool designed for modular application and it works great in a monorepo environment. It compiles apps using deterministic compilation to produce reusable build output. We mistakenly implemented Buck in our huge (pre-modularized) monolithic app that had caused extended build time until we modularized our app and achieved a 48% faster build time.
- Consider using an on-premise CI (Continuous Integration)/CD (Continuous Delivery). Extending and customizing your build system with a SaaS-based CI/CD will be hard, especially when using a modern build system.
Our Three Challenges
#1: Huge Monolithic App
In early 2019, we used a single xcodeproj file to build the Traveloka app. Since we shifted towards a super-app by the end of 2019, our codebase/LOC (Line of Code) had also grown by ~1.6x (from ~500.000 to ~800.000 lines) excluding 62 external in-app dependencies.
#2: Mixed Objective-C and Swift Languages
We adopted the Swift language in March 2019 and by the end of that year, it had reached 30% adoption. Swift is still, to date, slower to compile compared to Objective-C, especially noticeable with relatively huge projects. In addition, mixed Objective-C and Swift languages compile much slower than either purely one or the other, intelligibly, due to the additional compiler’s tasks to compile Objective-C’s objects declared in the Swift’ s bridging header file and vice versa. Each time an Objective-C object imports a Swift-generated header file, any Swift interface’s change exposed to Objective-C will recompile that object as well.
The main key to optimize with mixed Objective-C and Swift languages compile time is to minimize the exposure of both languages’ objects to each other. But since we have a monolithic app with a huge project, our bridging header file imports hundreds of Objective-C files, and the generated Swift header file, in turn, also has so many objects exposed to Objective-C.
#3: Unreliable Xcode Cache
Xcode produces artifacts and builds outputs in the DerivedData folder. But the cache system is tightly coupled with the compiler machine. In addition, our CI environment has more than one machine. Thus, the cache is unreusable. Other than that, each time we change between base and feature branch, Xcode will try to perform a clean build and invalidate the previous cache.
Journey #1: Buck Build System Integration
We began our journey by using Buck to enable reusable cache among CI machines and decreasing build time through incremental builds. We chose Buck because it was the only modern build system that supports mixed languages.
Issue: Header Import
Buck imports external dependencies using an angle brackets-style: #import <header_prefix/header_file.h> for public header and quotes style: #import “header_file.h” for private header files. However, CocoaPods, our main third-party dependency manager, allows mixing both import styles that clash with Buck. This conflict can be solved by adding the header map file path to the preprocessor_flags.
Result: Compile Time Bottlenecks → Slower Unit Test
After we fully migrated our project to Buck, it turned out build time was unexpectedly 3x slower after running unit tests through Buck than Xcode.
We noticed two bottlenecks during build time when analyzing the build trace produced by Buck in Google Chrome :
- Archiving the Traveloka app.
- Creating test bundles.
The archiving and test bundle creation took ~2.5 mins and ~3 mins respectively. In total, building the app took ~5 mins longer (than in Xcode) due to compiling hundreds of needed XIBs and vector images through ibtool and actool respectively. To give you a context, the ibtool compiler itself took up to ~1 sec to compile one XIB and the process is not concurrent. Our app was ~150MB and we knew it could be streamlined through modularization.
Journey #2: App Modularization
To break down the bottlenecks, we need to modularize our app first, so that the archiving and ibtool + actool compilations can be done in smaller modules simultaneously.
The Modular Principles
Migrating from a monolithic to a modular environment is a challenging task, even more so (for us) with the inherited spaghetti code to be migrated along. We had to refactor, decouple dependencies, and keep the module dependencies acyclic. To tackle that, we created the modular principles:
- Leveling modules. Each module must have a level that will be used as a base guideline to manage the dependency among modules.
- Limiting dependencies. Only higher-level modules can depend on lower-level modules, not vice versa nor across the same levels. For product modules, since product modules cannot depend on one another (the same level), they will need to “bridge” with the core modules that are placed lower in the middle level.
- Bottom-up migration. After we level the modules, we start migrating from the lowest-level modules (foundation layer) since it has the most dependencies with other teams/products. And If a product has a core module, the migration will start from there.
- Code duplication. While one of the main goals of modularization is reusability, sometimes it’s fine to duplicate code. If a module depends only on one class or object, it is best to just duplicate the class and rename it rather than having a module dependency because If a dependency-providing module has 100 objects, then we have to compile every of those objects too. So, the less dependency, the fewer objects to compile with duplication.
After we moved into a modular environment, we decided to use one repository only. However, after drawing our graph modules, the dependencies turned out to be complex. We want module’s changes to be reflected directly in our repository without having to distribute or version the module. Besides that, we were still planning to use Buck / Bazel in the future, both of which were created for a multi-module with a monorepo environment.
SandboxApp is a micro application that can run a product module directly without having to compile the whole application. This feature boosts our developers’ agility in their daily development. For example, when they switch branches, they don’t have to clean-build the whole application. With SandboxApp, changes made such as, but not limited to, UI, refactor, or additional features, can be reflected and built quickly. Not only that, SandboxApp comes with the ability to plug in or plug out other modules on demand. For example, our Flight developer can plug in the Booking module to test their flight booking flow.
To initialize SandboxApp dependencies properly and to standardize similar behavior with our main app, we created TVLApplication module that manages both our main app and SandboxApp dependencies, services, and configurations initialization across hundreds of modules.
Issue: Dependency Management and Module Template
We use CocoaPods to easily manage dependency for our ~100 modules using a :path reference to our local path. Moreover, to ensure all teams develop in the same structure, everyone uses a forked pod template, which covers:
- Basic CocoaPods setup and dependencies. By default, any product module always has modified podspec, podfile, BUCK file, SandboxApp, and dependency to infra modules.
- Initialization scripting. This script adds a swiftlint path, and copy required files.
Issue: Frequent Conflict in Xcodeproj and Pods
This issue is common in every modular migration in iOS. Because xcodeproj contains file references in the project, they will change during the migration as we move files into a new module project. Likewise, because we also depend on CocoaPods, our Pods folder’s contents will also change and impact the Pods.xcodeproj file.
To solve this issue, we use Xcodegen and multi-project CocoaPods so that each module can generate its own xcodeproj.
Result: Faster Build!
During POC (Proof of Concept), we benchmarked and successfully decreased our build time in local machines by up to 90% 🚀 from building a specific product through SandboxApp instead of building the whole app.
Not only that, our CI build time also improved. Previously, our project was a huge monolith with a mix of Objective-C andSwift languages. When the modular adoption rate reached >50% in September 2020, we saw decreasing build time despite the increasing LOC.
Journey #3: Buck Reintegration
After the modular migration, we thought that was the best chance to try reintegrating Buck to Traveloka app. The reintegration didn’t take that long because we had become familiar with it. Each module would have a BUCK file that contains rules to build the module. We added the template to our forked pod template so that developers can modify the BUCK file with minimal changes.
We also migrated our CI using Buck. The migration took 2 months, during which we used Buck only in the development environment (PR builds and staging builds), so that we could capture any anomaly or missed configuration, before committing it to the production environment (public distribution/release).
Result: Faster, Faster, Faster
After we modularised the Traveloka app, the two previously mentioned bottlenecks of archiving the app (XIBs and vector images had been moved to modules) and creating test bundles are gone! See Figure 9 below. 🎊. The ibtool compiler can also now run per module (instead of the whole app).
As can be seen in Figure 10, all of the processes are now divided into each module. XIBs and assets are also compiled into bundle resources, while codes are compiled into a framework in each module.
The CI builds are blazingly fast! Buck can reduce our CI build time by up to 48% 🚀 See Figure 11 below.
The Next Journey
We can still optimize Buck integration. What also makes Buck fast is the distributed cache feature, which can be used not only across builds, but also on developer’s local machine. But we haven’t implemented it yet due to a network bottleneck in our CI/CD system. We use hosted CI/CD, which also limits some Buck features.
Since the number of modules will keep increasing inevitably over time, we will continue to optimize and maintain our module design architecture with the guidance of our modular principles.
This is our journey, how about yours? If the challenges presented in this article are of interest to you, please check out our career’s page to pick your journey with us.
Big thanks to Albert Januar for providing valuable feedback, direct hands-on migrating of some modules, and guiding me throughout this project. Thank you David Theosaksomo and David Christiandy for all discussions and contributions to this project. Thanks to all product teams for your collaboration with the iOS Infra team to make this happen. Also thanks to iOS evangelists Chandra, Michael Christian, Agustinus Kevin Pongoh, Bobby Suryanaga, Stefano, Octaviano Putra, Stevanus Andrian Mudita for providing information about the product contexts and scenarios in your domain. Thanks to Yunita Andini, Otniel Yehezkiel, and Doni Ramadhan for providing feedback and suggestions to this article.