Монорепозиторий с Swift: организация кода на практике
Современная iOS-разработка всё чаще сталкивается с вызовом масштабирования кодовой базы. Когда проект вырастает до десятков модулей, общих библиотек и нескольких приложений (iOS, macOS, watchOS), традиционная модель с отдельными репозиториями (polyrepo) начинает тормозить разработку. На помощь приходит монорепозиторий — единый репозиторий, содержащий код всех компонентов экосистемы. В этой статье мы на практике разберём, как организовать монорепозиторий для Swift-проектов, используя современные инструменты: Swift Package Manager (SPM), Tuist и эффективные CI/CD-пайплайны.
1. Зачем нужен монорепозиторий в Swift-экосистеме?
Прежде чем погружаться в реализацию, важно понять ключевые преимущества подхода:
- Единая версионность — все модули и приложения находятся в одном коммите, что исключает проблему "ада зависимостей" (dependency hell).
- Упрощённый рефакторинг — изменение API в библиотеке сразу видно во всех потребителях; компилятор подсветит ошибки в рамках одного PR.
- Прозрачная навигация — разработчики видят весь код проекта, не переключаясь между репозиториями.
- Оптимизация CI/CD — можно настроить сборку только изменённых модулей (targeted builds), экономя время и ресурсы.
Однако монорепозиторий требует дисциплины: чёткой модульной архитектуры и инструментов, предотвращающих хаос. Для Swift лучшим выбором сегодня является Tuist — утилита, которая управляет Xcode-проектами декларативно, или чистый Swift Package Manager для модульной структуры.
2. Структура монорепозитория на Swift Package Manager
Начнём с базовой организации. Допустим, у нас есть два приложения: MyAppiOS и MyAppMacOS, а также набор внутренних библиотек. Файловая структура может выглядеть так:
Monorepo/
├── Apps/
│ ├── MyAppiOS/
│ │ ├── Sources/
│ │ └── Package.swift (или project.yml для Tuist)
│ └── MyAppMacOS/
│ ├── Sources/
│ └── Package.swift
├── Libraries/
│ ├── Networking/
│ │ ├── Sources/
│ │ └── Package.swift
│ ├── UIComponents/
│ │ ├── Sources/
│ │ └── Package.swift
│ └── Core/
│ ├── Sources/
│ └── Package.swift
├── Tests/
│ ├── NetworkingTests/
│ └── UIComponentsTests/
├── Package.swift (корневой манифест)
└── Tuist/ (если используем Tuist)
└── Project.swift
Корневой Package.swift связывает всё воедино. Пример манифеста:
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "MyMonorepo",
platforms: [
.iOS(.v16), .macOS(.v13)
],
products: [
.library(name: "Networking", targets: ["Networking"]),
.library(name: "UIComponents", targets: ["UIComponents"]),
.library(name: "Core", targets: ["Core"]),
.executable(name: "MyAppiOS", targets: ["MyAppiOS"]),
.executable(name: "MyAppMacOS", targets: ["MyAppMacOS"])
],
dependencies: [
// Внешние зависимости
.package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0")
],
targets: [
.target(
name: "Networking",
dependencies: ["Core", .product(name: "Alamofire", package: "Alamofire")],
path: "Libraries/Networking/Sources"
),
.target(
name: "UIComponents",
dependencies: ["Core"],
path: "Libraries/UIComponents/Sources"
),
.target(
name: "Core",
path: "Libraries/Core/Sources"
),
.executableTarget(
name: "MyAppiOS",
dependencies: ["Networking", "UIComponents"],
path: "Apps/MyAppiOS/Sources"
),
.executableTarget(
name: "MyAppMacOS",
dependencies: ["Networking", "UIComponents"],
path: "Apps/MyAppMacOS/Sources"
),
// Тесты
.testTarget(
name: "NetworkingTests",
dependencies: ["Networking"],
path: "Tests/NetworkingTests"
)
]
)
Важно: каждый внутренний модуль имеет собственный Package.swift только если он может быть опубликован отдельно. В рамках монорепозитория достаточно одного корневого манифеста — это упрощает управление версиями.
3. Настройка Tuist для монорепозитория
Если вы предпочитаете Xcode-проекты, а не SPM-сборку, Tuist станет вашим лучшим другом. Он генерирует .xcodeproj из декларативного файла Project.swift. Пример для нашего монорепозитория:
// Tuist/Project.swift
import ProjectDescription
let project = Project(
name: "Monorepo",
targets: [
// iOS приложение
Target(
name: "MyAppiOS",
platform: .iOS,
product: .app,
bundleId: "com.example.myappios",
infoPlist: .default,
sources: ["Apps/MyAppiOS/Sources/**"],
dependencies: [
.target(name: "Networking"),
.target(name: "UIComponents")
]
),
// macOS приложение
Target(
name: "MyAppMacOS",
platform: .macOS,
product: .app,
bundleId: "com.example.myappmacos",
infoPlist: .default,
sources: ["Apps/MyAppMacOS/Sources/**"],
dependencies: [
.target(name: "Networking"),
.target(name: "UIComponents")
]
),
// Библиотеки
Target(
name: "Networking",
platform: .iOS,
product: .framework,
bundleId: "com.example.networking",
sources: ["Libraries/Networking/Sources/**"],
dependencies: [
.target(name: "Core")
]
),
Target(
name: "UIComponents",
platform: .iOS,
product: .framework,
bundleId: "com.example.uicomponents",
sources: ["Libraries/UIComponents/Sources/**"],
dependencies: [
.target(name: "Core")
]
),
Target(
name: "Core",
platform: .iOS,
product: .framework,
bundleId: "com.example.core",
sources: ["Libraries/Core/Sources/**"]
)
]
)