XcodeGen Tips and Tricks

Joseph Colicchio
Udemy Tech Blog
Published in
7 min readJan 25, 2021

Overview

When I started at Udemy, the first thing I asked my manager was, “Do we use XcodeGen?”. Integrating it into our workflow quickly became my first big project, and for a good reason. Anyone who regularly collaborates on Xcode projects knows how obnoxious it can be to deal with merge conflicts involving the Xcode project files themselves. It can be difficult to look at the raw, automatically generated XML and determine the appropriate merge conflict resolution. On top of that, it’s not immediately apparent whether or not the resolution was successful without recompiling. Wouldn’t it be great if we could deterministically regenerate the Xcode project based on the repo’s source file hierarchy and a configuration file?

An example of a merge conflict within the Xcode project file, which XcodeGen can prevent from occurring

XcodeGen allows us to express the Xcode project, including source files and groups, targets, project settings, and schemes, all as convenient, human-readable yaml. When source files are added, removed, or moved, rerunning the tool will update the Xcode project accordingly. Since the project file is no longer checked into version control, this eliminates an entire class of merge conflict. When merge conflicts occur on the configuration file itself, they can be resolved much more easily in human-readable yaml than in autogenerated XML. XcodeGen also plays nicely with CocoaPods, since we can run back-to-back xcodegen and pod install commands to generate the Xcode Project and Workspace, respectively.

When I integrated XcodeGen with our existing codebase, I ran into a couple of problems along the way and had to adapt a step by step approach. I’ll note that a lot of this was upfront work, and once integrated, the actual day-to-day maintenance has been a breeze. Beyond that, XcodeGen began paying for itself in avoided merge conflict headaches almost immediately and was dubbed “Advil for our team.” Looking back, I think it’s safe to say that while the amount of work required to integrate it will be proportional to the complexity of the original project, it’ll almost certainly be worth it in the long run.

The first issue I really had to reckon with was the fact that, while our Xcode project organizes the source files neatly into groups and sub-groups in the hierarchy, on the file system almost every source file was located in a single root folder. When I naively ran XcodeGen with a bare-bones configuration file, the resulting project predictably threw every source into one large group. I wanted to preserve the organization of our original project file, so I first set about organizing the files into folders to mirror the organization of the Xcode project. Once this was done (it was a long, tedious, and mostly manual process), I felt like the repo was in a state that I could begin working on including the right source files for the right targets.

Our project file builds four apps and features 8 schemes and 15 targets in total. Each target has its own set of settings, dependencies, and source files, with some overlap. My next challenge was to devise a strategy that would make it simple to control which targets would compile which sources. I played around with a few approaches before ultimately deciding to organize all source files first by whether they were compiled by the application, notification, or test targets, with a fourth folder shared containing sources used universally. Within each of these groupings, I further subdivided until there was a folder for each target, with other shared folders to contain source files shared by a subset of targets.

Code organized into app, notification, and shared folders, further dividing app into main, demo, and shared folder

Once done, it was relatively easy to assign sources to each target based on their locations in the file system. For example, the source folders for our Udemy for Government app consist of the shared, app/shared, app/B2B/shared, and app/B2B/UFG folders, representing global shared code, application shared code, B2B shared code between Udemy for Business and Udemy for Government, and Udemy for Government-specific code. Similarly, the Notification Content target uses shared, notifications/shared, and notifications/NotificationContentExt. For cases where a particular source is shared by one or two app targets, as well as a test target, we can add exceptions to either target's configuration to include or exclude certain files.

Once I’d reorganized the sources and had configured the targets, I began tackling the project settings, dependencies, and custom build phases of each target. This mostly involved poring over the individual target settings in Xcode and transcribing the various settings into yaml. I found it helpful to use yaml’s node anchors (denoted with ampersands), as well as XcodeGen’s Target Templates and Settings Groups, to avoid repeating settings across targets as much as possible. I wish I had more helpful tricks to facilitate this step, but ultimately it ended up being a largely manual, tedious process.

Finally, with project settings configured for each target, I set out to configure the schemes necessary to build, run, and test our apps. This was a fairly straightforward step, mostly involving copying various Launch Arguments and Environment Variables into the yaml for each scheme’s Run and Test phases. Once this step was done, I was able to build, run, and test our app using the newly, deterministically generated XcodeGen project. Success!

Tips and Tricks

Avoid Globfiles

While integrating XcodeGen into our multi-target project, I noticed that the command to generate the project was taking upwards of one minute to complete. I slowly pared down the yaml until I identified the culprit: globfiles in the project sources. After replacing them with explicit paths to specific files, the project generated in around 4 seconds. That’s not to suggest you can’t include whole folders of files with one line. app/shared/ is perfectly fine, but app/shared/*.swift is not. I've found it's sometimes cleaner to use the excludes option to denylist specific files that I do not want to include from a subdirectory

File Organization

As stated above, I found it helpful to reorganize our sources on the file system according to which targets they belonged to. There are certainly other ways to achieve this, but this strategy lets us add, remove, or change sources for a given target just by moving a file around on the file system, without needing to update any yaml.

createIntermediateGroups

This XcodeGen option was easy to miss but proved to be instrumental in the approach I’d adopted. When adding a folder e.g. app/shared/API, it ensures app and shared are added as intermediate groups, rather than just adding API to the root of the project hierarchy. In my opinion, it should be true by default.

Including Non-Compiled Files

Some files e.g. info.plist we want to show in the project directory, but we don't actually want to copy into the compiled app. In these cases, we can add the file using buildPhase: none to make sure it's included in the project but not copied on compilation. Alternatively, we may want some source file copied into the application bundle rather than being compiled. In this case, we can again use buildPhase to ensure the file isn't compiled and is instead copied into the app's bundle.

Scheme vs. Target Scheme

In the XcodeGen yaml, each target’s scheme can be configured one of two ways: as a top-level Scheme, and as a Target Scheme object. The latter is convenient for trivially simple schemes but isn’t fully-featured, so if your scheme is reasonably complicated or uses run options or launch arguments, you may need to configure the scheme via the more fully-featured Scheme object.

Localizations

Localized files are represented on the file system as multiple files with the same name, located within various lproj folders, e.g. de.lproj. XcodeGen sometimes fails to recognize these as localized versions of the same file and will add each localization as an individual file unless a version of the file exists in Base.lproj. Copying the localization from en.lproj or creating an empty file of the correct type with the same name in Base.lproj should resolve this issue.

Occasionally, I’ll discover that I need XcodeGen to behave in a way it doesn’t presently support. In one case, there was an issue handling a specific type of CoreData file. In another, we couldn’t configure the scheme to use a specific StoreKit configuration file. When this happens, I usually check the XcodeGen repo on GitHub to see if the issue has been reported, and if not, I’ll report it as a new issue. If the fix is straightforward, I’ll also draft a PR with the prospective fix. Sometimes, the issue isn’t possible to fix without first updating XcodeProj, a dependency of XcodeGen. In these cases, we can repeat the above process contributing to the XcodeProj codebase. The XcodeGen repo also features a number of automated tests that must pass on GitHub, so when making changes, I try to add or fix the appropriate tests with my PR.

tl;dr How You Can Integrate XcodeGen Today

0. Installing XcodeGen

XcodeGen can be installed via Mint or Homebrew or compiled from source and installed locally. More details can be found on the XcodeGen repo on GitHub, here: https://github.com/yonaskolb/XcodeGen

1. Move files on the file system

Make sure the source files on the file system are organized into folders mirroring the groups of the Xcode project. You’ll also need to create a barebones project.yml file. After this step, running xcodegen should generate a project including all of the same sources in the Project Navigator as the original project. To see an example of this, you can reference a sample repo found here

2. Arrange folders according to target inclusion

Try to organize source files according to the targets those sources belong to, optionally using shared folders. For each target declared in the yaml, add these source folders to the target's sources. After this step, we should be able to generate the same targets with the same compiled and copied sources

3. Project settings, dependencies, and build phases

Go through the original project and add any custom settings for each target to the yaml under the target’s settings. After this step, the settings for each target should mirror the original project’s settings

4. Scheme configuration

Add and configure schemes to the yaml mirroring the schemes in the original project. After this step, the schemes should be set up and ready to build / run / test

--

--