Спойлер: сократили размер приложений на 44% и ускорили их запуск в среднем на 51%.

Привет, Хабр! Меня зовут Владислав Сединкин, я занимаюсь iOS 10 лет и последние 2 года работаю в мобильной Core-команде Туту. В этом году мы решили сменить менеджер зависимостей и мигрировать с CocoaPods на SPM. Результаты оправдали ожидания, хотя и сложности на этом пути, конечно, были. В статье расскажу про проблемы, с которыми мы столкнулись в процессе, и предупрежу, на что стоит обратить внимание, чтобы не повторить наши ошибки.
Почему мы решили мигрировать на SPM
Некоторые зависимости CocoaPods стало тяжело поддерживать. Наша основная архитектурная библиотека — TCA, и в ней нет поддержки CocoaPods. До определенного момента мы добавляли поддержку самостоятельно, но со временем это стало доставлять неудобства.
Мы хотели использовать единый менеджер зависимостей для всех продуктов. Так получилось, что какие-то продукты использовали CocoaPods, какие-то — SPM, где-то это вообще миксовалось. Пришла пора упорядочить хаос.
Мы планировать избавиться от сторонних инфраструктурных зависимостей — например, от Ruby.
Немаловажная причина — CocoaPods вошли в «режим поддержки», а SPM живет и активно развивается.
Ну и захотелось просто быть в тренде :)
Что было на старте
Основные наши проекты — четыре публикуемых в стор приложения — находятся в одном монорепозитории. Это наш суперапп Туту и три моноприложения: ЖД, Авиа и Автобусы. Совокупно на этот монорепозиторий приходится порядка 100 таргетов, 80+ внешних зависимостей, из которых 15 — наши внутренние продукты. По кодовой базе порядка 90% — Swift, оставшиеся 10% — Objective-C. И, конечно, служебные файлы: IB-файлы, CoreData, .xcasserts, .lproj и другие.
Мы распланировали процесс миграции. В итоге он состоял из четырех шагов.
Сначала добавили поддержку SPM для наших внутренних продуктов и для внешних фреймворков. Это была не самая трудозатратная часть.
Сформировали видение, как у нас будет построен Package.swift-файл, где настраивается и форматируется большое количество зависимостей.
Последовательно обновили все таргеты и приложения. Этот этап оказался самым долгим и проблемным.
Обновили инфраструкту CI/CD: настройка тестов, деплоя, импакт-анализа и т.д.

Сейчас у нас нет CocoaPods-зависимостей — только SPM-зависимости. В воркспейсе находится локальный пакет, в котором формируются как локальные зависимости, так и внешние фреймворки. Также есть набор .xcodeproj-файлов, которые представляют сами наши приложения.
Проблемы, с которыми мы столкнулись в процессе миграции
Поделюсь проблемами, с которыми мы столкнулись, и расскажу, какие решения нашли.
SPM требуется большой объем дискового пространства
Особенно когда у вас большое количество SPM-зависимостей. SPM загружает полный репозиторий с историей git и файлами для каждой зависимости. Так, например, мы заметили, что папка .build весит 16 ГБ, двенадцать из которых занимает папка repositories, содержащая полные git-репозитории для всех зависимостей.

Решение
Использовать Binary-фреймворки вместо обычных исходников. Замечу, что у этого метода есть недостатки: например, проблема с дебагом.
Есть Swift Package Registry, но для него нужно свое хранилище, куда вы будете складывать артефакты.
Можно очищать историю git от больших файлов. Но этот вариант вряд ли поможет с внешними зависимостями.
Мы выбрали второй вариант и сейчас переходим на Swift Package Registry с хранением в нашем корпоративном хранилище.
Плагин buildCommand замедляет время компиляции
Следующая проблема касается времени компиляции и влияет на developer experience. Когда таргет создается в Xcode, у нас есть build-фазы, в которых мы можем запускать скрипты. В SPM есть что-то похожее — это build-плагин, который запускается до или во время билда. Как раз в него мы планировали перенести запуск SwiftLint. Но при использовании этого плагина большое количество времени уходит на шаг apply build to
plug-in. Сама работа линтера может происходить за десятые доли секунды, тогда как шаг apply build to
занимает достаточное количество секунд. Это замедляет процесс билда. На «горячем» билде, который должен происходить за пару секунд, это ощущается.
Решение
Использовать git hooks вместо build-плагинов. Например, SwiftLint можно перенести в pre-commit hook, который будет запускаться каждый раз, когда разработчик будет пытаться сделать commit или push в репозиторий.
Обратиться к command-плагинам, которые позволяют запускать код в ручном режиме.
Мы используем оба этих способа.
Нельзя использовать Swift и C-подобные языки вместе в одном таргете
В Xcode-фреймворках часто может находиться и Swift, и Objective-C код. Это работает без проблем. Но SPM такую опцию не поддерживает, если вы попробуете миксовать языки, то увидите это:

