Who is this article for? It is for individuals who are either starting a project without a project file or those who are already in progress but may be facing challenges with maintenance. It may also appeal to anyone with an interest in the topic
At some point, everyone encounters a situation where a project needs to be divided into modules, each with its own scope and associated elements. There are several methods for dividing and maintaining a project. However, I would like to introduce a helpful tool that has aided me in the past
For those who require an example in the form of code, I will provide a link to the repository that contains an example related to this article. The example can be found at https://github.com/Ze8c/TuistExample/tree/main
So, let's get started with Tuist, following the instructions provided in this guide:
curl -Ls https://install.tuist.io | bash
Once Tuist is installed, you can generate a project directly from the console
tuist init --platform ios
and make
tuist generate
After initiating the project generation process, the project will automatically open in XCode
You'll be able to see the basic project structure and hierarchy. Close XCode and reopen the project for editing
tuist edit
XCode will open with the settings of our project displayed
As you can observe, the settings are based on Swift and resemble the Swift Package Manager (SPM), which makes it easier to get started
While Tuist allows for more flexible customization of the project, it often leads to code duplication in most cases. To enhance development speed and maintainability, you can utilize the factory pattern and create a more structured approach. Additionally, using flexible Swift enums to build a factory can be aesthetically pleasing
Important! The project setup is divided into subscopes, which is crucial when creating new files within the project setup
Let's begin by creating a structure for the "ProjectDescriptionHelpers" scope, which will manage certain application settings
public struct AppConfig {
public let marketingVersion: String
public let buildVersion: InfoPlist.Value
public let organizationName: String
public let bunldePrefix: String
public let iosTargetVersion: String
public init(
marketingVersion: String,
buildVersion: InfoPlist.Value,
organizationName: String,
bunldePrefix: String,
iosTargetVersion: String
) {
self.marketingVersion = marketingVersion
self.buildVersion = buildVersion
self.organizationName = organizationName
self.bunldePrefix = bunldePrefix
self.iosTargetVersion = iosTargetVersion
}
}
We'll start by creating a small extension for strings
extension String {
var capitalizingFirstLetter: String {
return prefix(1).uppercased() + dropFirst()
}
}
Next, we'll create scopes within the application. These scopes are folders that logically group modules together. For example, we'll create "flow" and "core" scopes, which will contain components related to flow management and core elements such as our design system
public enum AppScope: String {
case root
case core
case flow
public var folder: String {
rawValue.capitalizingFirstLetter
}
}
If the project has external dependencies, we need to add an additional target called "Dependencies" to the project configuration
We'll also include a dependency configuration entity to make the process more convenient. This entity will assist us in adding the dependencies to the project
enum AppDependencie {
case target(AppTarget)
case external(ExternalDependencie)
}
extension AppDependencie {
var convert: TargetDependency {
switch self {
case let .target(item): return .target(name: item.name)
case let .external(item): return .external(name: item.name)
}
}
}
public enum ExternalDependencie: CaseIterable {
case kingfisher
}
extension ExternalDependencie {
public var package: Package {
switch self {
case .kingfisher:
return .remote(
url: "https://github.com/onevcat/Kingfisher.git",
requirement: .exact("7.6.2")
)
}
}
var name: String {
switch self {
case .kingfisher: return "Kingfisher"
}
}
}
Finally, we'll add the external dependencies to the project in scope “Dependencies”
let packageManager = SwiftPackageManagerDependencies(dependencies: ExternalDependencie.allCases)
let dependencies = Dependencies(
swiftPackageManager: packageManager,
platforms: [.iOS]
)
extension SwiftPackageManagerDependencies {
init(dependencies: [ExternalDependencie]) {
self.init(dependencies.map(\.package))
}
}
Once we have defined the scopes, we'll organize our targets and set up their dependencies. It's important to keep track of dependencies to avoid circular dependencies between modules
public enum AppTarget: String, CaseIterable {
case main
case tuistExampleKit
case tuistExampleUI
}
extension AppTarget {
public var id: String {
rawValue
}
public var name: String {
id.capitalizingFirstLetter
}
public var scope: AppScope {
switch self {
case .main:
return .root
case .tuistExampleKit:
return .core
case .tuistExampleUI:
return .flow
}
}
private var dependencies: [AppDependencie] {
switch self {
case .main:
return [
.target(.tuistExampleKit),
.target(.tuistExampleUI)
]
case .tuistExampleKit:
return []
case .tuistExampleUI:
return [
.external(.kingfisher),
.target(.tuistExampleKit)
]
}
}
}
extension AppTarget {
public var target: Target {
switch self {
case .main:
return mainTarget(AppSetup.appConfig)
case .tuistExampleKit, .tuistExampleUI:
return makeBaseTarget(AppSetup.appConfig)
}
}
private func mainTarget(_ config: AppConfig) -> Target {
Target(
name: "Main",
platform: .iOS,
product: .app,
bundleId: config.bunldePrefix + "superapp",
deploymentTarget: .iOS(targetVersion: config.iosTargetVersion, devices: .iphone),
infoPlist: .extendingDefault(with: [
"CFBundleShortVersionString": InfoPlist.Value(stringLiteral: config.marketingVersion),
"CFBundleVersion": config.buildVersion,
"UIMainStoryboardFile": "",
"UILaunchStoryboardName": "LaunchScreen",
"CFBundleIconName": "AppIcon",
"CFBundleDisplayName": "TestName",
"UISupportedInterfaceOrientations": ["UIInterfaceOrientationPortrait"],
"UISupportedInterfaceOrientations~ipad": ["UIInterfaceOrientationPortrait", "UIInterfaceOrientationPortraitUpsideDown"],
"UIUserInterfaceStyle": "Light",
]),
sources: ["Targets/Main/Sources/**"],
resources: ["Targets/Main/Resources/**"],
dependencies: dependencies.map(\.convert),
settings: Settings.settings(
base:
SettingsDictionary()
.bitcodeEnabled(false)
.marketingVersion(config.marketingVersion)
.merging(["ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon"])
,
debug: Configuration.debug(name: .debug).settings,
release: Configuration.release(name: .release).settings,
defaultSettings: DefaultSettings.essential(excluding: [])
)
)
}
private func makeBaseTarget(_ config: AppConfig) -> Target {
createFolderIfNeeded()
let pathSource = "Targets/\(scope.folder)/\(name)/Sources"
let pathResource = "Targets/\(scope.folder)/\(name)/Resources"
let doesResourceFolderExist = FileManager.default.fileExists(atPath: pathResource, isDirectory: nil)
return Target(
name: name,
platform: .iOS,
product: .framework,
bundleId: config.bunldePrefix + id,
deploymentTarget: .iOS(targetVersion: config.iosTargetVersion, devices: .iphone),
infoPlist: .extendingDefault(with: [:]),
sources: ["\(pathSource)/**"],
resources: doesResourceFolderExist ? ["\(pathResource)/**"] : [],
dependencies: dependencies.map(\.convert),
settings: Settings.settings(
base: SettingsDictionary().bitcodeEnabled(false),
debug: Configuration.debug(name: .debug).settings,
release: Configuration.release(name: .release).settings,
defaultSettings: DefaultSettings.essential(excluding: [])
)
)
}
private func createFolderIfNeeded() {
let sourcesPath = "Targets/\(scope.folder)/\(name)/Sources"
if !FileManager.default.fileExists(atPath: sourcesPath) {
do {
try FileManager.default.createDirectory(
atPath: sourcesPath,
withIntermediateDirectories: true,
attributes: nil
)
FileManager.default.createFile(atPath: "\(sourcesPath)/Placeholder.swift", contents: nil)
} catch {
print("Create dir error", error.localizedDescription)
}
}
}
}
With all the preparations complete, we'll make some slight adjustments to the property that handles the entire project
let project = Project(
name: "TuistExample",
organizationName: AppSetup.appConfig.organizationName,
targets: AppTarget.allCases.map(\.target),
resourceSynthesizers: [
.strings(),
.assets(),
]
)
That's all for now. You can close the project configuration, press ctrl + C in the console, and remember to download all external dependencies using the appropriate command before generating the project
tuist fetch
Regenerate the project
tuist generate
Once the project generation is complete, you can enjoy the results.