Это — репозиторий с примером имплементации схемы build-cache для проектов использующих Swift Package Manager, которая была обсуждена на докладе 'SwiftPM — фреймворки вместо кофе' на 'Mobius 2024 Spring'. Используйте его как беклинк на некоторые главы с доклада, если вам интересна конкретная глава.
Это — реконструкция нашего проекта до внедрения похожего решения. Проект генерируется туистом, а сама разработка ведется в фичевых SPM пакетах + паре пакетов нижнего уровня (Core
, UI
), которые утилизируются фичевыми.
Делаем наш пакет с пакетами, зависимости которого мы в будущем и будем кешировать.
Почему так? Для того, чтобы SPM пакет сожрал бинарный артефакт, его нужно указать как .binaryTarget
и, собственно, вкинуть его в зависимости того таргета, с которым мы работаем. В нашем случае фичевых пакетов не один и не два, как и зависимостей, поэтому проще всего будет создать один новый пакет, который не будет содержать абсолютно никакого кода и будет чисто заглушкой для указания всех наших .binaryTarget
-ов. Если подключить такой пакет в один самый низкоуровневый модуль, например, кору приложения — то SPM в фичевых пакетах автоматом зарезолвит оттуда все нужные бинари. Альтернативный вариант — генерируемые бинарные артефакт прибить гвоздями в нужных местах, но тогда генерация уходит на второй план и при апдейте версий бинари придется переподключать.
Создаем пустой пакет и добавляем его в родительский конфиг генерации проекта, чтобы он был виден в сурсах. В него переносим все зависимости из Core
и UI
пакетов и подключаем его к ним же в обратном порядке. Если запустить проект то будет видно, что все заводится и зависимости в фичевых модулях транзитивно резолвятся.
Бинарные артефакты нужно чем то собрать. Заниматься этим будет тот же бинарник Tuist`a, который используется для генерации проекта — правда, в другом месте и с другим конфигом, чтобы разделять сладкое от соленого.
Создаем Project.swift
файл внутри скрытой папки нашего пакета с пакетами и копируем в него манифест зависимостей — их Tuist и будет чекаутить и собирать. Отдельный проект нам нужен для того, чтобы указать в нем наши сторонние зависимости из Package.swift
манифеста и Tuist начал их прогревать — это “теневой клон” нашего пакета с пакетами.
Если сейчас вызвать на этот под-проект команды
tuist install
tuist generate —no-binary-cache
то увидим там наш граф зависимостей, который и будет билдиться — с этим проектом мы играемся когда Tuist по какой то причине не может закешировать тот или иной фреймворк. Сама CLI не особо любит конкретизировать ошибки, с которыми падают ее команды, но это обычный Xcode проект и он спокойно скажет, что конкретно упало в резолве того или иного пакета.
Ну и вызовом tuist cache
уже в целом можно получить какие то фреймворки. По дефолту они ложатся в <ваш-юзер-нейм>/.cache/tuist-cloud/BinaryCache
и в целом можно даже попробовать копирнуть путь до нее и указать как .binaryTarget
в пакете, который сейчас висит в проекте — но пакеты не смогут зарезолвиться, так как локальному бинарному таргету нужно находится в саб-директории от пакета, который его пытается подключить.
Давайте фиксить резолв! Надо запаковать логику как прогрева, так и записи куда нибудь в одно место — делать это руками в какой то саб-директории просто больно, да и задокументировать такой флоу довольно проблематично.
Создаем два файла — один для генерации бинарных артефактов и второй для генерации манифеста с бинарными таргетами, который мы будем подключать в сам проект и его фичевые пакеты.
В файле warm.py
вызываем команды на генерацию бинарных артефактов tuist install & tuist generate —no-binary-cache
— больше в нем особо ничего не происходит.
В файле write.py
проходимся grep
-ом по папке с нашими бинарями <ваш-юзер-нейм>/.cache/tuist-cloud/BinaryCache
, копируем их в саб-директорию от пакета чтобы он смог зарезолвиться и генерируем манифест, который их подключает. Его мы вытаскиваем наружу и используем как манифест того пакета, который подключается в самом приложении.
В самом init.py
в корне проекта зашиваем вызов этих двух файлов. В целом, если сейчас вызвать init.py
в корне проекта, то все должно собраться — с зависимостями в виде бинарных артефактов. Но это до первой попытки обновить зависимости — если попробовать поменять версию, например, Alamofire, то проект перестанет собираться.
Если открыть папку с кешами и походить по ней, то можно будет заметить 2 версии Alamofire.xcframework
— Tuist себе ее сбилдил при ее обновлении. Внутри команды кеширования он компьютит версию зависимости, чтобы понять, нужно ли ему перебилдить тот или иной фреймворк,и если нужно (то есть если он не находит фреймворк под нужным хешом) — кладет новую версию прям рядом со старой. Дальше фреймворк из под нужного хеша он сможет прилинковать в таргет генерируемого проекта.
Однако мы так не можем — по крайней мере в подходе, где мы просто grep
-аем все что в папке.
Так как мы берем ВСЕ, не разбираясь в этих всех хешах, версиях и дубликатах — при попытке пройтись по папке и сгенерировать со всех вреймворков в ней какой либо манифест разумеется вылезли криты билда по дубликатам фреймворков, и даже если их обойти — непонятно нужной ли версии бинарь был взят — в одной папке могут жить под разными хешами как минорная 1.2, так и мажорная 2.0.
Снаружи пытаться считать хэши зависимостей сложнее — хотя и возможно — так что мы возьмем вариант попроще: будем считать хеш сумму нашего манифеста с зависимостями и класть все что варит туист в папку с названием в эту сумму. Так у нас будут актуальные фреймворки в едином экземпляре под конкретный манифест, по которым мы точно так же сможем бегать grep
-ом.
Tuist все свои кеши кидает в папку XDG_CACHE_HOME
— это по дефолту та самая <ваш-юзер-нейм>/.cache
— и если ее подменить в окружении, в котором выполняется вызов команд, то использоваться будет ваша папка. В коммите правим папку на нашу посчитанную и греем в нее все, что нужно при необходимости.
Теперь при попытке обновления какой-либо сторонней зависимости дубликатам будет взяться неоткуда, так как генерироваться все фреймворки будут заново и в другом месте. Этот вариант решения не без объективных (и довольно больших) минусов — на любой чих в виде даже комментария в Package.swift
файле все будет перегенерированно, что отнимает не только время на прогонах локально/на CI, но и банально место на тачке — в случае нашего проекта одна чистая папка фреймворков занимает ~4 gb. Но он простой, работает даже в таком формате и в целом может быть подменен на решение с использованием tuist graph
& tuist cache --print-hashes
.
Не забудем про бандлы — если раньше все ресурсы из подтягиваемых пакетов отруливались SPM-ом, то в рамках генерации статического фреймворка (которыми все spm пакеты и являются по дефолту в туисте) он создает отдельный таргет с бандлом, который по итогу будет сбилжен и подцеплен в ваш таргет. Но есть нюанс — туистом для генерации мы не пользуемся, и подключить бандл тупо некуда.
Еще есть момент, что эти самые бандлы он будет билдить не при кешировании — а при билде ваших таргетов. При запуске команды принта хешей она даже высветит название банда среди близящихся таргетов (на скрине — таргет Лотти, и таргет для ее бандла — с андерскором), и даже сбилдит его — но после попросту удалит его. Для проверки вызовите tuist cache —print-hashes
.
Как вариант — так как командой Tuist'a подразумевается, что бандл мы сбилдим при билде таргета, который он использует, то можно просто сделать “теневую” схему, которая будет билдить этот самый бандл и вызывать ее билд при генерации бинарных артефактов. В пост-билд скрипт закидываем пару строк кода, которые пробегутся по билд-папке (DerivedData) сбилженной схемы и копирнут оттуда все бандлы, которые нам нужны, а дальше этот бандл можно подключить любым удобным способом в проект — я просто копирую его при генерации манифеста, а позже подключаю в Project.swift
файле, который генерирует основной проект, как обычный ресурс.
Для проверки работы очистите папку кешей на вашей машинке, если вы запускали комиты выше, и вызовите инит файл в корне проекта.
В своей документации по использованию кэша бинарных артефактов ребята-разработчики Tuist рекомендуют использовать данный подход только для ускорения процессов на CI, а сборку на архивацию (проще — релизную) собирать исключительно из исходников. (Источник: https://docs.tuist.io/cloud/binary-caching.html#using-the-cache-binaries)
В дополнению к рекомендациям из документации могу добавить, что вести разработку с использованием пакетов сурсами в некоторых моментах может быть просто удобнее — в бинарном артефакте особо брейкпоинтами не полазаешь& Плюсом к этому — если используемое стороннее решение в виде скриптов, которые прогревают бинари, решает по то или иной причине перестать работать — наличие возможности пропустить эти скрипты и собраться из исходников при любом чихе позволит не терять рабочие часы в случае необходимости что-то срочно чинить/релизить.
Схема работы в нашем случае довольно простая — ловим аргумент в файлах генерации, который при наличии будет все пропускать и копировать внутренний манифест зависимостей наружу. Таким образом получим в виде подключаемого пакета в самом проекте обычный Package.swift
файл, который был сделан во втором коммите.