Technologies Natives

Adopting Tuist on an Existing Project

In the previous articles, you saw the theory of Tuist and its adoption on a new project. At BAM, we also converted older projects. This part focuses on this process.

A bit of context

The project I will take as an example for this article is a project with multiple “modules”. “Core” modules contain code that can be used everywhere in the application: some standard components, helper functions to have easy access to the keychain, extension of dependencies, etc. “Libraries” modules hold business logic; a module corresponds to a certain part of the business. Finally, each “feature” module contains a navigation flow of the application. On top of that, we have an “app” module that links everything.

These modules are Xcode projects that generate frameworks, except for the “app” one that generates the application. All of these projects are contained in an Xcode workspace.

Each project is a bit special :

  • Some are unit-tested
  • Some have resources (translations or images for instance)
  • Some have xcconfig files (we use them to have a different configuration depending on the dev / staging / production environment)
  • Some have specific options (one of them has a linker flag for the Firebase library)

There are dependencies between these projects: in this case, they are added as “Non-embedded” frameworks.

Simplified example of dependencies between modules

The project uses Carthage as its dependencies manager. The external dependencies are already pre-compiled and available in the Carthage/Build folder, and it avoids the issue of the non-support of Cocoapods by Tuist.

Step 1: conversion of the project

The first step is to convert each project to Tuist: we should be able to generate them with the command tuist generate.

Since the syntax to generate a project is a bit verbose, we first created a template that fits our needs.

++pre>++code class="language-swift">
import ProjectDescription

extension Project {
public static func frameworkProject(name: String, hasResources: Bool = false, hasTests: Bool = false, configKeys: [String] = [], dependencies: [TargetDependency] = [], otherSettings: [String: SettingValue] = [:]) -> Project {
                let hasConfig = configKeys != []

let devConfiguration: Configuration = .debug(name: "Development", xcconfig: hasConfig ? "Configuration/Development.xcconfig" : nil)
let stagingConfiguration: Configuration = .release(name: "Staging", xcconfig: hasConfig ? "Configuration/Staging.xcconfig" : nil)
let productionConfiguration: Configuration = .release(name: "Production", xcconfig: hasConfig ? "Configuration/Production.xcconfig" : nil)

let targetBaseSettings: [String: SettingValue] = [
"ENABLE_BITCODE": "NO" // We disable bit since it prevents us to archive the build since Xcode 13.4,
"FRAMEWORK_SEARCH_PATHS": .array([ // Will be remove when handling dependencies with Tuist
"$(PROJECT_DIR)/../../../Carthage/Build",
"$(PROJECT_DIR)/../../../Carthage/Build/iOS"
])
]

let targetSettings = targetBaseSettings.merging(otherSettings) { _, new in new }

var configDict: [String: InfoPlist.Value] = [:]
configKeys.forEach { key in
let plistKey = key.capitalized.replacingOccurrences(of: "_", with: "")
configDict[plistKey] = "$(\\(key))"
}

return Project(
name: name,
settings: .settings(configurations: [devConfiguration, stagingConfiguration, productionConfiguration]),
targets: [
Target(
name: name,
platform: .iOS,
product: .framework,
bundleId: "tech.bam.\\(name)",
deploymentTarget: .iOS(targetVersion: "13.0", devices: .iphone), infoPlist: .extendingDefault(with: configDict),
sources: ["Sources/**"],
resources: hasResources ? ["Resources/**"] : nil,
dependencies: dependencies,
settings: .settings(base: targetSettings, configurations: [devConfiguration, stagingConfiguration, productionConfiguration], defaultSettings: .recommended(excluding: Set()))
),
hasTests ? Target(
name: "\\(name)Tests",
platform: .iOS,
product: .unitTests,
bundleId: "tech.bam.\\(name)Tests",
deploymentTarget: .iOS(targetVersion: "13.0", devices: .iphone), infoPlist: .default,
sources: ["Tests/**"],
dependencies: [.project(target: name, path: ".")]
) : nil
].compactMap { $0 }
)
}
}
++/pre>++/code>

