SPM vs Tuist: що обрати для великих iOS-проєктів у 2026 році
У великих iOS-проєктах кількість модулів постійно зростає, через це ускладнюється їхня структура: стає важче керувати залежностями (як внутрішніми, так і зовнішніми), і це напряму впливає на швидкість розробки. Тому розробникам край важливо мати інструмент організації iOS-проєкту, який добре керуватиме залежностями між його модулями й конфігурацією загалом.
Є кілька типових маячків, які сигналізують, що вам потрібно переглянути поточну організацію модулів або замінити інструмент, завдяки якому ви ними керуєте. Наприклад:
- постійно росте build time;
- підтримка конфігурації проєкту займає дедалі більше часу;
- dependency graph стає настільки заплутаним, що будь-яка зміна має непередбачувані наслідки.
Коли наша команда почала стикатися з проблемами вище, ми все ще користувалися CocoaPods — опенсорсним менеджером сторонніх залежностей для iOS/macOS проєктів.
Бажання мігрувати на щось більш сучасне в нас було вже давно, адже CocoaPods завжди мав певні проблеми, як от довгий резолв графу (pod install), помилки резолву при транзитивних dependency, конфлікти між конфігами в Podspec та Xcode, постійні ребілди з незрозумілих причин, залежність від Ruby й багато іншого (причин на окрему статтю набереться).
Але вирішальним поштовхом усе ж стали новини про deprecation CocoaPods у 2026 році. Після них ми вирішили, що прийшов час нарешті відмовитися від CocoaPods і знайти новий інструмент для організації проєкту. Тоді ж почали придивлятися до Swift Package Manager (SPM) і Tuist.
Хто я і про що сьогодні вам розповім
Мене звати Анастасія Старченко, уже понад девʼять років я займаюся розробкою софта на macOS/iOS. Маю досвід роботи у Snap Inc. і MacPaw. Зараз працюю iOS Team Lead у продукті Hily в українській продуктовій IT-компанії appflame.
Сьогодні разом зі своїм колегою, Олександром Бакутою, iOS Engineer Hily, я поділюся нашим досвідом міграції з CocoaPods на SPM і Tuist.
Ми разом з Олександром займалися цією міграцією, розбивши зони відповідальності: я займалася дослідженням цих інструментів і всім, що стосувалося SPM, а Олександр узяв на себе роботу з Tuist. Нашою метою було обрати інструмент, який найкраще підходить для масштабного і динамічного модульного застосунку.
Нижче в статті ми поетапно розповідаємо, як ми мігрували на новий інструмент, порівнюємо функціональність SPM і Tuist, ділимося результатами впровадження Tuist Build Cache й загальними висновками, які можуть допомогти під час вашої міграції.
Як ми мігрували на SPM і Tuist
Крок #1. Підготовка до пошуку нового інструменту
Перед тим, як почати дослідження нових інструментів для організації iOS-проєкту, ми сформували чітке бачення, як в ідеалі має виглядати робота над нашим проєктом: менеджмент залежностей між модулями й зовнішніх залежностей, робота над конфігураціями й так далі. Нижче розберемо наші основні вимоги.
Вимога 1. Усі залежності між модулями на проєкті мають бути явними (explicit dependencies)
Явні залежності (explicit dependencies) — це чітко визначені, задекларовані модулем залежності (бібліотеки, фреймворки, інші модулі). Вони прямо вказані в конфігурації модуля.
Тоді як неявні (implicit / hidden / leaky dependencies) використовуються в модулі, але водночас вони не прописані в коді, як залежності цього модуля.
Розглянемо, звідки беруться неявні залежності на прикладі з CocoaPods, де всі залежності неявно транзитивні по дефолту.

