Better iOS projects: Getting (nearly) rid of .xcodeproj-Files - A (not so) short Introduction to Xcodegen

In the series “Better iOS projects”, we have a look at the various tools and environments that are useful to have a more convenient and efficient handling of iOS projects.

July 24, 2018, by Wolfgang Lutz(Twitter, GitHub)

header Xcodegen

Updates

  • 23. February 2021: Small updates (e.g. SwiftUI) and additional integrations (CocoaPods, SPM)
  • 28. July 2018: Removed brew tap call, as it is no longer necessary.

What is the problem with .xcodeprojs?

Xcode uses a project file, the .xcodeproj file, to bundle source code and resources for the IDE and build tools to digest. Though this works quite well most of the time, it has some downsides:

  • Though you can manually resolve merge conflicts in this file, e.g. if source code files or resources have been added on different branches, you can never be sure that the file is correct afterwards.
  • Syncing the folder structure on disk and the group structure in the project is mainly a manual process that sometimes leads to confusion. There already are tools to mitigate this, like synx or the sort functionality of the xcodeproj gem.
  • Xcode does not warn you when a file is missing until you compile.
  • Orchestrating dependencies and build scripts of multiple targets can become quite a hassle.

Introducing Xcodegen

Xcodegen is a tool, that allows us to generate the xcodeproj file from a definition in a file called project.yml. As the xcodeproj file can be generated whenever we like, we do not even have to keep it inside our git and can ignore it (though I personally prefer to keep it checked in, so I can see what changes my edits to the project.yml file introduced to the project).

Here are the two most important features of Xcodegen:

  • You can define every kind of Xcode target (application, frameworks etc.) for all sorts of platforms (iOS, tvOS, macOS and watchOS) this way.
  • It also allows to connect a folder of source files to a target, making it easier to manage which source code files are contained in which target.

Though XcodeGen is still quite a young project, it can already do a lot. Sometimes workarounds are needed, but the author is quite active on GitHub and bugs are often fixed only hours after reporting them. A big thanks for that!

How to install xcodegen

Amongst other methods of installation, you can install xcodegen using brew, by running

brew install xcodegen
  

or, if you are a loyal reader of this series, by using mint

mint install yonaskolb/xcodegen
  

Generating an App Project

First, create a new blank Single Page iOS App with Xcode to initially get all the necessary .swift, .xcassets etc. files. In the project creation dialogue, select UIKit. Xcodegen works for SwiftUI also, but the example in the end makes use of TinyConstraints, an AutoLayout Library, that is best demonstrated using UIKit.

We will now recreate the project using a project.yml file.

Leave Xcode and create a project.yml file with the following content in the root:

name: XcodegenApp # The name of the App
options: # Some general settings for the project
  createIntermediateGroups: true # If the folders are nested, also nest the groups in Xcode
  indentWidth: 2 # indent by 2 spaces
  tabWidth: 2 # a tab is 2 spaces
  bundleIdPrefix: "de.number42"
targets: # The List of our targets
  XcodegenApp:
    type: application
    platform: iOS
    deploymentTarget: "14.0"
    sources:
      #Sources
      - path: XcodegenApp
  

Projectyml

Then, rename the existing .xcodeproj (so that you can have a look at it and compare). I always just add “Backup” to the name here.

In the terminal, in your project root, run xcodegen or mint run xcodegen:

Xcodegen

Open the project and run it. You have the same results as before!

“The same results? But what about testing? The tests are gone!” I can hear you say. Do not worry, we will fix that immediately.

Generating TestTargets

Add the following target to the project.yml:

  XcodegenApp-iOS-Tests:
    type: bundle.unit-test
    platform: iOS
    deploymentTarget: "14.0"
    sources:
      - path: XcodegenAppTests
    dependencies:
      - target: XcodegenApp
  

Again, close Xcode. (Xcode is a bit peculiar about having changed the project files while they are open and gets angry sometimes.)

Generate the project, open it and run the tests: voila!

To add UI Tests, add this target:

  XcodegenApp-iOS-UITests:
    type: bundle.ui-testing
    platform: iOS
    sources:
      - path: XcodegenAppUITests
    dependencies:
      - target: XcodegenApp
  

That’s it, a buildable app project with working tests.

Generating a Framework Project

Let’s go a bit deeper now:

