Native Technologies

Setting up a modular iOS app for multiple environments with Tuist

Setting up the project

Follow the instruction in the official repository to install tuist on your machine https://github.com/tuist/tuist

Create a folder for your project and run tuist init --name MyExampleApp --template swiftui .Then run tuist edit . You could see the basic tuist project opened on Xcode now.

Congratulations, you got your fist app setting up with Tuist ! Now it’s time to customize it to fulfill the requirements.

Setting up the modules

In Manifests/Project.swift, we will create a project, add an app Target to it. This app will have some external dependencies (SwiftUIPager for instance) and also the Design system library as dependency.

++pre>++code class="language-swift">// Manifests/Project.swift

import ProjectDescription
import ProjectDescriptionHelpers

// Creates our project using a helper function defined in ProjectDescriptionHelpers
let project = Project(
   name: "ExampleProject",
   organizationName: "MyOrg",
   targets: [
       Target(
           name: "MyExampleApp",
           platform: .iOS,
           product: .app,
           bundleId: "$(PRODUCT_BUNDLE_IDENTIFIER)",
           deploymentTarget: .iOS(targetVersion: "15.0", devices: .iphone),
           infoPlist: InfoPlist.dictionary([
               "CFBundleName": "$(APP_NAME)",
               "CFBundleVersion": "1",
               "CFBundleShortVersionString": "0.0.1",
               "CFBundleIdentifier": "$(PRODUCT_BUNDLE_IDENTIFIER)",
               "CFBundleExecutable": "$(EXECUTABLE_NAME)",
               "CFBundlePackageType": "$(PRODUCT_BUNDLE_PACKAGE_TYPE)",
           ]),
           sources: ["Targets/MyExampleApp/Sources/**"],
           resources: ["Targets/MyExampleApp/Resources/**"],
           dependencies: [
               .project(
                   target: "MyDesignSystem",
                   path: .relativeToManifest("Projects/MyDesignSystem")
               ),
               .external(name: "Alamofire"),

           ]
       ),
       Target(
           name: "MyExampleAppUnitTests",
           platform: .iOS,
           product: .unitTests,
           bundleId: "com.myexampleapp.unittests",
           infoPlist: .default,
                   // To uncomment later on
           sources: ["Targets/MyExampleApp/Tests/**"],
           dependencies: [.target(name: "MyExampleApp")]
       ),
   ]
)
++/pre>++/code>

So here we are missing two things to complete this step :

  1. We need to tell Tuist where comes from Alamofire. To do so, we are going to edit the Tuist/Dependencies.swift file and specify where to pick Alamofire. In this example, I have shown how to use SPM, but you could also use another package manager like Carthage.

++pre>++code class="language-swift">// Tuist/Dependencies.swift
import ProjectDescription

let dependencies = Dependencies(
 /*carthage: [
       .github(path: "Alamofire/Alamofire", requirement: .exact("5.0.4")),
    ],*/
   swiftPackageManager: SwiftPackageManagerDependencies([
       .remote(url: "https://github.com/Alamofire/Alamofire.git", requirement: .exact("5.6.1")),
   ]),
   platforms: [.iOS]
)++/pre>++/code>

2. We need to create our Design System Library. It is going to also be a dedicated project, which will contain one target which is the design system library itself we want to build:

++pre>++code class="language-swift">// Manifest/Projects/MyDesignSystem/Project.swift

import ProjectDescription
import ProjectDescriptionHelpers

let project = Project(
   name: "DesignSystem",
   organizationName: "MyOrg",
   options: Project
       .Options
       .options(),
   targets: [Target(
       name: "MyDesignSystem",
       platform: .iOS,
       product: .framework,
       bundleId: "com.mydesignsystem",
       deploymentTarget: .iOS(targetVersion: "15.0", devices: [.iphone]),
       // To uncomment later on
       // sources: .paths(["Sources/**"]),
       // resources: ["Resources/**"],
       dependencies: []
   )]
)++/pre>++/code>