В коді може виглядати так, що FeatureModule імпортує тільки DomainModule, але насправді FeatureModule має доступ до функціоналу UtilsModule. Тож у FeatureModule можна звертатися до будь-якого методу, типу, класу з публічного інтерфейсу UtilsModule. Із часом такі звернення до UtilsModule накопичуються у FeatureModule й, по факту, один модуль буде залежати від іншого, але не буде явно його імпортувати — це і є неявна залежність.
Подібних неявних залежностей стає ще більше, якщо від DomainModule залежать і інші модулі — FeatureModule1, FeatureModule2, FeatureModule3 і т.ін. Тоді UtilsModule стає компонентом, від якого фактично неявно залежить увесь проєкт.
Чому всі залежності між модулями мають бути явними?
Неявні залежності шкодять проєкту, насамперед через side-effects, коли зміна в одному модулі неочікувано змінює поведінку іншого. Якщо пощастить, команда спіймає ці зміни на етапі компіляції чи тестування, але цьому немає 100 % гарантії.
На прикладі вище, зміна в UtilsModule може змінити поведінку всіх фіч, які неявно від нього залежні й у результаті зʼявляються баги.
Також, неявні залежності непомітно погіршують build time, а це має негативний вплив на developer experience й потенційно підвищує витрати на CI. Хоч в Xcode є механізми оптимізації часу зборки (наприклад, він кешує попередні результати компіляції модулей, щоби пришвидшити наступний білд), але при кожній зміні в модулі інвалідується його кеш і кеш усіх модулей, котрі від нього залежать.
Отже, навіть при мінорній зміні в такому god-module як UtilsModule, буде інвалідуватися не тільки його кеш, а ще й кеш усіх FeatureModules, які від нього залежать. Тобто, майже весь проєкт буде перезбиратися заново й компіляція буде відбуватися повільно, й без детального аналізу ви не зрозумієте причину, чому так відбувається.
Ще одна проблема неявних залежностей — розробники не бачать їх під час аналізу графу залежностей (dependency graph). Граф залежностей — це мапа всіх залежностей між модулями й ключ до аналізу здоров’я проєкту. Його характеристики безпосередньо впливають на швидкість компіляції та складність розробки. І якщо в графі відображені не всі залежності, то ви можете дійти до хибних висновків під час його аналізу.
Вимога 2. Dependency graph проєкту має бути оптимальним
Що властиво оптимальному графу?
Властивість 1 — відсутність циклічних залежностей (Acyclicity). Якщо модулі циклічно залежні, їх неможливо окремо використовувати, збирати, тестувати й тоді весь сенс модульності втрачається.

Граф проєкту зі циклічно залежними модулями виглядав би ось так:

Натомість граф має виглядати як дерево, без будь-яких циклів:

Властивість 2 — низхідний напрям залежностей (Downward Layering). Якщо архітектура проєкту має шарову структуру, то модулі вищих рівнів (наприклад, FeatureModule) залежать від модулів нижчих рівнів (наприклад, DomainModule), але не навпаки.

Властивість 3 — незмінна база та волатильні фічі (Stable Base — Volatile Features). Чим нижче розташований модуль на дереві залежностей, тим більше інших модулей залежні від нього. А, отже, зміна в ньому, як мінімум, спричинить перекомпіляцію значної частини проєкту. А як максимум — баги на всіх вищих рівнях.
Щоб уникнути таких проблем, низькорівневі модулі, як от Core, Networking, DesignSystem, Utils тощо мають бути максимально стабільними й майже ніколи не змінюватися. Часті зміни краще робити лише у високорівневих модулях (FeatureModules).
Вимога 3. Усі конфігурації проєкту мають бути явними — задекларованими в конфігураційних файлах або коді
Мова про схеми, таргети, Build Settings та Build Phases. Графічний інтерфейс Xcode з його безліччю полів і вкладок, а також громіздкий
Відповідно, якщо налаштування є явними, декларативними та human readable, це знижує ризики випадкових поломок, спрощує внесення змін до конфігурацій проєкту або його налаштувань та процес code review.
Крок #2. Пошук інструменту, який найкраще підійде під наші вимоги та задачі

