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

  • Resources and access keys to them – FigmaGen and Swiftgen

  • 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.

Yml config tree
Yml config tree

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.

Result of project initialization, open for editing.  In the comments - a cheat sheet for starting the division into modules
Result of project initialization, open for editing. In the comments – a cheat sheet for starting the division into modules

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.

The problem of duplicates in recent projects
The problem of duplicates in recent 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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *