🚀  Introducing Rollouts by Runway! Learn more  📈
🚀  Introducing Rollouts by Runway!  📈
Learn more   

How to get started with Xcode project generation

The bedrock of an iOS app is the Xcode project. It defines not only the source files to include and how to build them, but the app's dependencies, entitlements, build settings, and everything needed to take the project from sources & settings to an actual app on your phone. The project file is incredibly powerful, and without it we can't really build our apps. It’s also a file built in an undocumented format, prone to merge conflicts that can — if not done correctly —– render your project unopenable by Xcode. For indie developers, and even small teams, dealing with the occasional Xcode project file merge conflict is just part of developing for iOS, but as your team — and app — gets bigger, making changes to your Xcode project can seriously derail progress on making meaningful changes.

Thankfully, there’s a fantastic way to avoid these kinds of situations: avoid committing your Xcode projects ​to your repository, and instead use an Xcode project generation tool to dynamically generate your project files with a simple command. But before we get to these tools, let’s go spelunking in a project file and see how it works.

Anatomy of an Xcode project

Did you know that an Xcode project file (<code>.xcodeproj<code>) isn't really a file at all? It's a "package"; a folder made to look like a file to the file system. Inside of this folder lies a file that harnesses the power of an Xcode project: <code>project.pbxproj<code>. This file holds references to a project's targets, source files, dependencies, Swift packages, and more. A brand-new project will have somewhere in the neighborhood of 343 lines in that file (as of Xcode 14.1). Here's a sampling:

/* Begin PBXSourcesBuildPhase section */
		93DCDA872935AA1500ACDD41 /* Sources */ = {
			isa = PBXSourcesBuildPhase;
			buildActionMask = 2147483647;
			files = (
				93DCDA912935AA1500ACDD41 /* ContentView.swift in Sources */,
				93DCDA8F2935AA1500ACDD41 /* ProjectFunApp.swift in Sources */,
			);
			runOnlyForDeploymentPostprocessing = 0;
		};