Серед доступних інструментів організації проєкту в нас було два кандидати, котрі потенційно підходили під наші вимоги:
- SPM — офіційний менеджер залежностей від Apple, інтегрований у Xcode. Він дозволяє описувати модулі та їхні залежності декларативно, на Swift;
- і Tuist — open-source інструмент для генерації Xcode-проєктів. Він дозволяє декларативно описати всю структуру застосунку (таргети, залежності, конфігурації, build phases, скрипти) на Swift, у вигляді так званих маніфестів — swift-файлів з описами модулів та workspace, на основі яких Tuist генерує Xcode-проєкт.
Нашею задачею було цілісно переглянути підхід до організації та конфігурації проєкту, а не лише замінити CocoaPods на інший менеджер залежностей. Тому, обираючи між SPM і Tuist ми розуміли, що SPM насамперед — це dependency manager, він спрощує менеджмент зовнішніх залежностей та допомагає зручно працювати із внутрішніми модулями. Натомість Tuist — це повноцінний інструмент, який дозволяє декларативно працювати не тільки із залежностями, а і з усім проєктом.
Й оскільки наш ідеальний віжн виходив за межі роботи з dependency graph, ми обрали саме Tuist. Залишалося тільки визначитися зі стратегією міграції.
Крок #3. Міграція на новий інструмент
Спочатку ми хотіли мігрувати з CocoaPods одразу на Tuist. У цьому випадку, окрім зміни dependency manager, треба було б також одразу переїхати з мануального керування проєктом (через XCode GUI / XML файли.xcodeproj та.xcworkspace) на повністю декларативний підхід.
Така міграція була б для нас дуже болючою, затратною за часом і з великою кількістю рухомих компонентів, де можуть зламатися динамічне та статичне лінкування, build settings, менеджмент ресурсів і інші речі.
Тому ми зупинилися на другому варіанті — спочатку мігрувати з CocoaPods на SPM і потім на Tuist. У цьому випадку ми могли розділити міграцію на два етапи: спочатку оновити менеджер залежностей, а потім уже ввести нову систему керування проєктом.
А оскільки SPM і Tuist мають багато спільного, цей перехід потребує менше одномоментних змін у проєкті, ніж варіант мігрувати напряму з CocoaPods.
Далі поетапно розберемо, як ми мігрували.
Етап 1. Міграція з CocoaPods → SPM
1 крок: під’єднали всі наявні залежності пакетами замість Pods. Більшість сучасних SDK підтримують це (містять у собі файл Package.swift), або хоча б надають можливість додати їх у проєкт у вигляді xcframework — цей варіант також працює із SPM.
2 крок: описали внутрішні модулі. Нам довелося додати модулям декларативний опис. Для цього в кожному з них ми створили Package.swift файл, де тепер явно вказані таргети та залежності цього модуля (як зовнішні, так і внутрішні).
3 крок: видалили все, що залишилося від CocoaPods. Ми видалили з репозиторію файли Podfile та Podfile.lock, всю теку Pods/, наш.xcworkspace (з SPM залишився тільки.xcodeproj), перенесли всі потрібні xcconfigs з Pods/ в репозиторій та видалили зайві, і прибрали весь тулінг для Ruby.
Також ми почистили сам проєкт: видалили білд-фази [CP] Copy Pods Resources, [CP] Embed Pods Frameworks, [CP] Check Pods Manifest.lock. І наприкінці також віднайшли та почистили всі інші референси до подсів на проєкті.
Олександр Бакута,
iOS Engineer HilyЕтап 2. Міграція з SPM → Tuist
1 крок: заменеджили зовнішні залежності. Міграція тут буде не зовсім правильним терміном, адже Tuist все ще дозволяє використовувати SPM для external dependencies, тож мова імовірніше про впровадження Tuist в проєкт з SPM.
У перспективі вигідніше підключати важкі SDK через prebuilt xcframeworks для пришвидшення генерації проєкту, білд-тайму та більшого контролю над dependency graph (детальніше про це трохи згодом). А на перший час, можна спокійно залишити SPM для менеджменту external dependencies.
2 крок: задекларували внутрішні модулі. Формати декларації в SPM та Tuist дуже схожі: SPM декларує модуль у файлі Package.swift, а Tuist — у файлі Project.swift (в Tuist ці файли називаються маніфестами). Декларації модулей всередині цих файлів мають дуже подібний формат, тож у більшості випадків декларацію з пекедж-файлу можна буде скопіювати в проджект-файл as it is, або ж із невеликими змінами.
3 крок (основна частина роботи): налаштували декларативну конфігурацію всього проєкту. В Tuist декларативному оголошенню підлягають не тільки модулі, а й основний проєкт. Ми описали проєкт у маніфесті Project.swift, аналогічно зі внутрішніми модулями. Там прописуються всі залежності, білд фази, налаштування — усе, що раніше було заховано у вкладках в XCode.
Tuist також має власний CodeGen та менеджмент ресурсів — ці інструменти ми теж підключили.
Порівнюємо функціональність SPM і Tuist на практиці
Міграції з CocoaPods → SPM ми присвятили три місяці, а з SPM → Tuist — трохи більш ніж два тижні. Цей підхід дав нам можливість ознайомитися з обома інструментами (SPM та Tuist) та порівняти їх на практиці. Нижче ділимося висновками щодо функціональності обох інструментів.
Вимога 1. Усі залежності між модулями мають бути явними
→ SPM
SPM добре справляється зі своєю основною задачею — керуванням залежностями. У кожному модулі створюється файл Package.swift, усі залежності модуля описані там декларативно. Наприклад:

Вище можна побачити, що FeatureModule залежить від локальних модулів DomainModule та DesignSystem, а також зовнішньої бібліотеки Alamofire. Таку декларацію розробникам легко читати та аналізувати.
SPM гарантує ізольованість Swift packages. Кожен таргет компілюється як окремий модуль. Доступ до коду іншого модуля можливий лише після import. Модуль може імпортувати лише ті залежності, які були явно оголошені як його Package.swift, а випадково використати код іншого модуля неможливо.
Також, SPM за замовчуванням не дозволяє використовувати транзитивні залежності напряму. Наприклад, якщо залежності між модулями прописані у Package-файлах у такий спосіб, як нижче, то FeatureModule не може імпортувати та використовувати UtilsModule, якщо ця залежність явно не оголошена у Package-файлі FeatureModule.

Такий код не скомпілюється, або впаде на етапі лінкування. Таким чином SPM змушує розробників явно описувати dependency graph та запобігає появі прихованих залежностей. Саме завдяки цьому під час міграції на SPM ми виявляли приховані залежності, які давно були в нашому проєкті.
Проте, SPM залишає простір для того, щоб зробити залежність модуля частково неявною. Swift має механізм реекспорту залежностей — якщо DomainModule має явну залежність на UtilsModule, то в будь-якому файлі модуля DomainModule можна вказати @_exported import UtilsModule. Тоді FeatureModule, який імпортує DomainModule, автоматично зможе використовувати UtilsModule, навіть без явного оголошення цієї залежності у своєму Package.swift. Фактично @_exported дозволяє прокинути dependency через модуль і тим самим частково обійти вимогу explicit dependencies.
У великих модульних проєктах, як от наш, використання @_exported часто обмежують на рівні лінтера. Навіть Apple не заохочують використання цієї фічі — вона не є частиною офіційного стабільного API. Також частково недекларативними залишаються залежності основного application target, адже він не є Swift Package, а звичайним xcodeproj.
Залежності основного таргету залишаються частково неявними. Наприклад, залежності на сторонні SDK зазвичай додаються до основного таргету через меню Add Package Dependency → Link → Add Package у Xcode. У цьому ж діалозі можна обрати конкретні products із пакета та залінкувати їх до певних таргетів. Приклад із нашого проєкту:

Але після додавання, у вкладці «Package Dependencies» в Xcode видно лише сам пакет, але не видно, які продукти з нього залінковані до таргету:

Ще на етапі роботи з SPM ми додали залежність на декілька продуктів AdjustSDK через Xcode UI. Під час міграції на Tuist ми подивилися у вкладку Package Dependencies і перенесли тільки те, що побачили — залежність на AdjustSDK.
Уже потім за метриками на проді ми побачили, що зламалася маркетингова аналітика. Виявилося, що вона залежала від конкретного продукту цього SDK — AdjustGoogleODM — саме того, який ми пропустили під час міграції. При цьому проєкт продовжував збиратися і тести проходили успішно.
Звісно, іронічно, що цього могло б не статися, якби ми не мігрували на Tuist. Але насправді це могло б трапитися з будь-якої іншої причини — під час рефакторингу, іншої міграції або просто зі зростанням команди, бо критично необхідна залежність залишалася захованою у внутрішніх налаштуваннях Xcode.
Щоб уникнути такої проблеми на вашому SPM-проєкті, шукайте конкретні залінковані продукти через налаштування таргету (Frameworks, Libraries and Embedded Content / Link Binary With Libraries).
Олександр Бакута,
iOS Engineer Hily→ Tuist
У Tuist вся конфігурація, структура та залежності проєкту описуються в декларативних Swift-маніфестах. Там декларуються як залежності окремих модулів, так і залежності основного application target.
Для керування залежностями, потрібно відкрити маніфести через команду tuist edit, внести необхідні зміни, виконати tuist install, а потім згенерувати Xcode-проєкт заново за допомогою tuist generate. Лише після цього зміни з’являються в Xcode.
Локальні правки, зроблені безпосередньо в Xcode, не зберігаються. Вони будуть перезаписані під час наступної генерації проєкту.
Весь dependency graph визначається маніфестами Tuist, де декларативно описані всі таргети, залежності, конфігурації та скрипти проєкту. Його легко прочитати та проаналізувати, а всі зміни робляться лише через ці маніфести, а не в налаштуваннях проєкту в Xcode, тож структура й залежності залишаються узгодженими для всієї команди.
Як і з SPM, транзитивні залежності не стають автоматично доступними, але, на відміну від SPM, реекспорт транзитивної залежності не спрацює з Tuist. Tuist генерує Xcode project тільки на основі описаного маніфесту Project.swift — він не аналізує наявність директиви @_exported всередині самих модулів.
На прикладі нижче, під час генерації проєкту Tuist не додасть UtilsModule як Target Dependency для FeatureModule. Це значить, що Xcode не залінкує UtilsModule до FeatureModule й тоді білд впаде на рядку UtilsModule.anyFunction().
Щоб забезпечити коректне лінкування в XCode, в Tuist-маніфестах Project.swift потрібно описувати абсолютно всі target dependencies, навіть якщо вони реекспортуються іншими модулями. Це робить dependency graph повністю явним, tuist навіть дозволяє візуалізувати його у вигляді схеми. Приклад:
Менеджмент сторонніх залежностей у Tuist має свої нюанси. Більшість бібліотек орієнтовані на стандартні механізми інтеграції — CocoaPods або SPM — і майже ніхто не готує свої SDK спеціально для Tuist. Tuist дозволяє комбінувати різні способи підключення: він підтримує SPM пакети, xcframework та precompiled binaries.
Але якщо стороннє SDK має нетривіальний dependency graph, який порушує принципи його оптимальності, Tuist не зможе згенерувати проєкт, адже він накладає строгі вимоги на граф усіх залежностей, включно з third-party. У таких випадках доводиться шукати обхідні шляхи.
Ми, наприклад, зіткнулися із ситуацією, коли Adjust і Firebase, які використовуються на нашому проєкті, мають спільну транзитивну залежність. При інтеграції через Tuist це призвело до того, що залежність або дублювалася, або навпаки не була видима для одного з фреймворків. Довелося розв’язувати це вручну й контролювати порядок підключення та способи лінкування.
Окремо варто згадати Swift Macros і плагіни. Макроси, реалізовані як compiler plugins, під’єднуються не як звичайні залежності, а через спеціальний механізм компілятора.
Tuist має обмежену підтримку плагінів: їх не можна напряму підключити в Project.swift, а лише як таргет з окремо налаштованого модуля — це незручно налаштовувати і складно дебажити.
Також, якщо використовувати Swift Macros для codegen у проєкті з Tuist, переглянути (expand) згенерований код після збірки не можна. Через ці обмеження ми вирішили відмовитися від Swift Macros і перейти на Sourcery. Хоч це був відчутний крок під час міграції, ми давно планували таке перетворення. Детальніше про недоліки Swift Macros напишемо вже іншим разом.
Вимога 2. Dependency graph проєкту має бути оптимальним
→ SPM
SPM не дозволяє створювати циклічні залежності між пакетами. Якщо два пакети залежать один від одного, проєкт не збереться.
Втім, ця перевірка працює лише в межах локальних SPM-пакетів. Цикли в локальних фреймворках та сторонніх SDK залишаються непоміченими.
SPM знаходить помилки в графі (циклічні залежності, дублікати або фактичну відсутність у проєкті задекларованих залежностей) на етапі компіляції або лінкування. Через це нерідко доводиться проходити цикли build → error → fix → build.
Помилки пакетів, які показує Xcode, не завжди прямо вказують на джерело проблеми. Наприклад, якщо в одному пакеті використовується API іншого пакета, але ця залежність не задекларована в Package.swift, компіляція може пройти успішно, а збій виникне вже на етапі лінкування з абстрактною помилкою undefined symbols, без чіткого пояснення, яких саме символів бракує і в якому пакеті. Тому часто доводиться здогадуватися, що саме пішло не так.
А ось простота створення нових модулів підштовхувала нашу команду постійно робити точкові покращення в модульній архітектурі. Щоб створити новий модуль, достатньо створити нову папку й додати туди файл Package.swift. З переходом на SPM у нашому проєкті з’явилося 19 нових модулів — ми винесли в них окремі частини функціональності з більших модулів. У результаті ця функціональність стала ізольованою, покритою тестами та отримала стабільні контракти.
Втім, у редагуванні Package-файлів у нас час від часу виникають труднощі. Наприклад, Xcode не завжди одразу підхоплює зміни в Package.swift: іноді після редагування доводиться кілька разів зберегти файл (CMD + S), або вручну запустити повторну резолюцію пакетів, щоби API нової залежності стало доступним у пакеті.
Іноді SPM не вдається зарезолвити пакет. Причиною може бути помилка в назві файла, неправильно поставлена кома в самому Package.swift та інші тривіальні речі. У цьому випадку Xcode може тимчасово зламати відповідний таргет і його схема перестає бути доступною для збірки, а файли пакета можуть навіть зникнути з навігатора проєкту. Останнім часом у таких випадках нас виручають
Також SPM має кілька механізмів, які допомагають скорочувати час збірки. Насамперед, пакети SPM за замовчуванням — статичні бібліотеки. Це зменшує витрати часу на динамічне лінкування і запуск застосунку.
Другим важливим механізмом є локальний кеш збірки. Проміжні артефакти компіляції кешуються у DerivedData та перевикористовуються при наступних збірках.
Утім, цей кеш не є контрольованим. Він може інвалідуватися після змін у Package.swift, оновлення версії стороннього SDK або перемикання на іншу гілку. Xcode може вирішити перекомпілювати модулі заново, навіть коли вони мають кеш. Тому при роботі з SPM у розробників періодично виникає розчарування — кеш ніби й має прискорювати білди, але проєкт усе одно постійно збирається заново.
Олександр Бакута,
iOS Engineer Hily→ Tuist
На відміну від SPM, помилки dependency graph у Tuist виявляються ще до компіляції, на етапі генерації Xcode-проєкту.
Після внесення змін у маніфести Project.swift потрібно викликати команду tuist generate. У цей момент Tuist будує dependency graph проєкту. Він не збирає проєкт, а аналізує суто оголошені маніфести — тому ця операція займає декілька секунд.
Якщо під час tuist generate Tuist знаходить помилки в графі, він фейлить генерацію проєкту та виводить опис проблеми у human-readable форматі. Через це з Tuist проблеми в графі виявляються та фіксяться швидше, ніж із чистим SPM.
Однією з ключових переваг Tuist є можливість ефективно скорочувати час збірки проєктів. На відміну від SPM, який дозволяє просто прискорити компіляцію, Tuist зменшує кількість компіляцій, які взагалі потрібно виконувати.
Щоби скористатися Tuist-кешуванням, девелопери викликають команду tuist cache. Вона компілює всі модулі проєкту в бінарні артефакти — повноцінні бібліотеки, що готові до лінкування. Ці артефакти кешуються, і під під час наступних збірок закешовані модулі взагалі не компілюються — тільки лінкуються до готово продукту. Кеш перевикористовується доти, доки не змінюється сам модуль або будь-яка з його залежностей.
Наприклад, модуль Onboarding у нашому проєкті має 12 залежностей: репозиторій, дизайн-система, монетизація, аналітика, локалізація, інтерфейси, ресурси тощо.
Якщо розробник працює з модулем Onboarding без використання кешування, Xcode під час кожного білда компілює як сам модуль, так і всі його залежності (з обмеженими оптимізаціями, як в SPM). У результаті це приблизно 13 компіляцій (12 залежностей + сам модуль) і подальше лінкування.
Але якщо розробник працює в модулі Onboarding після виконання команди tuist cache, залежності модуля заздалегідь збираються в бінарні артефакти та кешуються.
Якщо ж розробник вносить зміни лише у Onboarding, під час наступного білда Xcode компілює тільки цей модуль, а вже скомпільовані залежності просто лінкуються.
Отже, замість того, щоб компілювати ~13 модулів, ми компілюємо лише один.
Команда tuist cache може виконуватися декілька хвилин — вона запускає повну перекомпіляцію всього проєкту, з нуля. Але її не потрібно викликати після кожного tuist generate: якщо dependency graph побудований на основі принципу «Stable Base — Volatile Features», то закешовані один раз артефакти будуть валідними тривалий час — аж до наступної зміни «бази». Тільки після змін у низькорівневих модулях варто заново викликати tuist cache.
Проте, кешування в Tuist не працює як чарівна паличка — цей механізм дасть результати тільки після оптимізації самого dependency graph. Якщо для кожної нової фічі треба вносити зміни в низькорівневі модулі, кеш буде швидко інвалідуватися і проєкт буде все одно перезбиратися заново майже кожен раз. Правильне декомпозування коду на стабільний та волатильний роблять кеш максимально ефективним.
Результати впровадження Tuist Build Cache
Після впровадження Tuist Build Cache ми оцінили його вплив окремо для локальної розробки та CI, оскільки в цих сценаріях кеш поводиться по-різному.
Локальні білди
Без використання Tuist Cache медіана локального білда на нашому проєкті становить 19 секунд. Більшість інкрементальних збірок займають
Головне обмеження Tuist Cache під час локальної розробки полягає в тому, що закешований модуль підключається до проєкту як готова бінарна бібліотека. Його вихідний код більше не доступний у Xcode: не можна переглянути його реалізацію, редагувати її чи дебажити модуль.
Наскільки це критично, напряму залежить від архітектури проєкту. У нашому випадку більшість фіч використовують спільний Domain Layer. Під час розробки нової функціональності доводиться або вносити зміни в цей шар, або щонайменше регулярно переглядати його код. Через це кешувати Domain Layer практично немає сенсу.
А якщо Domain Layer залишається не закешованим, то закешувати залежні від нього feature-модулі вже не вийде. Таким чином більша частина проєкту залишається не закешованою.
Через це обмеження, на нашому проєкті локальний Tuist Cache не став основним сценарієм роботи.
Оптимальним рішенням у такій ситуації є подальша декомпозиція спільного Domain Layer на декілька незалежних feature-specific модулей. Тоді під час роботи над FeatureA розробник змінюватиме лише FeatureA та DomainLayerA, а всі інші модулі будуть підключені, як бінарні артефакти.
Проте, на нашому проєкті локальні збірки і зараз відбуваються достатньо швидко, тож в цьому плані виграш від рефакторинга був би невеликий. З Tuist Build Cache, навіть коли всі модулі закешовані, перша збірка займає приблизно
Проте, є проекти, на яких цей виграш значно помітніший. На одному з проєктів, де я працювала, інкрементальна збірка займала в середньому 10 хвилин. Після міграції на Tuist і з використанням кеша, цей час зменшився до 40 секунд. Таке суттєве покращення може виправдати вартість рефакторинга.
CI
На CI ситуація зовсім інша.
До впровадження Tuist, звичайна збірка білда займала в середньому 10 хвилин, а ран юніт тестів — близько 20 хвилин. Якщо двічі запускати ран на одному і тому самому коміті, кожен прогін займав приблизно однаковий час, оскільки CI щоразу виконував повну перекомпіляцію проєкту.
Після впровадження Tuist Build Cache, перший запуск практично не змінився — кеш ще потрібно прогріти. Натомість, усі наступні збірки можуть перевикористовувати вже скомпільовані модулі.
Якщо між двома запусками не було змін, час збірки скорочується приблизно вдвічі:
- Plain Build — приблизно з 10 до 5 хвилин;
- Unit Tests — приблизно з 20 до 10 хвилин.
На практиці між комітами майже завжди є зміни, тому реальний ефект знову визначається dependency graph.
Якщо зміни торкаються лише верхньорівневих feature-модулів, більшість кешу залишається валідною, і компілюється лише невелика частина проєкту. Якщо ж змінюється низькорівневий Domain Layer, кеш інвалідується для значної частини модулів, і CI майже повністю перекомпілює проєкт.
Після впровадження Tuist приблизно 30% запусків юніт тестів на нашому проєкті виконуються за 10 хвилин — тобто майже вдвічі швидше, ніж раніше. Решта запусків займає від 10 до 20 хвилин, залежно від того, які модулі були змінені.
У деяких випадках час виконання навіть перевищує попередні значення і може досягати 25 хвилин. Це відбувається тоді, коли потрібно перебудувати кеш для великої частини проєкту: окрім самої компіляції, Tuist ще завантажує нові артефакти в Cloud Cache та виконує їх подальше лінкування.
Ми очікуємо покращити ці показники після подальшої декомпозиції Domain Layer та винесення більшої кількості коду з основного application target. Останній завжди компілюється повністю і не кешується, тому його розмір безпосередньо впливає на час збірки.
Cloud Cache
Для використання Build Cache на CI недостатньо локального кешу. Кеш, який створює Tuist, криптографічно прив’язаний до конкретної машини й не може бути використаний на іншій. Оскільки кожен запуск CI виконується на новій машині, для перевикористання кешу необхідно використовувати Tuist Cloud Cache.
На момент написання статті, Tuist безкоштовно надає 200 cache hits на місяць. У нашому проєкті Unit Tests запускаються приблизно
Cloud Cache також дозволяє спільно використовувати кеш між CI та локальними машинами розробників. Ми протестували цей сценарій, але відмовилися від нього, оскільки майже не використовуємо Build Cache під час локальної розробки.
Вимога 3. Усі налаштування проєкту мають бути явними (задекларованими в конфігураційних файлах або коді)
→ SPM
SPM дещо спрощує роботу з конфігураціями порівняно з CocoaPods. У проєкті, побудованому навколо SPM, немає workspace й залишається тільки один .xcodeproj. Модулі описані декларативно у свіфтових Package-файлах, які легко читати та мерджити.
На відміну від CocoaPods, SPM не додає загадкові скрипти у build phases та не вносить хаотичних змін target settings — SPM працює лише в межах декларативних пакетів. У цього є і зворотня сторона: SPM ніяк не полегшує роботу з конфігураціями основного проєкту — це не входить у його зону відповідальності.
Основний .xcodeproj залишається складним
Олександр Бакута,
iOS Engineer Hily→ Tuist
Як уже було згадано вище, у Tuist конфігурація проєкту повністю прописана в декларативних Swift-маніфестах. Це стосується і залежностей модулів, і конкретних продуктів, і налаштувань таргетів, схем, Build Settings та Build Phases.
Усі налаштування проєкту в Tuist легко читаються, і зміни в них зрозумілі іншим розробникам під час code review. Внести випадкові зміни практично неможливо: редагування можливе лише через команду tuist edit, яка відкриває окремий проєкт зі всіма маніфестами. Після внесення змін у маніфести, проєкт потрібно заново згенерувати командою tuist generate, і лише тоді оновлений workspace стане доступним у Xcode.
Будь-які зміни, зроблені через GUI Xcode поза маніфестами, перезапишуться під час наступної генерації. Завдяки цьому .xcodeproj та .xcworkspace можна відправляти в .gitignore, що практично усуває джерела merge-конфліктів.
З Tuist весь проєкт має централізований source of truth, де видно налаштування та архітектурні зв’язки, і вносити зміни можна контрольовано та передбачувано.
Крім того, Tuist дозволяє створювати спільні helper-функції для повторного використання конфігурацій між модулями. Приклад такої функції — helper для групування залежностей, котра часто використовується:
Це дозволяє уникнути дублювання коду при створенні нових модулів. В SPM таке зробити неможливо, адже кожен Package.swift ізольований і не має доступу якихось спільних функцій.
У Tuist весь dependency graph зберігається в центральному маніфесті, що робить його повністю видимим і контрольованим. Завдяки цьому девелопери точно знають, які модулі і версії сторонніх бібліотек використовуються в проєкті, і зміни в графі легко відстежувати.
У SPM кожен Package.swift визначає версії своїх залежностей окремо. Якщо серед версій одного й того самого SDK, зазначених у модулях, є розбіжності, то SPM на свій розсуд резолвить версію, яка підійде всім. Іноді це може призвести до випадкового оновлення залежності. Ця зміна може залишитися непоміченою і раптово проявитися на проді.
Наприклад, у нас колись незаплановано оновився SDK для сокетів, і ми дізналися про це лише після того релізу, коли побачили, що сокети перестали доходити. Але Tuist вирішує цю проблему: усі сторонні залежності і їх версії визначаються в одному маніфесті Dependencies.swift. Усі модулі користуються однаковим набором версій, і це добре видно в коді.
Висновок
Обидва інструменти мають свої сильні сторони й чудово справляються зі своїми завданнями.
SPM добре допомагає з керуванням пакетами, організувати модульну структуру та прискорює збірки завдяки інкрементальній компіляції. Це нативний інструмент, тому він інтегрується швидко й майже не створює додаткового оверхеду в проєкті. Для невеликих і середніх застосунків цього може бути цілком достатньо.
Однак Tuist корисніший для організації великих модульних проєктів. Він додає їм додатковий рівень контролю та прозорості: весь dependency graph стає наочним і централізовано керованим. Завдяки ньому версії сторонніх бібліотек зафіксовані, а зміни в структурі чи конфігураціях проєкту відбуваються через Swift-маніфести, які легко читати, аналізувати та переглядати під час code review. Декларативність маніфестів значно знижує ризик випадкових або непомітних змін у конфігурації проєкту, структурі модулів чи версіях залежностей.
Ще одна сильна сторона Tuist — можливість значно скоротити час збірки завдяки Build Cache. Але наш досвід показав, що його ефективність напряму залежить від якості dependency graph. На нашому проєкті локальна розробка майже не виграла від кешування, оскільки звичайний кеш Xcode вже забезпечує дуже швидкі інкрементальні збірки, а використання бінарних модулів накладає певні обмеження на навігацію та дебаг. Натомість, на CI ми побачили відчутний результат: приблизно 30% запусків Unit Tests тепер виконуються майже вдвічі швидше — близько 10 хвилин замість 20.
Головним висновком після впровадження Tuist стало розуміння, що Build Cache посилює лише вже добре спроектовану архітектуру. Чим менше зайвих залежностей, і чим чіткіше розділені стабільні та волатильні модулі, тим більший ефект він дає. У цьому сенсі Tuist мотивує регулярно переглядати архітектуру проєкту та робити dependency graph оптимальнішим.
Наразі ми дуже задоволені досвідом використання Tuist, плануємо й надалі працювати з ним і розкривати інші його можливості.
Якщо у вас з’явилися запитання по ходу статті — пишіть, ми з Олександром будемо раді на них відповісти :)
Олександр Бакута,