This content should be put in a file Tuist/ProjectDescriptionHelper/Project+Template.swift near every existing Xcode project. At the end, the duplicates will be deleted, but this allows us to do the conversion project by project.

Our config is very close to the default suggested by Tuist, so we don’t have that much change to do.

As you can see, all use cases described in the previous section are handled as parameters of this function. And it helps us to reduce the content of the Project.swift file. For example, here is the project containing the code to do API calls.

++pre>++code class="language-swift">
let project = Project(
   name: "MyApp",
   settings: .settings(configurations: [devConfiguration, stagingConfiguration, productionConfiguration]),
   targets: [
       appTarget,
       serviceExtensionTarget
   ]
)
++/pre>++/code>

As you can see, external dependencies are for now manually linked as external xcframework at their old installation path. This will change when dependencies will be handled by Tuist.

Our process was to convert project by project, cleaning the Xcode cache and rebuilding the app every time. This way, we were quickly able to know if there was an error during our conversion process. We could also stop at any time and merge the current work if needed since nothing was broken.

Step 2: main project

Next, we attacked the main project, the one building the app. This one has a config too different to use the pattern presented in the previous section.

First of all, it has a notification service extension. This target needs to be declared in the Project.swift for this module. This target has a specific configuration. To be sure not to forget anything, we extracted the configuration in a readable way. Tuist has a command for this:tuist migration settings-to-xcconfig -p Project.xcodeproj -t ServiceExtension -x ServiceExtension.xcconfig

We read this xcconfig file to add the following configuration and create the target.

++pre>++code class="language-swift">

let extensionBaseSettings: [String: SettingValue] = [
   "CODE_SIGN_STYLE": "Manual",
   "ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES": "YES",
   "SKIP_INSTALL": "YES",
   "COPY_PHASE_STRIP": "NO",
   "DEVELOPMENT_TEAM": "SOME_ID",
   "VERSIONING_SYSTEM": "apple-generic"
]

let serviceExtensionTarget = Target(
   name: "ServiceExtension",
   platform: .iOS,
   product: .appExtension,
   bundleId: "tech.bam.$(ENV_SHORT).ServiceExtension",
   deploymentTarget: .iOS(targetVersion: "13.0", devices: .iphone),
   infoPlist: "ServiceExtension.plist",
   sources: "ServiceExtension/**",
   dependencies: [.external(name: "AirshipNotificationServiceExtension")],
   settings: .settings(
       base: extensionBaseSettings,
       configurations: [devExtensionConfiguration, stagingExtensionConfiguration, productionExtensionConfiguration]
   )
)

++/pre>++/code>⚠️ A little warning, we had to change the plist for this extension target and add the CFBundleExecutable and CFBundleName keys. Otherwise, the app could not be installed on the simulator.

Then, it was time to attack the main target: the application. We used the same Tuist migration  command to extract the configuration.

The app target is very similar to the extension service. Just don’t forget to add the scripts you created for your app target and the dependencies, like this :

++pre>++code class="language-swift">let baseCarthagePath = "$(PROJECT_DIR)/../../../../Carthage/Build"

// […]

dependencies: [.xcframework(path: "\\(baseCarthagePath)/Moya.xcframework"), /* … */]++/pre>++/code>

With these two targets, the project itself is quite short:

++pre>++code class="language-swift">let project = Project(
   name: "MyApp",
   settings: .settings(configurations: [devConfiguration, stagingConfiguration, productionConfiguration]),
   targets: [
       appTarget,
       serviceExtensionTarget
   ]
)++/pre>++/code>

Finally, there is just the workspace file to create at the root of the project

++pre>++code class="language-swift">import ProjectDescription

let devConfiguration = ConfigurationName(stringLiteral: "Development")
let stagingConfiguration = ConfigurationName(stringLiteral: "Staging")
let productionConfiguration = ConfigurationName(stringLiteral: "Production")