Решение
Перенести C-подобный код в отдельный таргет и настраивать его отдельно. При настройке нужно включить аргумент publicHeadersPath
, указав путь до публичных заголовочных файлов, которые будут использоваться другими таргетами.
Второй аргумент — cSettings
. В нём указываются пути до внутренних заголовочных файлов. Они будут использоваться внутри фреймворка.
.target(
name: targetName,
dependencies: [Target.Dependency].aviaObjcLegacy,
path: "Avia/" + targetName,
publicHeadersPath: "include",
cSettings: [
.headerSearchPath("Api/"),
.headerSearchPath("Common/"),
.headerSearchPath("include")
]
)
Нельзя использовать unsafeFlags
В документации SPM указано, что в пакете, публикуемом вовне, мы не можем использовать unsafeFlags
, потому что это небезопасно.
Решение
Не использовать unsafeFlags в тех фрейморках, которые не являются конечным продуктом. Но если очень хочется их задействовать, можно заменить семантическое версионирование и забирать фреймворк через коммит или ветку.
Не каждый ресурс доступен по умолчанию в SPM-таргете
Автоматически распознаются ресурсы:
IB
CoreData
Asset catalogs
.lproj
Но если добавить другой тип ресурса, то Bundle.module
(SWIFTPM_MODULE_BUNDLE
) будет недоступен по умолчанию.
Решение
Отдельным аргументом в настройке таргета нужно добавить пути к ресурсам, которые нас интересуют.
.target(
name: targetName,
dependencies: [Target.Dependency].train,
path: “Train/” + targetName,
resources: [
.process(“Fakes/FakeData”),
.process(“CountriesProvider/Plists”),
]
)
После этого будет создан bundle-модуль, с помощью которого вы сможете обращаться к ресурсам.
extension Foundation.Bundle {
static let module: Bundle = {
let bundleName = "TargetName"
let bundleFinderResourceURL = Bundle(
for: BundleFinder.self
).resourceURL
var candidates = [
Bundle.main.resourceURL,
bundleFinderResourceURL,
Bundle.main.bundleURL,
]
...
for candidate in candidates {...}
fatalError("unable to find bundle named…")
}()
}
Но с ресурсами стоит быть аккуратными.
С виду безобидное расширение во вспомогательном таргете:
public extension UITableView {
func register<T: UITableViewCell>(_: T.Type, bundle: Bundle? = nil) {
let bundle = Bundle.module
let nib = UINib(nibName: T.nibName, bundle: bundle)
register(nib, forCellReuseIdentifier: T.reuseIdentifier)
}
}
Может привести к рантайм-крашу:

Вызывать Bundle.module
для доступа к ресурсам можно только в том случае, когда этот ресурс есть в бандле.
И еще одна безобидная «мелочь»: галочка в .xib-файле при переезде на SPM:

Приведет к тому же рантайм-крашу.

Чтобы это исправить, нужно явно прописать модуль для IB-файла, убрав галку Inherit Module From Target. Если файлов много и вручную проходиться по ним не хочется, то можно написать простой скрипт, который сделает это автоматически.
Проверьте настройки и описание модели, чтобы не получить рантайм-краш с CoreData-файлами. Удостоверьтесь, что в настройках модели в графе Module = Global namespace, Codegen = Manual / None.

Если установлен Codegen в ручном режиме, то проверьте наличие атрибута objc.
@objc(Model)
public class Model: NSManagedObject {
…
}
Подходы, которые упростят переезд
А теперь расскажу про подходы, которые помогут упростить миграцию и ускорить процесс переезда.
Декомпозируйте Package.swift, чтобы файл не разрастался до больших размеров
При переводе проекта на SPM файл Package.swift может стать достаточно большим. У нас он занимает около полутора тысяч строк.
let package = Package(
name: "PackageName",
defaultLocalization: "ru",
platforms: [.iOS(.v16)],
products: [
.library(name: "Target1", targets: ["Target1"]),
...,
.library(name: "Target10", targets: ["Target100"]),
],
dependencies: [
.package(url: "https://github.com/foo/bar", from: "1.0.0"),
...
],
targets: [
.target(name: "Target1", dependencies: [...], resources: [...]),
...,
.target(name: "Target100", dependencies: [...], resources: [...])
]
)
Если использовать только один инициализатор, будет сложно компилировать этот файл.
Для декомпозиции файла выносим продукты в отдельное свойство.
enum PTTEntity: String, CaseIterable {...}
let package = Package(
name: "PackageName",
defaultLocalization: "ru",
platforms: [.iOS(.v16)],
products: PTTEntity.allProducts,
dependencies: [
.package(url: "https://github.com/foo/bar", from: "1.0.0"),
...
],
targets: [
.target(name: "Target1", dependencies: [...], resources: [...]),
...,
.target(name: "Target100", dependencies: [...], resources: [...])
]
)
extension PTTEntity {
var allProducts: [Product] {...}
}
Так же поступаем и с зависимостями — выносим в отдельную функцию.
enum PTTEntity: String, CaseIterable {...}
let package = Package(
name: "PackageName",
defaultLocalization: "ru",
platforms: [.iOS(.v16)],
products: PTTEntity.allProducts,
dependencies: .allDependencies,
targets: [
.target(name: "Target1", dependencies: [...], resources: [...]),
...,
.target(name: "Target100", dependencies: [...], resources: [...])
]
)
extension PTTEntity {
var allProducts: [Product] {...}
}
extension Array where Element == Package.Dependency {
static var allDependencies: [Element] {...}
}
И выносим таргеты.
enum PTTEntity: String, CaseIterable {...}
let package = Package(
name: "PackageName",
defaultLocalization: "ru",
platforms: [.iOS(.v16)],
products: PTTEntity.allProducts,
dependencies: .allDependencies,
targets: PTTEntity.allCases.map(\.target)
)
extension PTTEntity {
var allProducts: [Product] {...}
var target: Target {...}
}
extension Array where Element == Package.Dependency {
static var allDependencies: [Element] {...}
}
Тогда инициализатор пакета останется коротким и простым, а вся работа декомпозируется на отдельные функции
Явно указывайте типы в Package.swift — это сильно облегчит жизнь компилятору
В противном случае есть риск получить ошибку — компилятор не сможет понять тип выражения.
dependencies: [
.product(name: “Name”, package: “Package”)
Target.Dependency.product(name: “Name”, package: “Package”)
]
При переводе существующего таргета с Xcode Framework на SPM могут появиться ошибки из-за того, что компилятор не понимает тип
Это происходит потому, что в SPM не поддерживаются umbrella header.

