Optimizing compilation and test runs with Xcode projects
Slow workspaces and long compilation times can hinder developer productivity. Learn how Tuist optimizes Xcode projects to improve performance and accelerate feature delivery.
Xcode projects and workspaces can take a long time to compile in clean environments. The compilation time typically grows with the project, with periodic performance improvements due to hardware upgrades—a costly endeavor for organizations.
Some developers normalize this as an inherent challenge of Apple native development. Others introduce dynamic runtimes like React Native, which can hot-reload code and skip most compilation cycles. Some organizations even consider absorbing the cost of replacing Xcode’s build system.
For years, we have invested in solving this challenge using the most native and cost-effective approach possible through Xcode primitives. We believe that unproductive development environments can negatively impact organizational productivity and the quality of shipped applications. Our goal is to help organizations prevent such inefficiencies.
In this blog post, I’ll share how Tuist addresses these challenges and the potential impact on your projects. Let’s begin by discussing project graphs.
Your Project is a Graph
Your project forms a compilation graph that the editor uses to provide a coding experience and that the build system uses to generate build products. Traditionally, this graph was statically codified in .pbxproj
and peripheral files like .xcconfig
or test plans. The build could be dynamically configured during operations like build or test.
This landscape changed with the introduction of Swift Package Manager (SPM). Suddenly, projects gained references to another graph—the package graph—which was dynamically generated by SPM and reconciled at build-time with closed-source code.
How can we optimize this? Simply put, we need to skip compilation steps in the graph. However, Xcode’s build system doesn’t allow direct manipulation of internal graph processing, unlike build systems like Gradle.
You can add tasks to the graph via build phases or SPM plugins, but what’s truly desired is a function that takes a graph as input and returns an optimized graph output. The image below captures this concept:
If optimizations were this straightforward, Apple would have implemented it already. To understand why it hasn’t happened, we need to discuss hermeticity.
Hermeticity in Xcode Projects
Bazel effectively demonstrates the importance of hermeticity in build systems. In essence, hermeticity means a build system should produce identical output given the same input, regardless of the execution environment. This enables artifact caching and sharing across different machines—a critical feature in distributed systems.
However, Xcode’s build system lacks hermeticity. Examples that break hermeticity include:
- Implicit dependencies relying on heuristics rather than explicit declarations
- Shared caches like derived data that can cause varying build behaviors
- Dependencies on external states like environment variables, system files, or user-specific configurations
- Non-hermetic scripts dependent on external commands with unpinned versions
Hermeticity isn’t just about build optimization—it’s crucial for making incremental builds more deterministic and improving features like SwiftUI previews. If the editor cannot determine the relationship between a change and the binary graph, previews simply won’t work.
Optimizing Xcode projects would require Xcode to evolve its graph toward encouraging hermeticity and discouraging implicit configurations. While Explicit Modules represent a step in this direction, significant progress remains ahead.
Encouraging Explicitness and Mutating the Graph
Tuist introduces a Swift DSL for declaring project graphs. When users define their projects, they’re essentially mapping out the module graph. The tuist generate
command transforms this graph into an Xcode workspace and projects.
This subtle capability is key to project optimization. By generating Xcode workspaces and projects, we can optimize them during the generation phase.
While we can’t completely solve hermeticity, we can encourage it through our APIs, particularly at the dependency level. By designing APIs that promote explicit dependency declarations, we can mitigate non-hermetic behaviors.
Our approach offers several advantages:
- Graph mutation using native Xcode project and target primitives
- A language that discourages non-hermetic configurations
- Utilizing XCFrameworks as compilation units
Caching and Selective Test Execution
By constructing a Merkle Tree that changes when a target changes, we can create unique identifiers for each target. This enables powerful workflows:
Tuist also introduces selective test execution. By persisting tests at the target level with associated hashes, the system can determine whether tests need re-execution.
Accelerating Feature Delivery
The combination of selective test execution and caching can yield significant CI improvements—up to 70% and beyond. Developers can optimize their Xcode projects without costly build system replacements or platform abstractions.
By declaring projects using a Swift DSL, teams can achieve optimizations, cleaner project structures, and more reliable Xcode performance. The impact on team productivity and business value delivery can be substantial.
Interested in learning more? Schedule a call with the Tuist team for a comprehensive walkthrough.