vijaytholpadi / swiftable-tuist-workshop

This repository contains the content for the Shiftable 2023 workshop about Tuist

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Swiftable Tuist Workshop

In this workshop, we will explore Tuist by creating a project and experimenting with various features.

The workshop is structured around a series of topics that are presented and should be followed sequentially. If, for any reason, you find yourself stuck in one of the topics, you will discover a commit SHA at the end of the topic that you can use to continue with the upcoming topics.

Assert the successful completion of a topic

To assert the successful completion of a topic, you can run the following command passing the topic number that you just completed

# Confirming the completion of step 1
bash <(curl -sSL https://raw.githubusercontent.com/tuist/swiftable-tuist-workshop/main/assert.sh) 1

Requirements

  • Xcode 15
  • Tuist >= 3.33.0

Topics

  1. What is Tuist?
  2. Project creation - Commit: 87bce844f467b428eac8110771cd4ae5a9698392
  3. Project edition - Commit: 424042c0c0d588bb60e1ee174ce6770386bd123b
  4. Project generation - Commit: 542ccb00cc310c707dca25bf2fff1641fb49a878
  5. Multi-target project - Commit: 08ceff08fcd827ce99ff9b8a30bdf6d366270227
  6. Multi-project workspace - Commit: 952a05d4d65492a4efc2be9f1e2cb742ada2ab21
  7. Sharing code across projects - Commit: 732f5d19a881c977fe857c7842743e5983e14551
  8. XcodeProj-native integration of Packages - Commit: 80d5958729a31ffa76193387e928743bdc5fd989
  9. Focused projects
  10. Focused and binary-optimized projects
  11. Bonus 1. Synthesized resource interfaces
  12. Bonus 2. Templates

1. What is Tuist?

Tuist is a command-line tool that leverages Xcode Project generation to help teams overcome the challenges of scaling up development. Examples of challenges are:

  • Git conflicts in Xcode projects.
  • Inconsistencies across targets and projects.
  • Unmaintainable target graph that creates strong dependencies with a platform team.
  • Inefficient Xcode and clean builds.
  • Suboptimal CI runs that lead to slow feedback loops.

How does it work?

You describe your projects and workspaces in Swift files (manifests) using a Swift-based DSL. We drew a lot of inspiration from the Swift Package Manager. Unlike the Swift Package Manager, which is very focused on package management, the APIs and models that you'll find in Tuist's DSL resemble Xcode projects and workspaces.

The following is an example of a typical Tuist project's structure:

Tuist/
    Config.swift
Project.swift

Install Tuist

You can install Tuist by running the following command:

curl -Ls https://install.tuist.io | bash

2. Project creation

Tuist provides a command for creating projects, tuist init, but we are going to create the project manually to familiarize ourselves more deeply with the workflows and building blocks.

First of all, let's create a directory and call it Swiftable. Create it in this repository's directory:

mkdir -p Swiftable
cd Swiftable

Then we are going to create the following directories and files:

touch Project.swift
mkdir Tuist
echo 'import ProjectDescription
let config = Config()' > Tuist/Config.swift

Before continuing ⚠️

bash <(curl -sSL https://raw.githubusercontent.com/tuist/swiftable-tuist-workshop/main/test.sh) 2

If you get stuck, clone this repo and run git checkout 2.

3. Project edition

Tuist provides a tuist edit command that generates an Xcode project on the fly to edit the manifests. The lifecycle of the project is tied to the lifecycle of the tuist edit command. In other words, when the edit command finishes, the project is deleted.

Let's edit the project:

tuist edit

Then add the following content to the Project.swift:

import ProjectDescription

let project = Project(name: "Swiftable", targets: [
    Target(name: "Swiftable", platform: .iOS, product: .app, bundleId: "com.swiftable.App", sources: [
        "Sources/Swiftable/**/*.swift"
    ])
])

We are defining a project that contains an iOS app target that gets the sources from Sources/Swiftable/**/*.swift. Then we need the app and the home view that the app will present when we launch it. For that, let's create the following files:

Sources/Swiftable/ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}
Sources/Swiftable/SwiftableApp.swift
import SwiftUI

@main
struct SwiftableApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Before continuing ⚠️

bash <(curl -sSL https://raw.githubusercontent.com/tuist/swiftable-tuist-workshop/main/test.sh) 3

If you get stuck, clone this repo and run git checkout 3.

4. Project generation

Once we have the project defined, we can generate it with tuist generate. The command generates an Xcode project and workspace and opens it automatically. If you don't want to open it by default, you can pass the --no-open flag:

tuist generate

Try to run the app in the generated project.

Note that Tuist generated also a Derived/ directory containing additional files. In some scenarios, for example, when you define the content of the Info.plist in code or use other features of Tuist, it's necessary to create files that the generated Xcode projects and workspaces can reference. Those are automatically generated under the Derived/ directory relative to the directory containing the Project.swift:

The next thing that we are going to do is including the Xcode artifacts and the Derived directory in the .gitignore:

*.xcodeproj
*.xcworkspace
Derived/
.DS_Store

Thanks to the above change, the chances of Git conflicts are minimized considerably.

Before continuing ⚠️

bash <(curl -sSL https://raw.githubusercontent.com/tuist/swiftable-tuist-workshop/main/test.sh) 4

If you get stuck, clone this repo and run git checkout 4.

5. Multi-target project

At some point in the lifetime of a project, it becomes necessary to modularize a project into multiple targets. For example to share source code across multiple targets.

Tuist supports that by abstracting away all the complexities that are associated with linking, regardless of the complexity of your graph.

To see it in practice, we are going to create a new target called SwiftableKit that contains the logic for the app. Then we are going to link the Swiftable target with the SwiftableKit target.

First, let's edit the Project.swift file:

tuist edit

And add the new target to the list:

import ProjectDescription

let project = Project(name: "Swiftable", targets: [
    Target(name: "Swiftable",
           platform: .iOS,
           product: .app,
           bundleId: "com.swiftable.App",
           sources: [
            "Sources/Swiftable/**/*.swift"
           ],
+           dependencies: [
+            .target(name: "SwiftableKit")
+           ]),
+    Target(name: "SwiftableKit",
+           platform: .iOS,
+           product: .framework,
+           bundleId: "com.swiftable.Kit",
+           sources: [
+            "Sources/SwiftableKit/**/*.swift"
+           ])
])

We can then create the following source file:

Sources/SwiftableKit/SwiftableKit.swift
import Foundation

public class SwiftableKit {
    public init() {}
    public func boludo() {}
}

And generate the project with tuist generate. Then import the framework from Swiftable and instantiate the above class to make sure the linking works successfully:

import SwiftUI
+import SwiftableKit

@main
struct SwiftableApp: App {
+    let kit = SwiftableKit()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Run the app and confirm that everything works as expected. Note how Tuist added a build phase to the Swiftable to embed the dynamic framework automatically. This is necessary for the dynamic linker to link the framework at launch time.

Before continuing ⚠️

bash <(curl -sSL https://raw.githubusercontent.com/tuist/swiftable-tuist-workshop/main/test.sh) 5

If you get stuck, clone this repo and run git checkout 5.

6. Multi-project workspace

Even though with Xcode projects and workspaces gitignored, there's less need for Xcode workspaces. You might want to treat projects as an umbrella to group multiple targets that belong to the same domain and use workspaces to group all the projects.

Tuist supports that too. To see it in practice, we are going to move the Project.swift under Sources/Swiftable:

mv Sources Modules

mkdir -p Modules/Swiftable/Sources
mv Modules/Swiftable/ContentView.swift Modules/Swiftable/Sources/ContentView.swift
mv Modules/Swiftable/SwiftableApp.swift Modules/Swiftable/Sources/SwiftableApp.swift

mkdir -p Modules/SwiftableKit/Sources
mv Modules/SwiftableKit/SwiftableKit.swift Modules/SwiftableKit/Sources/SwiftableKit.swift

touch Workspace.swift

cp Project.swift Modules/Swiftable/Project.swift
mv Project.swift Modules/SwiftableKit/Project.swift

We'll end up with the following directory structure:

├── Modules
│   ├── Swiftable
│   │   ├── Project.swift
│   │   └── Sources
│   │       ├── ContentView.swift
│   │       └── SwiftableApp.swift
│   └── SwiftableKit
│       ├── Project.swift
│       └── Sources
│           └── SwiftableKit.swift
├── Tuist
│   └── Config.swift
└── Workspace.swift

Note how we've organized the project in multiple modules, each of which has its own Project.swift. Now let's edit it with tuist edit and make sure we have the following content in the files:

Workspace.swift
import ProjectDescription

let workspace = Workspace(name: "Swiftable", projects: ["Modules/*"])
Modules/Swiftable/Project.swift
import ProjectDescription

let project = Project(name: "Swiftable", targets: [
    Target(name: "Swiftable",
           platform: .iOS,
           product: .app,
           bundleId: "com.swiftable.App",
           sources: [
            "./Sources/**/*.swift"
           ],
           dependencies: [
            .project(target: "SwiftableKit", path: "../SwiftableKit")
           ])
])
Modules/SwiftableKit/Project.swift
import ProjectDescription

let project = Project(name: "SwiftableKit", targets: [
    Target(name: "SwiftableKit",
           platform: .iOS,
           product: .staticLibrary,
           bundleId: "com.swiftable.Kit",
           sources: [
            "./Sources/**/*.swift"
           ])
])

Generate the project and makes sure it compiles and runs successfully.

Before continuing ⚠️

bash <(curl -sSL https://raw.githubusercontent.com/tuist/swiftable-tuist-workshop/main/test.sh) 6

If you get stuck, clone this repo and run git checkout 6.

7. Sharing code across projects

When you start splitting your project into multiple Project.swift a natural need for sharing code to ensure consistency arises. Luckily, Tuist provides an answer for that, and it's called Project Description Helpers. Let's create a folder Tuist/ProjectDescriptionHelpers and a file Project+Swiftable.swift:

mkdir -p Tuist/ProjectDescriptionHelpers
touch Tuist/ProjectDescriptionHelpers/Project+Swiftable.swift

Then let's edit the Tuist project with tuist edit and edit the following files:

Tuist/ProjectDescriptionHelpers/Project+Swiftable.swift
import ProjectDescription

public enum Module: String {
    case app
    case kit
    
    var product: Product {
        switch self {
        case .app:
            return .app
        case .kit:
            return .framework
        }
    }
    
    var name: String {
        switch self  {
        case .app: "Swiftable"
        default: "Swiftable\(rawValue.capitalized)"
        }
    }
    
    var dependencies: [Module] {
        switch self {
        case .app: [.kit]
        case .kit: []
        }
    }
}

public extension Project {
    static func swiftable(module: Module) -> Project {
        let dependencies = module.dependencies.map({ TargetDependency.project(target: $0.name, path: "../\($0.name)") })
        return Project(name: module.name, targets: [
            Target(name: module.name,
                   platform: .iOS,
                   product: module.product,
                   bundleId: "com.swiftable.\(module.name)",
                   sources: [
                    "./Sources/**/*.swift"
                   ],
                   dependencies: dependencies)
        ])
    }
}
Modules/Swiftable/Project.swift
import ProjectDescription
import ProjectDescriptionHelpers

let project = Project.swiftable(module: .app)
Modules/SwiftableKit/Project.swift
import ProjectDescription
import ProjectDescriptionHelpers

let project = Project.swiftable(module: .kit)

Before continuing ⚠️

bash <(curl -sSL https://raw.githubusercontent.com/tuist/swiftable-tuist-workshop/main/test.sh) 7

If you get stuck, clone this repo and run git checkout 7.

8. XcodeProj-native integration of Packages

Tuist supports integrating Swift Packages into your projects using Xcode's standard integration. However, that integration is not ideal at scale for a few reasons:

  • Clean builds, which happen in CI environments and often locally when developers clean their environments to resolve cryptic Xcode errors, lead to the resolution and compilation of those packages, which slows the builds.
  • There's little configurability of the integration, which creates a strong dependency on Apple to fix the issues that arise via their radar system.
  • There's little room for optimization. For example to turn them into binaries and speed up clean builds.

Because of that, Tuist proposes a different integration method, which takes the best of SPM and CocoaPods worlds. It uses SPM to resolve the packages, and CocoaPods' idea of integrating dependencies using XcodeProj primitives such as targets and build settings. Let's see how it works in action.

Create the following following file:

touch Tuist/Dependencies.swift

And use tuist edit to edit it with Xcode. We'll edit the following files:

Tuist/Dependencies.swift
import ProjectDescription

let dependencies = Dependencies(swiftPackageManager: .init([
    Package.package(url: "https://github.com/httpswift/swifter", .exact("1.5.0"))
]))
Tuist/ProjectDescriptionHelpers/Project+Swiftable.swift
import ProjectDescription

+public enum Dependency {
+    case module(Module)
+    case package(String)
+    
+    var targetDependency: TargetDependency {
+        switch self {
+        case let .module(module): TargetDependency.project(target: module.name, path: "../\(module.name)")
+        case let .package(package): TargetDependency.external(name: package)
+        }
+    }
+}

public enum Module: String {
    case app
    case kit
    
    var product: Product {
        switch self {
        case .app:
            return .app
        case .kit:
            return .framework
        }
    }
    
    var name: String {
        switch self  {
        case .app: "Swiftable"
        default: "Swiftable\(rawValue.capitalized)"
        }
    }
    
+    var dependencies: [Dependency] {
+        switch self {
+        case .app: [.module(.kit)]
+        case .kit: [.package("Swifter")]
+        }
+    }
}

public extension Project {
    static func swiftable(module: Module) -> Project {
+        let dependencies = module.dependencies.map(\.targetDependency)
        return Project(name: module.name, targets: [
            Target(name: module.name,
                   platform: .iOS,
                   product: module.product,
                   bundleId: "com.swiftable.\(module.name)",
                   sources: [
                    "./Sources/**/*.swift"
                   ],
                   dependencies: dependencies)
        ])
    }
}

Note that we add a new enum, Dependency that we can use to model dependencies, which can now be of two types, module or package. The enum exposes a targetDependency property to return the value that targets need when defining their dependencies.

Now we need to run tuist fetch, which uses the Swift Package Manager to resolve the dependencies. After they've been fetched, you can run tuist generate to generate the project and open it.

Then let's edit the SwiftableApp.swift to run the server when the view appears:

import SwiftUI
import SwiftableKit
+import Swifter

@main
struct SwiftableApp: App {
    let kit = SwiftableKit()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
+                .onAppear(perform: {
+                    let server = HttpServer()
+                    server["/hello"] = { .ok(.htmlBody("You asked for \($0)"))  }
+                    try? server.start()
+                    print("Server running")
+                })
        }
    }
}

Before we wrap up this topic, add Tuist/Dependencies to the .gitignore.

Before continuing ⚠️

bash <(curl -sSL https://raw.githubusercontent.com/tuist/swiftable-tuist-workshop/main/test.sh) 8

If you get stuck, clone this repo and run git checkout 8.

9. Focused projects

When modular projects grow, it's common to face Xcode and compilation slowness. It's normal, your project is large, and Xcode has a lot to index and process. If you think about it, it doesn't make sense to load an entire graph of targets, when you plan to only focus on a few of them.

Tuist provides an answer to that, and it's built into the command that you've been using since the beginning, tuist generate.

If you pass a list of targets that you plan to focus on, Tuist will generate projects with only the targets that you need to work on that one. For example, let's say we'd like to focus on SwiftableKit, for which we don't need Swiftable. We can then run:

tuist generate SwiftableKit

You'll notice that Swiftable will magically ✨ disappear from the generated project. If you want to include it too, you can pass it in the list of arguments:

tuist generate Swiftable SwiftableKit

Tuist gives developers an interface to express their target of focus.

10. Focused and binary-optimized projects

Tuist knows your project because you've described it to it, and this is very valuable information that can be used to perform powerful optimizations with little complexity for you. One of them is what we call binary caching.

Tuist can turn targets of your graph, including packages, into binaries, and replace targets with their binaries at generation time. Let's give it a shot:

tuist cache warm
tuist generate

By default, it tries to cache the dependencies. You can even cache your targets too. For example, if you want to focus on Swiftable, let's use a binary for everything else:

tuist generate Swiftable

Note: You might need to delete the workspace to mitigate an Xcode issue parsing the workspace.

Bonus 1. Synthesized resource interfaces

By default, Tuist synthesizes interfaces to access the resources in your project. It does so for a few reasons:

  • Add support for resources to products that don't support them, like libraries.
  • Help reduce the likelihood of runtime errors by leveraging the compiler.

To see it in action, let's edit the project description helper to include resources in our targets:

// Tuist/ProjectDescriptionHelpers/Project+Swiftable.swift
public enum Module: String {
+    var resources: ProjectDescription.ResourceFileElements? {
+        switch self {
+        case .kit: return ["Resources/**/*"]
+        case .app: return nil
+        }
+    }
}

public extension Project {
    static func swiftable(module: Module) -> Project {
        let dependencies = module.dependencies.map(\.targetDependency)
        return Project(name: module.name, targets: [
            Target(name: module.name,
                   platform: .iOS,
                   product: module.product,
                   bundleId: "com.swiftable.\(module.name)",
                   sources: [
                    "./Sources/**/*.swift"
                   ],
+                   resources: module.resources,
                   dependencies: dependencies)
        ])
    }
}

Then create the following file at Modules/SwiftableKit/Resources/swiftable.strings:

"conference" = "swiftable";

And then run tuist generate. You'll notice that the generated project now contains a SwiftableKitStrings.conference to access the string with the conference key.

This feature is powered by SwiftGen and you can extend it with additional templates.

To see how Tuist adds support for resources to products that don't support it, change the product of the SwiftableKit to .staticLibrary and generate the project again. You'll notice that Tuist generates a bundle and configures the build phases accordingly to ensure the bundle ends up copied inside the app.

// Tuist/ProjectDescriptionHelpers/Project+Swiftable.swift
public enum Module: String {
    case app
    case kit
    
    var product: Product {
        switch self {
        case .app:
            return .app
        case .kit:
+            return .staticLibrary
        }
    }
}

Bonus 2. Templates

Tuist has a command, tuist scaffold, to automate the generation of content in a project. Scaffold uses templates defined in a Tuist/Templates, and templates are a combination of a manifest file and Stencil files.

We are going to implement a template to create new feature modules in the project. Feature modules are horizontally distributed (they don't depend on each other), and they all depend on SwiftableKit.

The first step will be adjusting our helpers at Tuist/ProjectDescriptionHelpers/Project+Swiftable.swift to account for the new module type:

+public enum Module {
    case app
    case kit
+    case feature(String)
    
    var rawValue: String {
        switch self {
        case .app: "app"
        case .kit: "kit"
+        case .feature(let feature): feature
        }
    }
        
    var product: Product {
        switch self {
        case .app:
            return .app
        case .kit:
            return .staticLibrary
+        case .feature:
+            return .staticLibrary
        }
    }
    
    var name: String {
        switch self  {
        case .app: "Swiftable"
+        case let .feature(name): name.capitalized
        default: "Swiftable\(rawValue.capitalized)"
        }
    }
    
    var dependencies: [Dependency] {
        switch self {
        case .app: [.module(.kit)]
        case .kit: [.package("Swifter")]
+        case .feature: [.module(.kit)]
        }
    }
    
    var resources: ProjectDescription.ResourceFileElements? {
        switch self {
        case .kit: return ["Resources/**/*"]
        case .app: return nil
+        case .feature: return nil
        }
    }
}

Once we have the helpers adjusted, we can create the template at Tuist/Templates/feature:

Tuist/Templates/feature/feature.swift
import ProjectDescription

let nameAttribute: Template.Attribute = .required("name")

let template = Template(
    description: "Creates a new feature module",
    attributes: [
        nameAttribute,
    ],
    items: [
        .file(path: "Modules/\(nameAttribute)/Project.swift",
              templatePath: "Feature/Project.stencil"),
        .file(path: "Modules/\(nameAttribute)/Sources/Feature.swift",
              templatePath: "Feature/Sources/Feature.stencil")
    ]
)
Tuist/Templates/feature/Feature/Project.stencil
import ProjectDescription
import ProjectDescriptionHelpers

let project = Project.swiftable(module: .feature("{{name}}"))
Tuist/Templates/feature/Feature/Sources/Feature.stencil
import Foundation

public class {{name | capitalize}} {
    public init() {}
}

Once we have the template created, we can scaffold a new feature by running:

tuist scaffold feature --name Settings

The new module should get created at Modules/Settings. Note that you'll have to explicitly declare the dependency between the Swiftable module (app) and the new feature module.

About

This repository contains the content for the Shiftable 2023 workshop about Tuist


Languages

Language:Shell 51.5%Language:Swift 48.5%