XcodeGen — A step torward good collaboration
All iOS teams know the pain of solving git conflicts in Xcode project files. Thanks to XcodeGen, we at Baracoda are now able to avoid these headaches. In this article I’ll show you how we use this tool. We take a modular approach based on the target templates inheritance XcodeGen offers and use some advanced features that you might not know about even if you’re already using XcodeGen.
Table of contents
Collaborating on Project.pbxproj files is a pain
The file Project.pbxproj inside Project.xcodeproj is an important file in Xcode configuration. It maintains references to everything one needs to develop and build an Xcode project: files, groups, frameworks, project configurations schemes, and all build settings. The file is auto generated by Xcode and is updated every time you add a file (any kind of file: source file, asset etc.) or whenever you modify the project’s configurations.
Here is an extract from a pbxproj file, showing how adding MyClass.swift class inside a subgroup alters the file.
1/* Begin PBXBuildFile section */
2 0371E2F925C8278900CCF14E /* MyClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0371E2F825C8278900CCF14E /* MyClass.swift */; };
3/* End PBXBuildFile section */
4
5/* Begin PBXFileReference section */
6 0371E2F825C8278900CCF14E /* MyClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyClass.swift; sourceTree = "<group>"; };
7/* End PBXFileReference section */
8
9/* Begin PBXGroup section */
10033CEA3D24544F5E00E2F7FA /* Sources */ = {
11 isa = PBXGroup;
12 children = (
13 0371E2F525C8277300CCF14E /* Subfolder */,
14 );
15 path = Sources;
16 sourceTree = "<group>";
17 };
180371E2F525C8277300CCF14E /* Subfolder */ = {
19 isa = PBXGroup;
20 children = (
21 0371E2F625C8277900CCF14E /* AnotherGroup */,
22 );
23 path = Subfolder;
24 sourceTree = "<group>";
25};
260371E2F625C8277900CCF14E /* AnotherGroup */ = {
27 isa = PBXGroup;
28 children = (
29 0371E2F825C8278900CCF14E /* MyClass.swift */,
30 );
31 path = AnotherGroup;
32 sourceTree = "<group>";
33};
34/* End PBXGroup section */
35
36/* Begin PBXSourcesBuildPhase section */
37033CEA3724544F5E00E2F7FA /* Sources */ = {
38 isa = PBXSourcesBuildPhase;
39 buildActionMask = 2147483647;
40 files = (
41 0371E2F925C8278900CCF14E /* MyClass.swift in Sources */,
42 );
43 runOnlyForDeploymentPostprocessing = 0;
44};
45/* End PBXSourcesBuildPhase section */
The snippet can give you a clue about the newly added class, but it’s hard to follow and it’s almost impossible to have a clear picture of what was added and where. And this was just one file in an empty project.
You can imagine, this file tends to grow a lot in a real project, as in tens of thousands of lines of code.
In this example, just adding one class to the project made the pbxproj file larger by ~40 LoC. Moving any file from one Group to another or adding/removing new Groups will also result in many lines of changes. These changes also don't take into consideration the build settings, code signing, build phases etc.
An empty project created in Xcode, has a pbxproj with ~700 LoC. And when the team gets bigger with the project, this file will change more often, resulting in problems such as merge conflicts.
The Colgate Connect app, built by Kolibree, a Baracoda company, grew to over 60,000 lines across multiple files. Maintaining them became harder with each new contributor added to our team. Git conflicts appeared often and managing them was challenging, if not impossible, due to the nature of the file. However, the Project.pbxproj file is really important and we need to have it in the git repo…Or do we?
Introducing XcodeGen
“XcodeGen is a command line tool written in Swift that generates your Xcode project using your folder structure and a project spec.”
The principle is simple: it uses the disk files and folder structure to generate the groups and files in your project and it reads up a YAML configuration file for all other settings. The output is the Project.xcodeproj package (which includes the Project.pbxproj file) that can be used locally or on CI.
The smart thing about XcodeGen, is that git says “BuhBye!” to the entire Project.xcodeproj package (which in fact should be added to the .gitignore file), so no more git conflicts and annoying changes in the history.
But the benefits for using XcodeGen are far beyond the avoidance of git conflicts:
- Simplicity: It’s much faster to create and configure a new sub-project/framework since you’re doing it without messing around with Xcode;
- Consistency: You can use templates so you have all your sub-projects/frameworks with similar configuration;
- Readability: The files and groups are always synced up with files and directories on the disk, and the configuration YAML file is “human readable and git friendly”.
How we use it
At Baracoda, creating a new feature framework is rather easy with XcodeGen.
We have 3 chained configuration files:
1. Base configuration
This file defines a base configuration that is included in all other configuration files.
1options:
2 carthageBuildPath: ../Carthage/Build
3settings:
4 DEVELOPMENT_TEAM: [TEAM_ID]
5 SWIFT_VERSION: 5.0
6targetTemplates:
7 iOS:
8 platform: iOS
9 deploymentTarget: 12.0
10 settings:
11 base:
12 EXCLUDED_ARCHS[sdk=iphonesimulator*]: arm64 i386
13 EXCLUDED_ARCHS[sdk=watchsimulator*]: arm64 i386
14 watchOS:
15 platform: watchOS
16 deploymentTarget: 6.0
17 settings:
18 base:
19 EXCLUDED_ARCHS[sdk=iphonesimulator*]: arm64 i386
20 EXCLUDED_ARCHS[sdk=watchsimulator*]: arm64 i386
21 CrossPlatform:
22 platform: [iOS, watchOS]
23 deploymentTarget:
24 iOS: 12.0
25 watchOS: 6.0
26 settings:
27 base:
28 EXCLUDED_ARCHS[sdk=iphonesimulator*]: arm64 i386
29 EXCLUDED_ARCHS[sdk=watchsimulator*]: arm64 i386
This is the parent of all other configurations. Here we define the carthageBuildPath for easily importing Carthage frameworks later.
We also define here the DEVELOPMENT_TEAM and the SWIFT_VERSION. Basically at this step you can define all your common build settings.
You can take a look at the build settings you can configure in Xcode.
We also define the default base target templates that we’ll use around the project. Keep in mind that the targets can define their own build settings.
2. Base framework configuration
We then define a base configuration specific to frameworks (and if it matters, we have another one for projects).
1include:
2 - path: ../Config/common-project.yml
3options:
4 bundleIdPrefix: [BUNDLE_ID_PREFIX]
5settings:
6 CODE_SIGN_STYLE: Automatic
7 CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED: NO
8targetTemplates:
9 BaseFramework:
10 type: framework
11 preBuildScripts:
12 - name: Linter
13 script: "../Scripts/objc_todo_fixme_warnings.sh
14 ../Scripts/swiftlint.sh"
15 iOSFramework:
16 templates:
17 - iOS
18 - BaseFramework
19 watchOSFramework:
20 templates:
21 - watchOS
22 - BaseFramework
23 CrossPlatformFramework:
24 templates:
25 - CrossPlatform
26 - BaseFramework
27 BaseFrameworkUnitTests:
28 type: bundle.unit-test
29 settings:
30 CODE_SIGN_IDENTITY: iPhone Developer
31 dependencies:
32 - carthage: [CARTHAGE FRAMEWORK 1]
33 - carthage: [CARTHAGE FRAMEWORK 2]
34 iOSFrameworkUnitTests:
35 templates:
36 - iOS
37 - BaseFrameworkUnitTests
This one imports the base common configuration, in the first rows. Basically this is how we create the chaining between the configuration files. We define the bundleIdPrefix that will be set to all frameworks and some other simple settings.
A target template can inherit from multiple other target templates. This makes it easier to make them granular and specialize them as needed. For example, in our case, an iOS framework template inherits from the iOS base template (defined in the common configuration file) and a base framework template defined in here.
3. The Feature Framework configuration
For each new feature we start, we first create the new folders structure and a new project.yml file that will be committed to our git monorepo.
1include:
2 - path: ../Config/framework-project.yml
3name: NewCoolFeature
4targets:
5 NewCoolFeature:
6 templates:
7 - iOSFramework
8 sources:
9 - path: Sources
10 dependencies:
11 - framework: KolibreeFoundation.framework
12 implicit: true
13 - carthage: RxSwift
14 - carthage: RxCocoa
15 - carthage: RxFeedback
16 scheme:
17 testTargets:
18 - NewCoolFeatureTests
19 gatherCoverageData: true
20 NewCoolFeatureTests:
21 templates:
22 - iOSFrameworkUnitTests
23 sources: [NewCoolFeatureTests]
24 dependencies:
25 - target: NewCoolFeature
26 - framework: KolibreeFoundationTestable.framework
27 implicit: true
In this file we define the name of the framework and the final target that we’ll have in Xcode. Linking the dependencies is an easy job, as seen in the example, regardless of whether they’re internal or Carthage frameworks.
We also create the test target with its own dependencies in this file.
And that’s almost it. To wrap everything up, we open the terminal to our framework location and type the xcodegen
command. Once it is complete, we need to add our new NewCoolFeature.xcodeproj
package to our workspace Project.xcworkspace
. This is how we create a new framework.
One thing to consider…
Whenever a peer adds a new file to the project or modifies XcodeGen’s configuration file, you’ll need to run the xcodegen
command. This may seem like a downside, but it’s not a big deal.
If you’re using git, you can create a post-merge hook that handles the command automatically when you pull or merge new commits.
You can and should integrate XcodeGen into Fastlane to create a specific lane that handles the generation for your entire project solution. In the Colgate Connect app, we have more than 20 projects and frameworks configured with XcodeGen and executing the generation lane takes less than 10 seconds.
Additional cool features
Like any other tool, there are additional cool features that aren't well marketed.
We found it useful to define settings groups so they can be integrated much easier in target definitions depending on whether it’s a production or staging build. For example:
1settingGroups:
2 SilenceEnabledGroup:
3 configs:
4 debug:
5 GCC_PREPROCESSOR_DEFINITIONS: DEBUG=1 GLES_SILENCE_DEPRECATION=1
6 SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG
7 release:
8 GCC_PREPROCESSOR_DEFINITIONS: GLES_SILENCE_DEPRECATION=1
9 SilenceDisabledGroup:
10 configs:
11 debug:
12 GCC_PREPROCESSOR_DEFINITIONS: DEBUG=1
You can define passing arguments at launch in a target’s scheme:
1ATarget:
2 scheme:
3 commandLineArguments:
4 "-com.apple.CoreData.ConcurrencyDebug": true
5 "-com.apple.CoreData.Logging.stderr": true
You can exclude sources:
1ATarget:
2 sources:
3 - path: WatchApp
4 excludes:
5 - "ExcludedPathOrFile"
You can set the header visibility for Objective C headers:
1ATarget:
2 sources:
3 - path: "APath"
4 headerVisibility: private
5 - path: "APath/File.h"
6 headerVisibility: public
It's possible to link a framework with Optional status:
1ATarget:
2 dependencies:
3 - sdk: Combine.framework
4 weak: true
Conclusion
Two years ago we started to integrate XcodeGen. We did it gradually for the existing projects in our workspace, starting with one of our test applications before moving to the feature frameworks. We missed it in the frameworks where we had not yet integrated it and we ended up setting a high priority for XcodeGen integration tasks. Currently, more than 95% of our build system is generated with XcodeGen, and we've never looked back.
Starting a new feature is now really easy for us. We don’t waste time or effort creating the project and making sure it’s correctly set up.
Almost always, we have multiple peers working on the same feature. Git conflicts are a part of our life, but thanks to XcodeGen, we can avoid some of the most annoying ones.