Чтобы решить эту проблему, достаточно добавить файл на таргет. В нем нужно прописать импорты через атрибут @_exported.

Еще одна особенность — импорт Objective-C кода
Способ импорта зависит от того, как располагаются заголовочные файлы в папке include.

Если вы кладете публичный заголовочный файл в корень, то использование в коде будет происходить через импорт и кавычки. Если положить заголовочный файл во вложенную папку и в папку include, использование будет в угловых скобках.
Результаты и метрики
За 3 месяца мы перевели 4 приложения на SPM.
Сократили размер приложений на 44%.
Бинарный файл весил 40 Мб, а стал весить 100 Мб, зато размер папки Frameworks Туту уменьшился с 261 Мб до 11 Мб. По умолчанию SPM линкует все статически, благодаря этому происходит оптимизация компилятора.
Сократили время запуска на 51%.
На старте один Map Image занимал 440 мс.
Что такое Map Image
Map Image — отображение сегментов Mach-O-образа (фреймворка или приложения) в виртуальное адресное пространство процесса.
Apply Fixups + Building Closure дает еще 982 мс.
Что такое Apply Fixups и Building Closure
Apply Fixups — корректировка указателей памяти (rebase + bind).
Building Closure — сборка и кэширование структуры зависимостей и fixups.
После переезда на SPM количество сегментов Map Image сократилось со 146 до 5 — по времени это 440 мс против 36 мс. В случае с Apply Fixups и Building Closure мы сократили время с 982 мс до 196 мс и уменьшили время запуска примерно на секунду.

В результате мы:
отказались от CocoaPods во всех приложениях;
обеспечили быстрый и стабильный бамп наших зависимостей (отказ от pod lint);
уменьшили и ускорили приложения.
Но не все так радужно, есть и негативные моменты:
теперь Xcode запускается дольше из-за того, что при запуске проекта SPM резолвит весь граф зависимостей;
покушение на место на диске.
Вместо заключения
Поделюсь финальными краткими рекомендациями.
Если можете использовать Package Registry — используйте.
Отдавайте предпочтение command-плагинам и гит-хукам вместо build-плагинов, чтобы не давать нагрузку на горячие билды приложения.
Чтобы работать с С-подобным кодом, нужно выносить его в отдельный таргет.
Не используйте
unsafeFlags
для внешних пакетов. Если очень нужно, используйте не семантическое версионирование, а получение зависимостей через commit либо ветку.Внимательно и осторожно работайте с IB-файлами и CoreData, чтобы избежать неожиданных крашей в рантайме.
Оптимизируйте Package.swift для приемлемой работы с ним.
Используйте
@_exported import
, чтобы не приходилось импортировать foundations в каждый файл вашего таргета.Осмысленно используйте папку include для C-файлов, чтобы не пришлось тратить лишнее время на корректировку импортов объектов C-кода.
Эти рекомендации помогут обновить ваши приложения. Даже небольшой командой. Даже из одного человека, как это сделали мы.
Буду рад ответить на вопросы в комментариях!
MeGaPk
Немного критики на использование SPM'a локально:
Начиная с Xcode 26 во всем проекте теперь есть ограничение на ~1к таргетов SPM локальных. Иначе будет креш.
Так же если много таргетов, то Clean Code должно отрабатывает (до 1-ой минуты)
Ну и вкусенькое - переименование (через рефакторинг) то работает, то нет.