let testTargets = [
   TargetReference(projectPath: "src/features/FeatureA", target: "FeatureATests"),
   TargetReference(projectPath: "src/libraries/LibA", target: "LibATests"),
   TargetReference(projectPath: "src/libraries/LibB", target: "LibBTests"),
   TargetReference(projectPath: "src/core/CoreUtils", target: "CoreUtilsTests")
]
let appTarget = TargetReference(projectPath: "src/app/app", target: "App")

let workspace = Workspace(
   name: "App",
   projects: ["src/app/app/**", "src/features/**", "src/libraries/**", "src/core/**"],
   schemes: [
       Scheme(
           name: "App - Development",
           shared: true,
           hidden: false,
           buildAction: .buildAction(targets: [app]),
           testAction: .targets(
               testTargets.map { TestableTarget(target: $0) },
               arguments: nil,
               configuration: devConfiguration,
               attachDebugger: true,
               // This is our test settings, to be sure to test with the right Location everytime
               options: .options(language: SchemeLanguage(stringLiteral: "en-gb"), region: "United Kingdom", coverage: true, codeCoverageTargets: testTargets),
               diagnosticsOptions: [.mainThreadChecker]
           ),
           runAction: .runAction(
               configuration: devConfiguration,
               attachDebugger: true,
               executable: appTarget,
               // To debug Firebase
               arguments: Arguments(environment: [:], launchArguments: [
                   LaunchArgument(name: "-FIRDebugEnabled", isEnabled: false),
                   LaunchArgument(name: "-FIRDebugDisabled", isEnabled: false)
               ]),
               options: .options(),
               diagnosticsOptions: [.mainThreadChecker]
           ),
           archiveAction: .archiveAction(configuration: devConfiguration)
       ),
       Scheme(
           name: "App - Staging",
           shared: true,
           hidden: false,
           buildAction: .buildAction(targets: [appTarget]),
           archiveAction: .archiveAction(configuration: stagingConfiguration)
       ),
       Scheme(
           name: "App - Production",
           shared: true,
           hidden: false,
           buildAction: .buildAction(targets: [appTarget]),
           archiveAction: .archiveAction(configuration: productionConfiguration)
       )
   ],
   generationOptions: .options(enableAutomaticXcodeSchemes: false, autogeneratedWorkspaceSchemes: .disabled)
)++/pre>++/code>

You can now delete all the project helper files in the project folders and put it only of the root of the workspace. When you do a tuist edit at this level, you should see the workspace, all the project files in their folder, and the project helper.

And if you run tuist generate, the workspace should generate as it was before. You should be able to build, test and archive.

Step 3: time to do some cleaning

If everything is working fine, you can delete the project files and git ignore them: everything will be generated by Tuist. When doing so, don’t forget to add the command tuist generate -n to your CI / CD before trying to build everything.

⚠️ If you have an error, check that you named correctly your schemes and target. If something changed, the command lines break.

Step 4: Add the dependencies

Now it is time to remove the xcframework path you had to put everywhere. As explained before, Tuist support both Carthage and SPM. On our project, it allows us to do a smooth transition between these two systems, waiting for the SPM support by the dependencies.

First, we created the Dependencies.swift file at the root of the project and fill it with our dependencies, all in the Carthage section. Then, we ran the tuist fetch command (and waited a bit since Carthage can be a bit long). When it is done, we replaced all the .xcframwork references with the path by external with just the name of the dependency.

You will notice that Tuist handle automatically the status of the framework (embed and sign or do not embed): it differentiates static and dynamic framework, no more need to read precisely the documentation of the libraries to find this information.

After one successful build, we started moving dependencies from the Carthage array to the SPM array. One thing to note is that the dependencies can not exist in both systems at the same time. You may also delete the files in the Carthage folder.But you can’t mix the two systems any way you want. If lib B depends on lib A, they both need to be handled by the same system.

Conclusion

Following these steps, we had a smooth transition between automatic xcodeproject files and Tuist. We encountered only a few errors that you can see next to the ⚠️ icon in this article. It reduced drastically the number of git conflicts we have had since. And the onboarding cost for the new devs is not as big as expected.

Rejoins nos équipes