6 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівдякую за статтю, дуже корисно
якщо можна, хотів би перепитати у вас
— який саме підхід ви використовували при переході, поетапний чи одразу весь проєкт?
— чи можна підтримувати стару та нову інфраструктуру паралельно?
— як щодо делівері нових фітч, паралельно тому поки був перехід?
Дякую за відгук!
При обох міграціях переводили одразу весь проєкт в окремих гілках.
Можна! Подси дозволяють під’єднувати SPM-пакети, а з Tuist головне спочатку перевести xcodeproj у декларативний вигляд, а потім можна всі існуючі модулі під’єднати пакетами, і переносити їх по одному.
Якщо робити міграцію поступово, то взагалі нема проблем. Оскільки ми робили міграцію одразу всього проєкту, то доводилось регулярно підмерджувати собі актуальний девелоп) Загалом конфліктів було не так багато, бо майже всі зміни при міграції відбувалися в конфігураційних файлах, а не в swift-коді.
дякую вам!
насправді ще купа проектів на подсах і це дуже круто що ви поділились своїм досвідом
Дякую вам за відгук)
стаття цікава з точки зору нового менеджера пакетів Tuist, про який я не знав, поцікавлюсь чи дає він переваги для розробки бекенду на лінукс. Але дійсно дивно, звісно субєктивно, дуже дивно, що в когось ще є кокоаподс залежності. Я перейшов на SPM в довоєнні часи, коли його ще навіть в хкоді нативно не було і тут, як то кажуть, в 2026 стаття «як ми прибрали кокоаподс» 🙂 я був дуже здивований, що проект тягнув кокоаподс стільки часу
дякую за відгук! не впевнена, що Tuist допоможе з розробкою бекенда на лінуксі, бо він в першу чергу генерує Xcode проєкти, а Xcode на лінуксі нема 😅. Можливо, є аналогічні інструменти для таких цілей)