compare XcodeGen and Tuist
When you think about dividing into modules, a lot of questions arise: how to distribute responsibility between modules? What will be the result of splitting into modules? How do I support a multi-module application?
It’s easy to get lost along the way: modularization is tricky. Like any other complex and routine process, you want to automate it. Project generation utilities help provide a convenient and flexible modularization process.
I AM – Nikita Korobeinikov, iOS Team Lead in Surf… Did some experiments on splitting applications into modules using XcodeGen and Tuist… In this article, I will talk about this experience, and also share our understanding of the ideal graph of modules.
Surf’s technical solutions in iOS that we use in our day-to-day work are in our repository… Collected libraries, utilities, tools and best practices. They help us to simplify the initialization of projects, design the architecture, write clean code. They can help you too 🙂
What is a module
Module – a functionally complete piece of code that performs a specific task: for example, access to the service layer, implementation of a feature or use case.
Here you can draw an analogy with one of the principles of OOP – the principle of single responsibility: a good module, like a good class, performs one specific task.
Module Models may contain descriptions of business logic models.
Resources – resources and access keys to them.
Network – implementation of the network layer.
Library – reusable UI components or screens (the name Library came to us from a combat project, but it would be more correct to call this module UI Kit).
AuthFlow will describe the authorization flow using the network layer and reusable components. Here flow is any custom scenario.
Application collects all flows into an application.
In this example, the module can use other modules, but connection between them is possible only vertical – So the project architect decided. Network cannot access resources or components from the adjacent Library module. AuthFlow can’t turn to MonitorFlow etc.
This recommendation can be applied to other modularization models as well: vertical linking allows for the principle of single responsibility for module assignment. This gives clean code and project structure.
Why beat on modules
Speeding up incremental builds… The build system will cache the products of modules (in our case, these are frameworks) and rebuild only those modules in which changes are made. For example, one project in monolithic architecture took 40 seconds to complete. After being divided into 20 modules, it began to assemble in 5 seconds: acceleration by 8 times.
A multi-module application is easier to navigate… A well-broken project reflects the purpose of the modules in the folder and file structure. It is immediately clear from which “building blocks” the project is being assembled and where changes need to be made.
Flexibility and isolation: modules can be replaced in whole or in part. A large group of developers can work on a project without conflicts. The main thing is that all team members understand how the division into modules was carried out. Templates and code generation can be used to unify this understanding.
Why are utilities needed
To do modularization on bare Xcode, you need:
Understand the difference between static, dynamic frameworks and other types of targets.
Be able to customize the configuration of projects from scratch.
Understand linking features.
Be able to debug modules.
In other words, everything is complicated. And this is the only disadvantage of splitting into modules. To neutralize it, you can use the utilities for generating projects. They allow you to move module configurations into separate files with settings. It’s much easier to figure them out than where it is in an Xcode project.
The developer can limit the scope of work when Network, Library and other low-level modules are generated by utilities.
At Surf we strive to initialize new projects using the following utilities:
The network layer and models will help generate SurfGen…
Some of the reusable UI components can be copied from templates.
To create a screen inside flow modules, we will get the basis from templates Generamba…
The diagram of application modules with generation sources depicts an ideal situation: the developer works only in Flow modules and configures Application…
In practice, several more modules that are unique for each project will be added to these modules. Let’s call them “communication modules”: they will contain common protocols and extensions that are used in higher-level modules and connect them with each other.
Utils and many Features modules are examples of communication modules.
Utils appear for communication between business modules and UI modules. For example, DateFormatter can be used in Models for parsing dates and in Library – to format the same dates into a readable form.
Features (feature modules) are unique. For example, on one project, we moved to them the mechanism for updating the notification icon in Navigation Bar… The feature was to send a request and update UIBarButtonItem the correct picture. It could not be defined in the network layer and the layer with UI components. This module was used on ten screens from different flows.
Output: there are many modules, and it is difficult to manage them without utilities. It is especially difficult to maintain a multi-module project on bare Xcode. A hotbed of conflict is xcworkspace with many subprojects or a chubby project with many targets. Creating each target manually increases the likelihood of error…
Basic process for creating a module:
choose the type of target,
set project-specific compilation flags,
connect external dependencies,
make sure that all module files are added to the new target, and so on.
It is much easier to create new projects or targets for modules using utilities for managing modules. Let’s take a look at what capabilities XCodeGen and Tuist offer.
XcodeGen
Peculiarities XcodeGen:
Allows you to generate projects or targets from yml configs. As a consequence, all xcworkspace and pbxproj files after moving to XcodeGen can be sent to gitignore. This to some extent protects us from conflicts – but if all targets are described in one monolithic yml, there will still be conflicts.
Supports any dependency manager. Pods are described and linked in the usual way through the Podfile. For carthage dependencies and packages, you will need to additionally specify the reference in yml.
A large application will have many modules. To prevent yml from becoming a moss-covered monolith of several thousand lines, templates exist in XcodeGen.
ServicesFramework:
platform: iOS
deploymentTarget: 12.0
type: framework
sources:
- TestProject/Services/${source_folder}
info:
path: TestProject/Services/${source_folder}/Info.plist
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: ru.surfstudio.testproject.service.${target_name}
configs:
Debug:
EXCLUDED_ARCHS[sdk=iphonesimulator*]: "arm64"
The target template is given a name and then begins base description target. Notice the lines with curly braces: source_folder and target_name… it template attributes: they can be set using a template.
Attribute target_name predefined. In this case, it takes the value of the name of the target using the template (here – NetworkServices). Attribute source_folder, which defines a subfolder with target sources, is set as follows.
# Шаблоны
include:
- path: ../templates.yml
relativePaths: false
# Название проекта
name: NetworkServices
targets:
NetworkServices:
templates:
- ServicesFramework
templateAttributes:
source_folder: Network
dependencies:
- target: Models
embed: false
- target: SecurityServices
embed: false
Which template to use is indicated through the keyword templates… One target can use multiple templates… If the template is located in a separate yml config, you need to specify the path to it using include…
Any properties from the template can be added or overridden in the final target. For example, in a template for an application target, you can describe all internal dependencies, basic settings, configured through attributes.
App:
type: application
platform: iOS
deploymentTarget: 12.0
dependencies:
- target: Utils
- target: Resources
- target: Models
- target: SecurityServices
- target: NetworkServices
- target: AnalyticsServices
- target: PushServices
- target: NotificationsFeature
- target: Library
- target: AuthFlow
- target: NotificationsFlow
- target: MonitoringFlow
- target: ProfileFlow
attributes:
SystemCapabilities:
com.apple.Push:
enabled: 1
info:
path: TestProject/Application/Info/Info.plist
properties:
CFBundleName: ${bundle_name}
CFBundleDisplayName: ${bundle_display_name}
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
CFBundleDevelopmentRegion: ru
UILaunchStoryboardName: Launch Screen
UIUserInterfaceStyle: Light
UIBackgroundModes: [remote-notification]
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
NSAppTransportSecurity:
NSAllowsArbitraryLoads: true
NSExceptionDomains:
ssmpprod40.regeora.ru:
NSExceptionAllowsInsecureHTTPLoads: true
NSIncludesSubdomains: true
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.testproject.${bundle_suffix}
ASSETCATALOG_COMPILER_APPICON_NAME: ${icon_name}
TARGETED_DEVICE_FAMILY: 1
configs:
Debug:
EXCLUDED_ARCHS[sdk=iphonesimulator*]: "arm64"
And in the target config, add them.
TestProject:
templates:
- App
templateAttributes:
bundle_name: TestProjectDebug
bundle_display_name: Мой проект
bundle_suffix: kissmp.debug
icon_name: AppIcon-Debug
scheme:
configVariants: all
testTargets:
- UnitTests
sources:
- path: TestProject/Application
excludes:
- "**/.gitkeep"
- "Info/*/*"
- path: TestProject/Application/Info/GoogleService-Info.plist
buildPhase: resources
dependencies:
- target: DebugScreen
info:
path: TestProject/Application/Info/Info.plist
settings:
base:
MARKETING_VERSION: "0.1.6"
CURRENT_PROJECT_VERSION: 78
DEVELOPMENT_TEAM: EFAAG9GXN4
CODE_SIGN_ENTITLEMENTS: TestProject/TestProject.entitlements
CODE_SIGN_IDENTITY: "iPhone Developer"
CODE_SIGN_STYLE: Manual
PROVISIONING_PROFILE_SPECIFIER: "com.testproject.kissmp.debug-Development"
preBuildScripts:
- script: ${PODS_ROOT}/SwiftLint/swiftlint
This example adds the module to the internal dependencies DebugScreen… All basic settings are merged. The priority is always with the target config, and not with the template…
We get the yml configs tree, in which:
It’s easier to figure it out than in the target tree or Xcode projects.
We are saving ourselves from conflicts.
Cons of modularizing with XcodeGen:
You need to be a middle class in terms of competence to create new modules in a finished project.
You need to understand the YAML markup language: configs are written in it.
XcodeGen will not always give a clear description of the error.
Editing takes place in a text editor, not in the IDE.
Tuist
Possibilities Tuist sound promising:
Projects are described in Swift, edited in Xcode.
It is possible to write executable scripts that partially replace tulling on a vinaigrette from fastlane, ruby and shell.
It itself downloads dependencies from SPM or Carthage.
At the heart of Tuist’s project generation is the Swift Package Manager.
Every time we start editing a project and run the command tuist edit
, by default a new workspace will be created. This will gradually litter recents Xcode projects.
To avoid this, you should call this command with the flag -P
…
The possibilities of templating projects or targets for splitting into modules in Tuist are limited by the capabilities of the Swift language.
Constants can be defined.
import ProjectDescription
public enum Constants {
public static let organization = "mycompany"
public static let j2objcHome = "J2OBJC_2_5_HOME"
}
Write extensions to entities.
The main thing is to follow the rule: all extensions must be collected in a folder tuist / ProjectDescriptionHelpers… Then the generator will combine them into a module of the same name.
import ProjectDescription
public extension TargetScript {
/// Running SwiftGen to generate resource accessors
/// - Parameters:
/// - binaryPath: path to folder `SwiftGen/bin/swiftgen` relative to `$(SRCROOT)`
/// - Example: `..`
/// - Warning: SwiftGen.yml should placed in root of target with Resources
static func swiftgen(binaryPath: String) -> TargetScript {
let name = "Generate resource files"
let script: String = "${SRCROOT}/(binaryPath)/SwiftGen/bin/swiftgen"
return .pre(script: script,
name: name)
}
}
Tuist even lets you define Xcode’s text settings and is guaranteed to capture the indentation and line wrapping rules.
import ProjectDescription
public extension ProjectOption {
static let `default`: ProjectOption = .textSettings(usesTabs: true,
indentWidth: 4,
tabWidth: 4,
wrapsLines: true)
}
The external dependencies of all modules must be defined in one file. And here SPM and carthage dependencies coexist.
import ProjectDescription
let dependencies = Dependencies(
carthage: [
.github(path: "tonyarnold/Differ",
requirement: .exact("1.4.5")),
.github(path: "airbnb/lottie-ios",
requirement: .exact("3.2.3"))
],
swiftPackageManager: .init([
.remote(url: "https://github.com/surfstudio/ReactiveDataDisplayManager",
requirement: .exact("8.0.5-beta")),
.remote(url: "https://github.com/kishikawakatsumi/KeychainAccess.git",
requirement: .exact("4.2.2")),
.remote(url: "https://github.com/ReactiveX/RxSwift.git",
requirement: .exact("6.2.0")),
.remote(url: "https://github.com/CombineCommunity/RxCombine.git",
requirement: .exact("2.0.1")),
.remote(url: "https://github.com/ra1028/DifferenceKit.git",
requirement: .exact("1.2.0")),
.remote(url: "https://github.com/pointfreeco/swift-snapshot-testing.git",
requirement: .exact("1.8.2"))
], deploymentTargets: [.iOS(targetVersion: "9.0", devices: [.iphone])]),
platforms: [.iOS]
)
There is a separate command for downloading dependenciestuist dependencies fetch
… Linking occurs at the time of project generation.
It is worth noting that Cocoapods dependencies are not supported. These utilities conflict in the generation product. Both Cocoapods and Tuist generate xcworkspace.
This is how the module configuration might look like. Project.swift file.
All project settings can be changed. Unlike XcodeGen, there are hints from the IDE, with the help of which it is easier to figure out in which section of the config to set this or that setting.
import ProjectDescription
import ProjectDescriptionHelpers
let project = Project(
name: "legacy",
options: [.default],
settings: .settings(base: [
"HEADER_SEARCH_PATHS": ["../model/j2objc_dist/frameworks/JRE.framework/Headers"]
]),
targets: [
Target(name: "Legacy",
platform: .iOS,
product: .staticFramework,
bundleId: "com.mycompany.mobile.stocks.legacy",
deploymentTarget: .default,
infoPlist: "legacy/Info.plist",
sources: ["legacy/**"],
headers: .init(
public: [
"legacy/Chaos/**",
"legacy/UI/Screens/OrderEntry/OEModel/**",
"legacy/UI/Screens/OrderEntry/DXOrderEditorListener.h",
"legacy/legacy.h"
],
private: nil,
project: ["legacy/UI/Screens/OrderEntry/Listener/**"]),
dependencies: [
.project(target: "SharedModel", path: "../model"),
])
]
)
To link projects, set up diagrams and activity configurations in a multi-module project, you need a Workspace.swift configuration.
import ProjectDescription
let workspace = Workspace(
name: "Stocks",
projects: [
"legacy",
"model",
"stocks",
"stocks-ui-kit"
],
schemes: [
Scheme(name: "Stocks",
shared: true,
buildAction: .buildAction(targets: [
.project(path: "legacy", target: "legacy"),
.project(path: "model", target: "SharedModel"),
.project(path: "stocks-ui-kit", target: "StocksUI"),
.project(path: "stocks", target: "Stocks")
]),
testAction: .targets([
TestableTarget(target: .project(path: "stocks", target: "UnitTests"))
]),
runAction: .runAction(configuration: "Debug",
executable: TargetReference(projectPath: "stocks",
target: "Stocks")),
archiveAction: .archiveAction(configuration: "Debug")),
Scheme(name: "StocksUI",
shared: true,
buildAction: .buildAction(targets: [
.project(path: "stocks-ui-kit", target: "StocksUI"),
.project(path: "stocks-ui-kit", target: "StocksUI-App")
]),
testAction: .targets([
TestableTarget(target: .project(path: "stocks-ui-kit", target: "SnapshotTests"))
]),
runAction: .runAction(configuration: "Debug",
executable: TargetReference(projectPath: "stocks-ui-kit",
target: "StocksUI-App")),
archiveAction: .archiveAction(configuration: "Debug"))
]
)
In this example, the module with reusable components, which has its own snapshot tests and Example-app, is highlighted in a separate scheme.
Pros of Tuist for generating multi-module projects:
Minimizes conflicts.
The configuration couldn’t be easier. If you know Swift, you can handle it.
Extensive opportunities and plans to replace the bodies of fastlane, ruby-gems and others.
The developers of the utility paint bright prospects.
Minuses:
The project is young, there are many bugs. Some of them are because the developers wanted to make life easier. Simplified life for some – made it difficult for others…
Complex project migration. For example, Tuist has a built-in resource key generator like Swiftgen… In practice, it turned out that some of the stencil templates that worked on SwiftGen do not work with the resource generator inside Tuist.
While I would not recommend using Tuist as a standard, you can experiment with it on new – unloaded or small – projects.
Modules speed up assemblies and development by more than 100%. In conjunction with utilities for code generation, we get a graph of modules, most of which are ready to use.
We found out that XcodeGen is stable and Tuist is damp but has huge potential. It’s up to you which utility to choose and whether your project needs it: small applications from a couple of screens do not need multi-modularity.