/* End PBXSourcesBuildPhase section */
​
/* Begin XCBuildConfiguration section */
		93DCDA972935AA1600ACDD41 /* Debug */ = {
			isa = XCBuildConfiguration;
			buildSettings = {
				ALWAYS_SEARCH_USER_PATHS = NO;
				CLANG_ANALYZER_NONNULL = YES;
				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";

As you can see we've got some source files (<code>ContentView.swift<code> and <code>ProjectFunApp.swift<code> which are part of a compilation build phase) and some build settings belonging to the compilation of that target. More and more things get added to the project as it gets bigger, to become the full app that we could ship. Thankfully the project file is managed wholly by Xcode, and for simple apps (and indie developers) you rarely have to think about its inner workings. 

Things get more complicated when multiple developers make changes to the Xcode project, and then commit them to a shared repository. Even something seemingly harmless — like two devs making project changes at the same time — can result in overlapping updates to the project file, and you may soon find yourself needing to make heads or tails of a conflict. Speaking from experience, it is definitely a worrying experience to find yourself hand-editing an Xcode project file — it can be very difficult to resolve project file changes correctly, and even a small mistake (like missing a semicolon) can result in an Xcode project that simply won’t open. It’s not uncommon to need to revert your changes and have to attempt the merge again, wasting hours of time that would otherwise be spent developing great new features for your app. As your team gets bigger, these situations become more common — which is why for many teams, choosing to explore Xcode project generation (and get rid of checked-in Xcode project files) is a question of when and not if .

Xcode project generation makes it possible to avoid making changes to Xcode project files directly by instead _describing_ a project using a manifest. Tools such as XcodeGen or Tuist can then read from your manifest to build an Xcode project for you based on the files on disk, build settings, dependency graphs, and all the things that we need in Xcode projects.

Changes to a manifest file are much simpler to make, and merge conflicts (when they arise) much easier to resolve. Using this approach makes it possible for many developers to collaborate on a single project in a much more streamlined way, without needing to waste time resolving issues with project files any time a change to the project is made. 

Choosing Xcode project generation is a no-brainer for growing teams, but it’s not always straightforward to adopt – especially if your team hasn’t been particularly neat or careful with your Xcode project files in the past. Luckily there are some preliminary steps you can take to help smooth the transition, setting you up to implement Xcode project generation as seamlessly as possible. 

Preparing for project generation

Extract your build settings

The first step when preparing to transition to Xcode project generation is de-emphasizing your project file:  removing from it as much as possible. One of the lowest-hanging fruits are build settings. Thankfully, the wonderful BuildSettingsExtractor by James Dempsey is there to help you out. It examines each target in your project and creates <code>xcconfig<code> files for each target configuration, as well as a shared file that each configuration file imports.

Once these files have been extracted, you can attach each file to its correct target configuration using Xcode. I’d even suggest taking this one step further and having a script run to verify no build settings creep back in to your project file — helping to keep your project file as simple and lean as possible.

Clean up your files

All Xcode project generation tools require a manifest spec which points to globs of files in your repository. These become sources & resources for Xcode to assemble your targets into their final product form. If you have files strewn all about the repository, it can be difficult to pinpoint exactly what files are contained in any given target. For this reason, it’s a good idea to determine how you want your target directories to be organized long-term — and start implementing that directory structure as soon as possible.

Here’s how this can be done:

0. Run the excellent Synx tool on your repo. You may find your project file version to be old enough that Xcode won't move files on disk when moving them in the project, so things have gotten way out of whack. This tool ensures you'll be working with files in their proper places from the start (for the most part).

1. Using Xcode, create the directory structure for each target and start moving files & resources around. Start by creating the top level <code>Sources<code> directory and moving sources in there. Then look back in the Finder and see what files are left over. There's a good chance you can just delete them straight away — if Xcode didn’t know about them, then they aren’t affecting your app.

<tip-green>Tip 1: It can be a good idea to start building the project manifests in parallel to the file reorg. By building the manifests for each target (defining where to find the sources, resources, target build phases, etc) you can ensure that at the end of the day you'll have a buildable project from the collective manifests.<tip-green>

<tip-green>Tip 2: One of the big things the Tuist docs point out is to order operations by target dependency count. Start with targets that have no dependencies, get their files organized, and you'll be able to have a project and a building target right away. This can help to build momentum in the project.<tip-green>

2. Once a target is organized in the filesystem, and it builds using the new generated project — and also still builds with the original Xcode project — make a PR changing just that target in the main branch. Keep all this churn as incremental as possible rather than dumping a PR that touches literally every file in the repo. So instead of one massive PR, you'll have many smaller (but still big) PRs that landed in sequence before the one which generates the project file lands.

Evaluate your options — choosing a project generation tool

Once you’ve done the preliminary clean-up work, you’re ready to adopt a project generation tool to build your project files. As mentioned above, there are two big players in the project generation space: XcodeGen and Tuist. Each have their merits and drawbacks, and they are built very differently, so it’s important to consider their differences in the context of your team when making a choice.

XcodeGen

  • ↔️ Manifests are all defined in <code>yml<code> or <code>json<code> files.
  • ✅ Super fast project generation.
  • ✅ Templates greatly enhance flexibility & reuse of things like target definitions.
  • ✅ Lightweight app built in Swift with binary releases available for download. It’s super easy to integrate into your repository.
  • ❗️ Manifests only have so much flexibility because they aren’t defined in code.
  • ❗️ Errors can be hard to debug (yml is a good but somewhat picky format).

XcodeGen utilizes a Project Spec file to define the project manifest (here’s a link to the documentation). Here is an example of a basic manifest:

name: MyProject
include:
  - base_spec.yml
options:
  bundleIdPrefix: com.myapp
packages:
  Yams:
    url: https://github.com/jpsim/Yams
    from: 2.0.0
targets:
  MyApp:
    type: application
    platform: iOS
    deploymentTarget: "10.0"
    sources: [MyApp]
    settings:
      configs:
        debug:
          CUSTOM_BUILD_SETTING: my_debug_value
        release:
          CUSTOM_BUILD_SETTING: my_release_value
    dependencies:
      - target: MyFramework
      - carthage: Alamofire
      - framework: Vendor/MyFramework.framework
      - sdk: Contacts.framework
      - sdk: libc++.tbd
      - package: Yams
  MyFramework:
    type: framework
    platform: iOS
    sources: [MyFramework]

This manifest creates a project file with a framework target and an app target (which depends on the framework). It also brings in a package dependency. Manifests can be split up and included in other manifests using the <code>import<code> key to another file.

Tuist

  • ↔️ Manifests are all written in Swift.
  • ✅ It’s extremely opinionated but uses those opinions helpfully (mostly).
  • ✅ You get a framework called ProjectDescriptionHelpers which lets you add whatever helpful code or additions you want to make your manifests as simple as possible.
  • ✅ There’s a fantastic community building up around it.
  • ✅ Can bundle itself and all of its dependencies with one command, making pinning to any given version (or commit hash) super simple.
  • ❗️ Generation is much slower than XcodeGen. This is because Tuist is attempting to do more for you, but sometimes I found that it gets in the way and I have to work around it when the Tuist way of doing things differs from my own.
  • ❗️ Errors can also still be hard to debug, because you can’t (currently) pause the debugger when running the manifest generator.

Tuist has a very healthy documentation section of their website. It details the protocols that it uses and that you can implement in your Swift code — code that you fill out in a module called <code>ProjectDescriptionHelpers<code>. Here's a basic manifest from Tuist:

import ProjectDescription
​
let project = Project(
    name: "MyApp",
    organizationName: "MyOrg",
    targets: [
        Target(
            name: "MyApp",
            platform: .iOS,
            product: .app,
            bundleId: "io.tuist.MyApp",
            infoPlist: "Info.plist",
            sources: ["Sources/**"],
            resources: ["Resources/**"],
            headers: .headers(
                public: ["Sources/public/A/**", "Sources/public/B/**"],
                private: "Sources/private/**",
                project: ["Sources/project/A/**", "Sources/project/B/**"]
            ),
            dependencies: [
                /* Target dependencies can be defined here */
                /* .framework(path: "framework") */
            ]
        ),
        Target(
            name: "MyAppTests",
            platform: .iOS,
            product: .unitTests,
            bundleId: "io.tuist.MyAppTests",
            infoPlist: "Info.plist",
            sources: ["Tests/**"],
            dependencies: [
                .target(name: "MyApp")
            ]
        )
    ]
)

This is a project which contains two targets: an app target and a corresponding test target. Like XcodeGen, the paths are all relative to the working directory where Tuist is being run. It's helpful to build out a suite of helpers to quickly access common paths and directory structures in your project. We could write a whole other post about setting up a module system in Tuist 😀

Tuist also is more than just a project generator. They've built out a whole dependency caching system which takes Swift packages and attempts to pre-build them for you, so that Xcode projects open up lightning fast and don't need to compile packages with the rest of your code. The Tuist team really is trying to build out a more comprehensive solution (akin to Bazel in some ways, but not aiming to replace <code>xcodebuild<code>). These additional features make Tuist an attractive option to some teams.

Other benefits of Xcode project generation

In addition to cleaning up your repository and improving collaboration as your team scales, project generation can create solutions to otherwise difficult situations. For instance, linking a Swift package to some build configurations but not others is something that is currently not possible within Xcode. But using project generation, it’s possible to entirely omit packages from certain types of builds (take for example the Proxyman Atlantis package, which you might not want linked in release build configurations). Using Tuist, for example, you can leverage environment variables inside of the Swift code that you write to associate specific packages to the project (or remove it entirely).

// in shell script
TUIST_IS_RELEASE tuist generate

// in Swift manifest
if Environment.isRelease.getBoolean(default: false) {
  // add the package to the project
}

As you work with tools like this and encounter these sorts of unusual situations, project generators can be another tool in your bag to solve them.

Adopting Xcode project generation to help your team scale

Xcode project generation is a great solution to the pain of dealing with project files as your team and app grows — and making the switch doesn’t have to feel daunting. Putting in some work at the beginning of your project can pay major dividends: slimming down your existing project file and getting your repository structure in place before (or while) you build out the manifests to generate your project will set you up for success during the transition. Both Tuist and XcodeGen offer a lot of power and flexibility, and the choice is yours as to which best suits your team’s unique characteristics. Once your team has adopted Xcode project generation, you will without a doubt quickly benefit from the improved collaboration it unlocks, and the additional flexibility it lends to working with Xcode projects going forward.

App Development

Release better with Runway.

Runway integrates with all the tools you’re already using to level-up your release coordination and automation, from kickoff to release to rollout. No more cat-herding, spreadsheets, or steady drip of manual busywork.