Maintaining submodules in Xcode has always been a bit of a hassle. Introducing modularization into your app is a breeze with xcodegen.

To learn how to do this, we create a XcodegenAppCore framework, that contains the classic fruit enum:

  1. Create a folder “XcodegenAppCore” in the root

  2. Create “Fruit.swift” inside this folder. Add this as content:

    public enum Fruit: String {
      case apple
      case banana
      case cherry
    }
      

    Fruit

  3. Add an Info.plist with these contents to the XcodegenAppCore folder:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">

<plist version="1.0">

<dict>
    <key>CFBundleDevelopmentRegion</key>
    <string>$(DEVELOPMENT_LANGUAGE)</string>
    <key>CFBundleExecutable</key>
    <string>$(EXECUTABLE_NAME)</string>
    <key>CFBundleIdentifier</key>
    <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>CFBundleName</key>
    <string>$(PRODUCT_NAME)</string>
    <key>CFBundlePackageType</key>
    <string>FMWK</string>
    <key>CFBundleShortVersionString</key>
    <string>1.0</string>
    <key>CFBundleVersion</key>
    <string>$(CURRENT_PROJECT_VERSION)</string>
    <key>NSPrincipalClass</key>
    <string></string>
</dict>
</plist>
  1. Add this target to the project.yml:

    XcodegenAppCore:
      type: framework
      platform: iOS
      deploymentTarget: "14.0"
      sources:
        - path: XcodegenAppCore
    
  2. Add

    dependencies:
        - target: XcodegenAppCore
    

    To the XcodegenApp target. The result should look like this:

    XcodegenApp:
      type: application
      platform: iOS
      deploymentTarget: "14.0"
      sources:
        #Sources
        - path: "XcodegenTest"
      dependencies:
        - target: XcodegenAppCore
    

    Close Xcode, run xcodegen, then run the project. Ok, it’s building, but nothing is happening yet.

  3. To test the framework in an UIKit app, add

    import XcodegenAppCore
    

    to a ViewController and add

    print(Fruit.apple)
    

    to the viewDidLoad().

If you created a SwiftUI project, you can change the view to this to test the framework:

  import SwiftUI
  import XcodegenAppCore // import the framework

  struct ContentView: View {
    var body: some View {
      Text("Hello, world!")
          .padding()
        .onAppear {
          print(Fruit.apple) // Use something defined in the framework when the view appears
        }
    }

The debug logger now proudly confirms you as the owner of your very own fruit related framework 🍎 🍌 🍒.

Adding Dependencies

CocoaPods

You can simply integrate the generated project with Cocoapods, like you would integrate any other project, just make sure to call pod install after every time you run XcodeGen.

Swift Package Manager (SPM)

XcodeGen has direct support for Apple’s own Swift Package manager.

  1. Add
packages:
  TinyConstraints:
    from: "4.0.1"
    url: "https://github.com/roberthein/TinyConstraints"

at the same level of the project.yml as the targets section.

  1. Add this line to your XcodegenApp target’s dependencies in project.yml:
  - package: TinyConstraints

Close Xcode, run xcodegen, then run the project.

Carthage

Yet another way to handle dependencies on iOS is Carthage.

XcodeGen makes it very easy to manage Carthage dependencies, so let’s add roberthein’s TinyConstraints as a Layout Library to learn how that’s done:

  1. Run

    brew install carthage
    
  2. Create a file named “Cartfile”

  3. Add

    github "roberthein/TinyConstraints"
    

    to it.

    Cartfile

  4. Run

    carthage update --platform iOS
    
  5. Add this line to your XcodegenApp target’s dependencies in project.yml:

      - carthage: TinyConstraints
    

    Close Xcode, run xcodegen, then run the project.

Testing the new Dependency

Whichever dependency manager you chose, you can now use TinyConstraints:

import TinyConstraints
      
In the viewDidLoad(), add:
let fruitLabel = UILabel()
fruitLabel.text = Fruit.banana.rawValue

view.addSubview(fruitLabel)
fruitLabel.centerInSuperview()
viewDidLoad Run the project to see TinyConstraints in Action:
iphone_screenshot

Acknowledgments

Thanks to Yonas Kolb for reviewing this article before release and to Maximiliane Windl for testing the tutorial, fixing issues and making screenshots. Also thanks, as always, to Melanie Kloss for the great banner image.

Everything from the series ‘Better iOS Projects’: