Пример настройки Tuist на существующем проекте

Пример настройки Tuist на существующем проекте

Что такое Tuist

Tuist это инструмент для генерации проектов XCode. Это позволяет организовать структуру проекта и автоматизировать конфигурацию зависимостей более удобным способом. Такое решение отлично подходит для тех у кого есть множество проектов которые зависят от одной кодовой базы или для тех кому необходимо собирать один и тот же проект с различными конфигурациями (Test, Stage, Prod и т.п.). Так же это избавляет от разрешения конфликтов при мерже xcproget-ов, так как по большей части их больше нет, они каждый раз будут сгенерированы по новому для каждого разработчика отдельно.

Как всегда есть и минусы в подобном решении, так как проект с открытым исходным кодом, владельцы проекта не всегда идут на встречу и решают проблемы сообщества. Однако, на то он и open source, и если какое то решение необходимо реализовать, то это можно решить своими силами. Проект написан swift, а большинству кто будет пользоваться инструментом этот язык хорошо знаком.

Так же Tuist не поддерживает и не будет поддерживать CocoaPods. Это не такая критичная проблема так как перенос всех зависимостей на SPM или Carthage не составит труда.

Подготовка

Сперва необходимо установить сам tuist, если вы все еще это не сделали

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

Далее уже можно приступать к самой подготовке. Нам нужно сохранить конфигурации ваших проектов и их таргетов. Для этого я сделал папку Configs, куда сохранил все конфигурации

tuist migration settings-to-xcconfig -p Project.xcodeproj -t MyApp -x Configs/MyApp.xcconfig
tuist migration settings-to-xcconfig -p Project.xcodeproj -x Configs/MyAppProject.xcconfig

Зависимости

На этом шаге мы почти готовы удалить проекты, но для начала необходимо перенести зависимости. Создаем папку Tuist и в нем файл Dependencies.swift. В нем мы поместим все зависимости. Здесь небольшой пример содержимого файла, на примере части конфига в нашем проекте

import ProjectDescription

let dependencies = Dependencies(
		carthage: [
        .github(path: "Alamofire/Alamofire", requirement: .exact("5.0.4")),
    ],
    swiftPackageManager: [
        .remote(url: "https://github.com/amplitude/Amplitude-iOS.git", requirement: .upToNextMajor(from: "8.0.0")),
        .remote(url: "https://github.com/firebase/firebase-ios-sdk.git", requirement: .upToNextMajor(from: "9.0.0")),
        .remote(url: "https://github.com/facebook/facebook-ios-sdk", requirement: .upToNextMajor(from: "15.0.0")),
        .remote(url: "https://github.com/onevcat/Kingfisher.git", requirement: .upToNextMajor(from: "7.0.0")),
        .remote(url: "https://github.com/SnapKit/SnapKit.git", requirement: .upToNextMajor(from: "5.0.0")),
    ],
    platforms: [.iOS]
)

Если в вашем проекте используется Realm, то существует проблема https://github.com/tuist/tuist/issues/3928 которая не позволяет нам добавить его ни через carthage ни через SPM. К сожалению, единственное решение которое я нашел, это импорт xcframework realm-а уже напрямую.

Конфигурация

Теперь мы готовы приступить к самой конфигурации проектов. Создаем файл Projects.swift в корне проекта и начинаем добавляет проекты. В нем будет указаны конфигурации самих проектов и их взаимоотношения между друг другом. А так же добавим скачанные зависимости в необходимые таргеты.

import ProjectDescription

let deploymentTarget: DeploymentTarget = .iOS(targetVersion: "13.0", devices: [.iphone, .ipad])

let project = Project(
    name: "Company Apps",
    organizationName: "Our company name",
    targets: [
        Target(
            name: "MyApp1",
            platform: .iOS,
            product: .app,
            bundleId: "com.company.myapp1",
            deploymentTarget: deploymentTarget,
            infoPlist: "Targets/MyApp1/Info.plist",
            sources: ["Targets/MyApp1/**"],
            resources: [
                "Targets/MyApp1/Resources/**",
                "Targets/MyApp1/GoogleService-Info.plist",
            ],
            dependencies: [
                .target(name: "MyFramework"),
            ],
            settings: .settings(
                configurations: [
                    .debug(name: .debug, xcconfig: Path("Configs/MyApp1.xcconfig")),
                    .release(name: .release, xcconfig: Path("Configs/MyApp1.xcconfig")),
                ]
            )
        ),
        Target(
            name: "MyApp2",
            platform: .iOS,
            product: .app,
            bundleId: "com.company.myapp2",
            deploymentTarget: deploymentTarget,
            infoPlist: "Targets/MyApp2/Info.plist",
            sources: ["Targets/MyApp2/**"],
            resources: [
                "Targets/MyApp2/Resources/**",
                "Targets/MyApp2/GoogleService-Info.plist",
            ],
            dependencies: [
                .target(name: "MyFramework"),
            ],
            settings: .settings(
                configurations: [
                    .debug(name: .debug, xcconfig: Path("Configs/MyApp2.xcconfig")),
                    .release(name: .release, xcconfig: Path("Configs/MyApp2.xcconfig")),
                ]
            )
        ),
        Target(
            name: "MyFramework",
            platform: .iOS,
            product: .framework,
            bundleId: "com.company.MyFramework",
            deploymentTarget: deploymentTarget,
            sources: ["Targets/MyFramework/**"],
            resources: ["Targets/MyFramework/Resources/**"],
            headers: .headers(
                public: [
                    "Targets/MyFramework/Thirdparity/SomeCLibrary/**",
                    "Targets/MyFramework/Headers/**",
                ]
            ),
            dependencies: [
                .external(name: "Alamofire"),
                .external(name: "Amplitude"),
                .external(name: "ApphudSDK"),
                .external(name: "FacebookCore"),
                .external(name: "FirebaseAnalytics"), .external(name: "FirebaseRemoteConfig"), .external(name: "FirebaseCrashlytics"),
                .external(name: "Kingfisher"),
                .external(name: "SnapKit"),

                .xcframework(path: "Frameworks/RealmSwift.xcframework"),
                .xcframework(path: "Frameworks/Realm.xcframework"),
            ],
            settings: .settings(
                configurations: [
                    .debug(name: .debug, xcconfig: Path("Configs/MyFramework.xcconfig")),
                    .release(name: .release, xcconfig: Path("Configs/MyFramework.xcconfig")),
                ]
            )
        ),
    ]
)

В этом примере я постарался охватить максимальное количество ситуаций которое может возникнуть при переносе существующего проекта на tuist. У нас есть два проекта которые имеют один общий фреймворк который забирает все зависимости проекта и даже имеет C код при себе. Так как фреймворки не могут иметь bridging хедеров, то импорт такого кода происходит через импорт самодельного хедера с нужным набором импортов. Важно сложить все ресурсы которые не являются swift кодом или исходниками на другом языке в отдельную папку, иначе придется указывать локацию таких файлов отдельно как, например, я совершил импорт файла конфигурации от firebase.

Разберем текущий инциализатор по пунктам

  • name - Название проекта, это будет название таргета
  • platform - платформа соответсвенно
  • product - тип таргета, фреймворк, приложение или другое.
  • bundleId - наш идентификатор бандла
  • deploymentTarget - минимальная версия SDK
  • infoPlist - адрес нашего plist (не обязателен, если он почти пустой)
  • sources - адреса исходников. Полезно так как можно соединить код с разных проектов
  • resources -  адреса ресурсов
  • dependencies - зависимости. Могут быть другие таргеты или внешние зависимости указанные в файле Dependencies.swift
  • settings - конфигурации проектов которые мы предварительно экспортировали

Так как это swift файлик, то генерацию каждого таргета можно оптимизировать путем расширения Target и упрощения инициализатора.

Скрипты

Запуск скриптов до или после сборки можно реализовать через конфигурацию таргета. Создадим в корне проекта папку с нужными нам скриптами. Например, для firebase.

scripts/firebase.sh:

if [ "${CONFIGURATION}" != "Debug" ]; then
     "Tuist/Dependencies/SwiftPackageManager/.build/checkouts/firebase-ios-sdk/Crashlytics/run" \
     "Tuist/Dependencies/SwiftPackageManager/.build/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols" \
     -gsp ${SCRIPT_INPUT_FILE_0} \
     -p ios ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}
fi

Далее добавим скрипт в конфигурацию

...
let project = Project(
    ...
    targets: [
        Target(
            ...
            resources: [
                "Targets/MyApp1/Resources/**",
                "Targets/MyApp1/GoogleService-Info.plist",
            ],
            scripts: [
            	.post(
                     path: "./scripts/firebase.sh",
                     name: "Firebase Crashlystics",
                     inputPaths: ["Targets/MyApp1/GoogleService-Info.plist"]
                 ),
            ]
            dependencies: [
                .target(name: "MyFramework"),
            ],
            ...
        ),
        ...
    ],
    ...
}

Оптимизация

Tuist позволяет нам реализовать расширения классов где мы можем оптимизировать инициализацию таргетов, фреймворков и пр., что значительно разгрузит конфигурацию и улучшит ее читаемость.

Для этого создадим папку ProjectDescriptionHelpers в папке Tuist и наполним ее расширениями классов которые хотим упроситить. Например, инициализация таргета Target+Apps.swift:

import ProjectDescription

public extension Target {
    static func app(
        name: String,
        bundleId: String,
        id: String,
        scripts: [TargetScript]
    ) -> Target {
        Target(
            name: name,
            platform: .iOS,
            product: .app,
            bundleId: bundleId,
            deploymentTarget: .default,
            infoPlist: "Targets/\(id)/Info.plist",
            sources: ["Targets/\(id)/**"],
            resources: [
                "Targets/\(id)/Resources/**",
                "Targets/\(id)/GoogleService-Info.plist",
            ],
            scripts: scripts,
            dependencies: [
                .target(name: "MyFramework"),
            ],
            settings: .settings(
                configurations: [
                    .debug(name: .debug, xcconfig: Path("Configs/\(id).xcconfig")),
                    .release(name: .release, xcconfig: Path("Configs/\(id).xcconfig")),
                ]
            )
        )
    }
}

extension DeploymentTarget {
    static let `default`: DeploymentTarget = .iOS(targetVersion: "13.0", devices: [.iphone, .ipad])
}

Теперь можно обновить список проектов

...
let project = Project(
    ...
    targets: [
        .app(name: "MyApp1",
            bundleId: "com.company.myapp1",
            id: "MyApp1",
            scripts: [
                .firebase(plist: "Targets/MyApp1/GoogleService-Info.plist"),
         ]),
        .app(name: "MyApp2",
            bundleId: "com.company.myapp2",
            id: "MyApp2",
            scripts: [
                .firebase(plist: "Targets/MyApp2/GoogleService-Info.plist"),
         ]),
        ...
    ]
)

Здесь так же присуствует расширение для скриптов. Оно реализовано следующим образом TargetScript+Upload.swift:

import ProjectDescription

extension TargetScript {
    public static func firebase(plist: Path) -> TargetScript {
        .post(
            path: "./scripts/firebase.sh",
            name: "Firebase Crashlystics",
            inputPaths: [plist]
        )
    }
}

Запуск

На этом вся необходимая конфигурация готова. Остается только попробовать запустить генерацию. Но предварительно необходимо сделать загрузку зависимостей. Это делается командой

tuist fetch

А потом уже можно сделать генерацию самого проекта

tuist generate

Если все пройдет хорошо, что конечно скорее всего не так, то нам откроется наш workspace с проектами. Так же есть удобная функция позволяющая собрать проект чисто под одно приложение. Например:

tuist generate MyApp1

Если вам понадобится вновь производить какие то изменения в конфигурацию можно пользоваться командой

tuist edit

Будет создан проект манифеста, после изменения которого нужно будет нажать ctrl+c что бы tuist почистил лишнее.

Советую занести все сгенеренные tuist-ом файлы в gitignore чтобы избежать конфликтов между различными версиями. Необходимо привыкнуть, что самому проект лучше не открывать, а запускать через tuist generate.

Возможные проблемы

Если вы обновите набор зависимостей, то загрузить обновления необходимо через флаг u:

tuist fetch -u

Так же может произойти так, что после обновления каких либо конфигураций, проект перестает генерироваться, то можно попробовать почистить кеш и прочие файлы которые этому препятствуют.

tuist clean 

Так же можно сгенерировать проект без использования кеша

tuist generate --no-cache