Once this is done, we need last one thing to set this up :

  1. in the terminal, run tuist fetch to fetch the Alamofire dependency (this needs to be done after every time you add an external dependency to your project
  2. Then run tuist generate to generate a project for the first time
  3. We just need to polish the way our folder are organized. Open the project with the finder, and under the Projects/MyDesignSytem folder, create two folders : Sources  and Resources .In the Sources one, still with the finder (or another IDE that you like), create a HelloWorld.swift file with the following content :

++pre>++code class="language-swift">// Projects/MyDesignSystem/Sources/HelloWorld.swift
public let foo = "hello world"
++/pre>++/code>

Finally, we should be done for the first step. You can run `tuist generate`  and it will generate automatically your app. In Xcode edit your SwiftUI file to use content from your design system :

++pre>++code class="language-swift">// ExampleProject/Project/Targets/MyExampleApp/Sources/MyExampleApp.swift in Xcode

import SwiftUI
import MyDesignSystem

@main
struct MyExampleAppApp: App {
   var body: some Scene {
       WindowGroup {
           Text(foo)
       }
   }
}++/pre>++/code>

If you run the MyExampleApp scheme, you should be able to build your application correctly 🎉

This is great, we have just created with a few line of codes, multiple targets, an app one using a design system one, and it compile properly.

However at this point, you should face a new issue while trying to run the app.

This is normal, it is because we haven’t set some necessary config values like the bundleIDs (per environment) of our app

Supporting different environments

Because we are working on a real-case scenario, most likely you will want to have different environment for your app : One for debug while developing, one for staging/preproduction for publishing your app on a test platform like AppCenter and finally a production one for publishing your app on the store. This is super important to take this into account as soon as possible and thanks to Tuist we can manage easily our different environments.

First, we are going to create different schemes per environment, then we are going to tell every target, which settings to use, and finally register the schemes globally

++pre>++code class="language-swift">// Manifest/Project.swift

let debugConfiguration: Configuration = .debug(
   name: "Debug",
       // Add your xcconfig here per environment which will for instance contain your different bundleIds, etc
   xcconfig: .relativeToRoot("Targets/MyExampleApp/Configurations/Dev/MyExampleApp.xcconfig")
)
let stagingConfiguration: Configuration = .release(
   name: "Staging",
   xcconfig: .relativeToRoot("Targets/MyExampleApp/Configurations/Staging/MyExampleApp.xcconfig")
)

let releaseConfiguration: Configuration = .release(
   name: "Release",
   xcconfig: .relativeToRoot("Targets/MyExampleApp/Configurations/Release/MyExampleApp.xcconfig")
)

// MARK: Create schemes
let debugScheme = Scheme(
   name: "MyExampleApp-Debug",
   shared: true,
   buildAction: .buildAction(targets: [TargetReference(stringLiteral: "MyExampleApp")]),
   testAction: .testPlans([], configuration: .configuration("Debug")),
   runAction: .runAction(configuration: .configuration("Debug")),
   archiveAction: .archiveAction(configuration: .configuration("Debug")),
   profileAction: .profileAction(configuration: .configuration("Debug")),
   analyzeAction: .analyzeAction(configuration: .configuration("Debug"))
)

let stagingScheme = Scheme(
   name: "MyExampleApp-Staging",
   shared: true,
   buildAction: BuildAction(targets: [TargetReference(stringLiteral: "MyExampleApp")]),
   testAction: .testPlans([], configuration: .configuration("Release")),
   runAction: .runAction(configuration: .configuration("Release")),
   archiveAction: .archiveAction(configuration: .configuration("Staging")),
   profileAction: .profileAction(configuration: .configuration("Staging")),
   analyzeAction: .analyzeAction(configuration: .configuration("Staging"))
)

let prodScheme = Scheme(
   name: "MyExampleApp-Release",
   shared: true,
   buildAction: BuildAction(targets: [TargetReference(stringLiteral: "MyExampleApp")]),
   testAction: .testPlans([], configuration: .configuration("Release")),
   runAction: .runAction(configuration: .configuration("Release")),
   archiveAction: .archiveAction(configuration: .configuration("Release")),
   profileAction: .profileAction(configuration: .configuration("Release")),
   analyzeAction: .analyzeAction(configuration: .configuration("Release"))
)

let settings: Settings =
   .settings(base: [:],
             configurations:
             [
                 debugConfiguration,
                 stagingConfiguration,
                 releaseConfiguration,
             ])

let project = Project(
   name: "ExampleProject",
   organizationName: "MyOrg",
   settings: settings,
   targets: [
       Target(
           name: "MyExampleApp",
           platform: .iOS,
           product: .app,
           bundleId: "$(PRODUCT_BUNDLE_IDENTIFIER)",
           deploymentTarget: .iOS(targetVersion: "15.0", devices: .iphone),
           infoPlist: InfoPlist.dictionary([
               "CFBundleName": "$(APP_NAME)",
               "CFBundleVersion": "1",
               "CFBundleShortVersionString": "0.0.1",
               "CFBundleIdentifier": "$(PRODUCT_BUNDLE_IDENTIFIER)",
               "CFBundleExecutable": "$(EXECUTABLE_NAME)",
               "CFBundlePackageType": "$(PRODUCT_BUNDLE_PACKAGE_TYPE)",
           ]),
           sources: ["Targets/MyExampleApp/Sources/**"],
           resources: ["Targets/MyExampleApp/Resources/**"],
           dependencies: [
               .project(
                   target: "MyDesignSystem",
                   path: .relativeToManifest("Projects/MyDesignSystem")
               ),
               .external(name: "Alamofire"),

           ],
           settings: .settings(base: [:], configurations: [
               debugConfiguration,
               stagingConfiguration,
               releaseConfiguration,
           // if you use fastlane, it is recommanded to add this extra line about the CODE_SIGN_IDENTITY
           ], defaultSettings: .recommended(excluding: ["CODE_SIGN_IDENTITY"]))
       ),
       Target(
           name: "MyExampleAppUnitTests",
           platform: .iOS,
           product: .unitTests,
           bundleId: "com.myexampleapp.unittests",
           infoPlist: .default,
                   // To uncomment later on
           sources: ["Targets/MyExampleApp/Tests/**"],
           dependencies: [.target(name: "MyExampleApp")]
       ),
   ],
   schemes: [
       debugScheme,
       stagingScheme,
       prodScheme,
   ]
)
++/pre>++/code>

++pre>++code class="language-swift">// Manifest/Projects/MyDesignSystem/Project.swift

import ProjectDescription
import ProjectDescriptionHelpers

let project = Project(
   name: "DesignSystem",
   organizationName: "MyOrg",
   options: Project
       .Options
       .options(),
   settings: Settings.settings(
       configurations: [
           .debug(name: "Debug"),
           .release(name: "Staging"),
           .release(name: "Release"),
       ]
   ),
   targets: [Target(
       name: "MyDesignSystem",
       platform: .iOS,
       product: .framework,
       bundleId: "com.mydesignsystem",
       deploymentTarget: .iOS(targetVersion: "15.0", devices: [.iphone]),
       sources: .paths(["Sources/**"]),
       // unlock this line once you want to add custom resources like colors, images, etc.
       // Don't forget to create the Resources folder to make it work!
       //   resources: ["Resources/**"],
       dependencies: []
   )]
)++/pre>++/code>

++pre>++code class="language-swift">// Tuist/Dependencies.swift

let dependencies = Dependencies(
 /*carthage: [
       .github(path: "Alamofire/Alamofire", requirement: .exact("5.0.4")),
    ],*/
   swiftPackageManager: SwiftPackageManagerDependencies([
       .remote(url: "https://github.com/Alamofire/Alamofire.git", requirement: .exact("5.6.1")),
// INSERT your settings here
   ], baseSettings: Settings.settings(
       configurations: [
           .debug(name: "Debug"),
           .release(name: "Staging"),
           .release(name: "Release"),
       ]
   )),
// END INSERTION
   platforms: [.iOS]
)++/pre>++/code>

The only remaining part missing is adding a different .xcconfig file at the path indicated in the configuration we have just set for supporting different settings per configuration. For instance for dev this can be something like this (change with your values)

++pre>++code class="language-swift">PRODUCT_BUNDLE_IDENTIFIER = com.myexample.dev
BUNDLE_ID = $(PRODUCT_BUNDLE_IDENTIFIER)
CODE_SIGN_IDENTITY=iPhone Developer
PROVISIONING_PROFILE_SPECIFIER = match Development com.myexample.dev
PROVISIONING_PROFILE = $(sigh_${BUNDLE_ID}_development)
DEVELOPMENT_TEAM = XXXXXXXX

// For Firebase Analytics
OTHER_LDFLAGS = $(inherited) -ObjC ++/pre>++/code>

Please note the way we are mapping configurations, although Debug and Release looks pretty straightforward, we are telling Xcode that our Staging configuration is a “release” like configuration because this is going to be shipped not for debugging purpose but for our end users in a distribution mode.

Now the last remaining thing, we can run our application! Run tuist generate, compile the application with the MyExampleApp-Scheme, and voilà!

Conclusion

Today, we have learnt how to set a project with tuist with custom internal libraries (Design system), some external dependencies, and different environments. You can find the github repository here as an example.

In this article, we are going to develop an app together from scratch with tuist. In particular, we are going to focus on a real case scenario where you need your code to be in different modules so that you create reusable libraries for instance. In particular, we are going to have an app which where we will have a Design System Library that we want to build as a reusable library and also some dependencies installed with SPM.

Développeur mobile ?

Rejoins nos équipes