Target описывает один продукт сборки (приложение, фреймворк, тестовый бандл), build configuration представляет собой именованный набор build settings вроде Debug или Release, а scheme говорит, какие таргеты собирает каждое действие (Run, Test, Profile, Archive) и с какой конфигурацией.
Configuration: одна колонка настроек, общая для всех таргетов. Флейвор Staging означает новую конфигурацию плюс xcconfig, почти никогда не дублированный таргет, который потом приходится держать в синхроне.
Scheme: своих build settings не имеет; хранит аргументы запуска, переменные окружения и test plans, и они действуют, только когда приложение запускает Xcode.
**.xcodeproj**: один проект: таргеты, ссылки на файлы и настройки в project.pbxproj.
**.xcworkspace**: контейнер, который открывает несколько проектов и пакетов вместе, чтобы их таргеты могли зависеть друг от друга. CocoaPods генерирует его вокруг вашего проекта.
Классическая ловушка: схемы по умолчанию персональные и лежат в xcuserdata/. Пока кто-нибудь не поставит галочку Shared, которая переносит файл в xcshareddata/xcschemes/, CI падает с «scheme not found». xcshareddata держат в git, а xcuserdata добавляют в .gitignore.
iOSSenior
Сборка Xcode и модуляризация
Таргеты, схемы, xcconfig, SPM, статическая и динамическая линковка, XCFramework, время сборки
Закрыто вопросов
0 / 22
- 0Повторить
- 1Часто спрашивают
Вопросы
Берите самую старую версию iOS, на которой ещё сидит заметная доля ваших пользователей (обычно текущий мажорный релиз минус один-два), а новые API вызывайте за проверками if #available и аннотациями @available.
Смотрите на свою аналитику, а не только на график распространения от Apple: доля ваших сессий на каждой версии iOS и есть ровно то, что вы теряете, поднимая таргет.
Низкий таргет стоит времени разработки (проверка доступности и запасной путь на каждый новый API, плюс тестирование старой ОС); высокий стоит пользователей, которые остаются на последней совместимой версии без обновлений.
Пакеты задают нижнюю границу: зависимость, у которой platforms: выше вашего таргета, падает с «The package product 'X' requires minimum platform version 17.0 for the iOS platform, but this target supports 16.0», так что решает самый медленный модуль.
#available выполняет проверку в рантайме; @available задаёт контракт на этапе компиляции, который заставляет вызывающий код делать проверку.
Сертификат доказывает, кто подписал приложение, entitlements перечисляют возможности, которые приложение заявляет (push, App Groups, общий Keychain), а provisioning profile представляет собой подписанное Apple разрешение, связывающее App ID, сертификаты, entitlements и (для разработки и Ad Hoc) конкретные устройства.
Сертификат: пара ключей: приватный ключ лежит в вашем Keychain, публичную половину подписывает Apple. Development-сертификаты подписывают сборки для ваших устройств, а distribution-сертификаты подписывают сборки для TestFlight, App Store и Ad Hoc. Без приватного ключа сертификат бесполезен.
App ID: регистрация вашего bundle ID (com.example.app) в developer-портале с префиксом team ID; к нему привязаны включённые возможности.
Entitlements: plist, вшитый в бинарник при подписи. Каждый entitlement должен быть разрешён профилем, иначе установку или загрузку отклонят.
Provisioning profile: встраивается в приложение как embedded.mobileprovision; бывает development, Ad Hoc, App Store и Enterprise.
При установке iOS проверяет подпись, наличие сертификата и устройства в профиле и соответствие entitlements профилю.
CocoaPods генерирует workspace и собирает поды из центрального репозитория спек, Carthage только собирает фреймворки, которые вы подключаете сами, а SPM является собственным менеджером зависимостей Apple, встроенным в Swift и Xcode: без отдельного инструмента и без сгенерированного workspace.
CocoaPods: Ruby-гем с Podfile и Podfile.lock. pod install переписывает ваш проект и добавляет свои xcconfig. Огромный каталог, но это вторая система сборки, которую нужно поддерживать. Мейнтейнеры объявили, что trunk-репозиторий спек станет read-only в декабре 2026 года, после этого новые версии подов публиковаться не будут.
Carthage: собирает XCFramework, а линковку оставляет вам; вручную и почти не поддерживается.
SPM: Package.swift плюс Package.resolved, интеграция в Xcode. Поддерживает ресурсы, бинарные таргеты (XCFramework), build-плагины и макросы, причём макросы распространяются только пакетами.
Почему перешли: на каждом Mac и CI-раннере на один инструмент меньше, нативная поддержка в Xcode, локальные пакеты заодно служат инструментом модуляризации, и вендоры SDK теперь выпускают SPM первым. CocoaPods в основном остаётся в легаси-приложениях и React Native-проектах.
В Debug assert не проходит, и приложение падает с «Assertion failed: id is required»; в Release assert вырезан при компиляции, а блока #if DEBUG не существует, поэтому вызов молча печатает item--1.
Чем отличаются две конфигурации
Оптимизация: SWIFT_OPTIMIZATION_LEVEL равен -Onone в Debug и -O в Release. assert и assertionFailure проверяются только в -Onone; precondition переживает -O (его убирает только -Ounchecked); fatalError срабатывает в любом режиме, кроме -Ounchecked, где оптимизатор вправе считать, что до него не дойдут.
**#if DEBUG**: работает, потому что новые проекты кладут DEBUG в SWIFT_ACTIVE_COMPILATION_CONDITIONS только для Debug. Это этап компиляции: в Release-бинарнике этого кода нет вообще.
Режим компиляции: Debug компилирует инкрементально; Release использует wholemodule и оптимизирует сквозь файлы. Поэтому Release собирается дольше, а работает быстрее.
Отладочная информация: DEBUG_INFORMATION_FORMAT равен dwarf в Debug и dwarf-with-dsym в Release, где появляется dSYM, по которому символицируются краш-репорты.
Тестируемость: ENABLE_TESTABILITY = YES в Debug позволяет @testable import видеть internal-символы.
Никогда не проверяйте реальный ввод через assert: в сборке, которую запускают пользователи, его нет. Используйте precondition или нормальную обработку ошибок и проверяйте Release-сборку перед релизом, потому что баги, проявляющиеся только с оптимизатором, и отсутствующий отладочный код видны только там.
.xcconfig представляет собой текстовый файл с build settings, привязанный к конфигурации: настройки живут в строках, которые можно ревьюить и мержить, а не внутри project.pbxproj, который Xcode переписывает как машинный блоб и на котором две ветки обязательно сконфликтуют.
Порядок вычисления
От низшего к высшему, побеждает последний: значения по умолчанию Xcode → xcconfig проекта → build settings проекта → xcconfig таргета → build settings таргета → xcodebuild SETTING=value в командной строке.
Что нужно знать
**$(inherited)** подставляет значение с уровня ниже. Для списочных настроек (OTHER_LDFLAGS, HEADER_SEARCH_PATHS, SWIFT_ACTIVE_COMPILATION_CONDITIONS) забытый $(inherited) молча выбрасывает флаги, заданные ниже (например, флаги линковщика от CocoaPods), и сборка падает на линковке с undefined symbols, далеко от строки, которую вы правили.
**#include** подключает общий базовый файл; #include? переживает отсутствие файла, что удобно для персональных переопределений, лежащих в .gitignore.
Условные настройки вроде OTHER_SWIFT_FLAGS[sdk=iphonesimulator*] заменяют форк таргета.
**// начинает комментарий, поэтому значение-URL обрежется; храните хост, а URL собирайте в коде.
Типичная ловушка**: значение, оставшееся во вкладке Build Settings таргета (выделено жирным), перебивает xcconfig. «Мой xcconfig игнорируется» почти всегда означает именно это. Проверяйте через xcodebuild -showBuildSettings, а не перечитыванием файла.
Типичная раскладка: один Base.xcconfig, по файлу на конфигурацию, который его подключает, и пустая вкладка Build Settings.
Система сборки Xcode превращает таргеты в граф зависимых задач, а затем для каждого таргета компилирует исходники, обрабатывает ресурсы, линкует один бинарник, собирает бандл, запускает скриптовые фазы и подписывает результат, причём параллельно везде, где позволяет граф.
Шаги
Планирование: разрешить пакеты, вычислить build settings, упорядочить таргеты по зависимостям и построить граф задач. Задачи, у которых не изменились входы, пропускаются, и на этом держатся инкрементальные сборки.
Компиляция: swift-frontend проверяет типы и компилирует Swift-файлы пачками и выпускает .swiftmodule (интерфейс модуля, против которого компилируются зависящие от него модули); clang компилирует C и Objective-C. На выходе получается по одному .o на исходный файл.
Ресурсы: asset-каталоги превращаются в Assets.car (actool), сториборды и XIB компилируются в nib (ibtool), String Catalog преобразуется в .strings, а в Info.plist подставляются значения build settings.
Линковка: ld сливает объектные файлы и статические библиотеки в один Mach-O-исполняемый файл и записывает, какие динамические библиотеки ему нужны.
Сборка бандла и подпись: бинарник и ресурсы копируются в .app, встраиваются фреймворки и расширения, на своих местах запускаются Run Script-фазы, затем codesign с entitlements. Release-сборки (а значит, и Archive) также создают dSYM.
Куда уходит время
Обычно на компиляцию Swift, а в больших приложениях ещё и на линковку. Скорость даёт скорее граф, чем флаги: модули, которые компилируются параллельно, скриптовые фазы с объявленными входами и выходами и инкрементальные сборки вместо привычки делать clean.
Target задаёт один модуль, product определяет то, что пакет экспортирует наружу, package dependency указывает на другой пакет, который вы используете; ваш таргет снаружи не виден, пока его не включил library-продукт, сколько бы в нём ни было public.
Target: один модуль (Swift или C-семейство). dependencies задают порядок сборки и то, с чем линкуется таргет, но SwiftPM по умолчанию не отвергает забытый в манифесте импорт: транзитивно собранный модуль часто всё равно находится, и это ломается позже при изменении графа. Проверку включает swift build --explicit-target-dependency-import-check=error; в xcodebuild аналога нет.
Product: то, что пакет экспортирует. .library(name:targets:) собирает один или несколько таргетов под именем, от которого зависят потребители; не попавшие в продукт таргеты остаются внутренней деталью.
Package dependency: .package(path:) для локального пакета, .package(url:from:) для удалённого. В .product(name:package:) метка package: обозначает идентичность пакета (имя папки или репозитория), а не имя библиотеки. Это самая частая ошибка в манифестах.
Вторая половина касается контроля доступа: держите internal по умолчанию, package (Swift 5.9) используйте для хелперов, общих для таргетов этого пакета, а public оставляйте только для настоящего API.
Ресурсам нужен .process(...) или .copy(...) в таргете, читаются они через Bundle.module, иначе в рантайме их просто нет.
Если список продуктов такой же длинный, как список таргетов, границы вы не провели.
SwiftPM собирает требования к версиям от всех пакетов графа и выбирает одну версию на пакет (самую новую, которая удовлетворяет всем), а затем записывает точные версии и ревизии в Package.resolved. Именно закоммиченный файл заставляет каждую машину собирать один и тот же граф.
Требования задаются semver-диапазонами: from: "1.2.0" означает 1.2.0..<2.0.0, .upToNextMinor(from:) пропускает только патч-релизы, exact: фиксирует одну версию. branch: и revision: не версионированы, и пакет, зависящий от ветки, сам не может быть подключён как версионированная зависимость.
Одна версия на пакет: в отличие от npm, два зависящих пакета не получат разные копии. Несовместимые диапазоны роняют резолв с ошибкой, где названы оба требования.
Где лежит файл: рядом с Package.swift, а для Xcode-проекта в .xcodeproj/project.xcworkspace/xcshareddata/swiftpm/. Его по ошибке добавляют в .gitignore, потому что он выглядит сгенерированным. Учитывается только файл корневого пакета, так что Package.resolved библиотеки ничего не фиксирует для её потребителей.
Resolve и update: swift package resolve и xcodebuild -resolvePackageDependencies соблюдают файл; swift package update и пункт Xcode Update to Latest Package Versions его переписывают. На CI флаг -disableAutomaticPackageResolution заставляет сборку упасть вместо тихого перерезолва.
Цепочка поставок: макросы и build-плагины исполняют код во время сборки, поэтому обновляйте их осознанно, по одной зависимости на пул-реквест.
.xcframework представляет собой бандл, в котором рядом лежат сборки библиотеки под каждую платформу и вариант (устройство, симулятор, Mac Catalyst) плюс Info.plist, по которому Xcode выбирает нужную. Fat-фреймворк сливал всё в один бинарник через lipo, и это сломалось, когда симуляторы стали arm64.
**Почему lipo не работает**: Mach-O хранит по одному слайсу на архитектуру, а на Apple silicon и симулятор, и устройство используют arm64, так что в один бинарник они не помещаются. Fat-бинарник, в котором остались слайсы симулятора, отклоняется при загрузке (ITMS-90087, unsupported architectures), а старый скрипт «вырезать архитектуры» заодно ломал подпись вендора.
Устройство: по директории на слайс, например ios-arm64 и ios-arm64_x86_64-simulator, в каждой .framework или статическая библиотека с заголовками, опционально с dSYM.
Как собрать: архив под каждый destination с SKIP_INSTALL=NO и BUILD_LIBRARY_FOR_DISTRIBUTION=YES, затем xcodebuild -create-xcframework.
Как распространять: SwiftPM .binaryTarget с URL zip-архива и контрольной суммой или локальным путём. XCFramework можно подписывать (Xcode 15+), а SDK из списка Apple commonly used SDKs обязаны быть подписаны и содержать privacy manifest.
Цена любой бинарной зависимости: без dSYM в неё не зайти отладчиком, не пропатчить в своём темпе, и при каждом обновлении Xcode или Swift вы ждёте вендора.
Run Script-фазу без объявленных входных и выходных файлов невозможно признать актуальной, поэтому система сборки запускает её каждый раз, а если она пишет файл, который читает компилятор, перекомпилируется и всё, что ниже по графу.
Как сделать фазу пропускаемой
Объявите Input Files и Output Files, или .xcfilelist, если список длинный или генерируется. Система сборки сравнивает их и пропускает фазу, если ничего не изменилось.
Каждый файл, который скрипт читает, должен быть входом, а каждый, который пишет, должен быть выходом. Фаза без выходов грязная по определению; снятая галочка «Based on dependency analysis» лишь делает это явным.
ENABLE_USER_SCRIPT_SANDBOXING (включён в новых проектах начиная с Xcode 15) роняет сборку, когда скрипт трогает необъявленный путь: раздражает, но именно это держит объявления честными.
Другие способы убить инкрементальную сборку
Генератор, который переписывает выход при каждом запуске (таймстемп, номер сборки), меняет байты, даже когда смысл не изменился. Пишите, только если содержимое отличается.
Форматтер, который правит файлы в дереве исходников: новые даты изменения означают перекомпиляцию.
Линтинг читает всё дерево и не пишет ничего, что нужно компилятору, поэтому ему место в CI или pre-commit-хуке, а не в каждой локальной сборке.
Фаза сборки должна уметь сказать, что она прочитала и что записала; если не может, это не шаг сборки.
App thinning означает, что App Store собирает из вашей загрузки вариант под каждое устройство: slicing оставляет одну архитектуру, один масштаб ассетов и подходящие GPU-ресурсы, поэтому пользователь скачивает сжатый вариант, намного меньший универсального .ipa, а устанавливает более крупный несжатый.
Slicing работает только с картинками и данными внутри asset-каталога. PNG, лежащий в бандле отдельно или в ресурсах фреймворка, уезжает на все устройства.
On-demand resources держат помеченный контент вне первоначальной загрузки и скачивают его при первом использовании: туториалы, наборы уровней, редко нужные медиа. Apple предлагает на замену Background Assets; его управляемые asset packs, которые хостит Apple, требуют iOS 26 в качестве deployment target, поэтому для приложений с более старыми версиями ODR остаётся.
Bitcode: история: объявлен устаревшим в Xcode 14, и App Store его больше не принимает.
Размер загрузки и размер установки: загрузка соответствует сжатому размеру, от него зависит предупреждение о скачивании по сотовой сети; установка соответствует несжатому, и именно он не помещается на заполненное устройство. Зашифрованный исполняемый файл почти не сжимается, поэтому код идёт почти один к одному, а ассеты ужимаются сильно.
Измерение до загрузки: экспортируйте не-App Store-сборку с thinning равным <thin-for-all-variants>; Xcode запишет App Thinning Size Report.txt с обоими размерами по каждому варианту устройства.
Сравнивайте самое старое и самое новое поддерживаемое устройство отдельно: варианты различаются сильнее, чем кажется.
PrivacyInfo.xcprivacy представляет собой plist в составе приложения или SDK, который объявляет, какие данные собираются, есть ли трекинг, через какие домены он идёт и зачем вызываются API из списка «required reason». App Store Connect помечает сборку, которая использует эти API без объявленной причины, и выпустить её нельзя, пока причина не объявлена.
Required reason API: UserDefaults, временные метки файлов, время загрузки системы, свободное место на диске и активные клавиатуры. Для каждой категории нужен утверждённый код причины, например CA92.1 для чтения собственных UserDefaults приложения. Без него App Store Connect помечает сборку ошибкой ITMS-91053, «Missing API declaration», и выпустить её нельзя, пока причина не объявлена.
Трекинг: NSPrivacyTracking и NSPrivacyTrackingDomains. Если пользователь не разрешил трекинг, iOS блокирует соединения с перечисленными доменами.
Собираемые данные: NSPrivacyCollectedDataTypes. Xcode сводит манифесты приложения и всех SDK в privacy report (Organizer ▸ Generate Privacy Report), который помогает заполнить privacy label в App Store.
Сторонние SDK поставляют собственный манифест; манифест приложения их не покрывает. SDK из списка Apple commonly used SDKs также обязаны быть подписаны.
**Это не Info.plist**: строки вроде NSCameraUsageDescription задают текст системного запроса разрешения, и обращение к камере без такой строки роняет приложение.
Вы архивируете Release-сборку, подписываете её для дистрибуции и загружаете в App Store Connect, где она обрабатывается и может уйти тестировщикам TestFlight; затем вы привязываете её к версии приложения, отправляете на App Review и выпускаете вручную, автоматически или поэтапно.
Путь
Archive и загрузка: Product ▸ Archive, затем Organizer, Transporter, xcodebuild -exportArchive с destination равным upload или fastlane. Номер сборки должен быть уникальным в пределах версии; обработка занимает минуты.
TestFlight: внутренние тестировщики (до 100 участников команды) получают сборку сразу после обработки. Внешним тестировщикам (до 10 000, по email или публичной ссылке) нужна Beta App Review для первой сборки версии. Сборки истекают через 90 дней.
App Review: нужны метаданные, скриншоты, privacy label, ответ про export compliance (ITSAppUsesNonExemptEncryption в Info.plist отвечает на него за каждую сборку) и демо-аккаунт, если приложение требует логина. Типичные причины отказа: краши, контент-заглушки, проблемы с приватностью, продажа цифровых товаров в обход правил.
Релиз: вручную, автоматически после одобрения или phased release на 7 дней, который касается только автообновлений и может быть приостановлен. Для критических фиксов есть expedited review.
Требования, которые меняются каждый год
Каждую весну Apple поднимает минимальный SDK для загрузки: Xcode 16 с iOS 18 SDK с апреля 2025 года, Xcode 26 с iOS 26 SDK с апреля 2026 года.
Privacy manifest, объявления required reason и подписи сторонних SDK проверяются при загрузке, а не на ревью.
CI запускает тот же xcodebuild (обычно через fastlane или Xcode Cloud) на macOS-раннере, и самое сложное здесь подпись: раннеру нужны приватный ключ distribution-сертификата и подходящий профиль, причём без участия чьего-то Mac или личного Apple ID.
Пайплайн
На каждый пул-реквест: разрешить пакеты по закоммиченному Package.resolved, собрать, прогнать test plan на симуляторе, линтинг.
На main или тег: выставить номер сборки, архивировать, загрузить в TestFlight, сохранить dSYM для краш-репортера.
Воспроизводимость: зафиксировать версию Xcode (xcode-select или DEVELOPER_DIR), использовать shared-схемы, кешировать чекауты SwiftPM через -clonedSourcePackagesDirPath.
Варианты подписи
**fastlane match**: сертификаты и профили хранятся зашифрованными в приватном git-репозитории или бакете; CI запускает его с readonly: true, чтобы он ничего не создавал и не отзывал; проект при этом должен использовать ручную подпись с полученным профилем (или вызывать update_code_signing_settings).
Ручные секреты: .p12 и профиль лежат в зашифрованных секретах CI и импортируются во временный keychain (setup_ci или security create-keychain).
Облачное управление: Xcode Cloud или xcodebuild -allowProvisioningUpdates с API-ключом позволяют Apple самой управлять сертификатами.
Аутентифицируйтесь API-ключом App Store Connect (.p8, key ID, issuer ID), а не чьим-то Apple ID: никаких запросов двухфакторной аутентификации, и ключ можно отозвать отдельно.
Статическая библиотека копируется в бинарник при линковке и ничего не стоит при запуске, а каждый встроенный динамический фреймворк является отдельным Mach-O-образом, который dyld должен найти, отобразить в память, пропатчить и инициализировать до main: на двенадцать фреймворков эта работа выполняется двенадцать раз.
Статическая
.a или library-продукт SwiftPM без явного type, который обычно линкуется статически; задайте type: .static или .dynamic, когда это важно.
Линковщик может выкинуть то, на что никто не ссылается.
Цена: если слинковать одну статическую библиотеку в два образа, получите две копии её кода и её глобальных переменных.
Динамическая
Один образ в Frameworks/, встроенный и подписанный. Нужна, когда расширение и приложение должны делить одну копию кода, и типична для бинарных SDK.
За каждый образ dyld платит поиском пути, отображением в память, проверкой подписи, фиксапами rebase и bind, регистрацией Objective-C-классов и инициализаторами.
Фреймворки Apple почти бесплатны, потому что заранее слинкованы в dyld shared cache. Ваши в него не входят.
Mergeable libraries (Xcode 15+)
Соберите фреймворки с MERGEABLE_LIBRARY = YES и задайте MERGED_BINARY_TYPE у таргета приложения: Release вливает их в бинарник приложения, как при статической линковке, а Debug оставляет отдельными, чтобы инкрементальная линковка оставалась быстрой.
Ловушка в том, чтобы мерить тёплый запуск: после первого запуска dyld кеширует информацию о загрузке, поэтому мерьте первый запуск после установки или перезагрузки шаблоном App Launch в Instruments. Статику берите по умолчанию, динамику только тогда, когда код нужно делить в рантайме, а mergeable тогда, когда хочется много модулей и один образ.
Analytics объявлен как type: .static, поэтому его код, включая AnalyticsTracker.shared, копируется и в Feed, и в Profile. В рантайме живут два независимых синглтона, а для Objective-C-классов консоль предупреждает «Class X is implemented in both … One of the two will be used. Which one is undefined.»
Как это выглядит: два кеша, два хранилища фича-флагов или две очереди событий, которые расходятся, +load и глобальные инициализаторы выполняются дважды, один и тот же код оплачен дважды в размере. Swift-типы не дают никакого предупреждения, так что баг обычно тихий.
Иногда Xcode ловит это сам: когда Xcode-таргеты статически линкуют один и тот же продукт пакета, он сообщает, что продукт «is linked as a static library by» обоими и «this will result in duplication of library code».
Как найти
otool -L на бинарнике приложения и каждом встроенном фреймворке: граф линковки в том виде, в каком он собран.
nm -gU на каждом бинарнике с grep по символу из подозрительной библиотеки: один и тот же определённый символ в двух образах и есть баг.
LD_GENERATE_MAP_FILE = YES и link map показывают, какие объектные файлы куда попали; image list в lldb показывает загруженные образы в рантайме.
Как исправить
Код должен содержаться ровно в одном образе. Либо сделайте всё статическим (type: .static у каждого продукта, как в примере), чтобы Analytics был только в бинарнике приложения (или используйте mergeable libraries), либо, если расширение должно делить его в рантайме, дайте Analytics единственного динамического владельца, против которого линкуются остальные. Вырезать дублирующийся символ не значит исправить: две копии состояния никуда не делись.
Прежде чем винить компилятор, докажите, что пересобирается: правка одной строки, которая стоит минут, гораздо чаще упирается в граф зависимостей (что-то заставляет пересобираться много файлов или модулей), чем в один медленный файл.
Измерить
Product ▸ Perform Action ▸ Build With Timing Summary или -showBuildTimingSummary, затем Build Timeline в навигаторе отчётов: каждая задача, её длительность и шла ли сборка параллельно или одной длинной последовательной цепочкой.
Тронуть один файл и посчитать задачи SwiftCompile. Если их сотни на правку одной строки, граф неправильный.
Только потом смотреть на исходники, с -warn-long-function-bodies и -warn-long-expression-type-checking.
Обычные виновники
Run Script-фаза без объявленных выходов или генератор, который переписывает файл на каждой сборке.
Правка public- или @inlinable-объявления в модуле, который импортируют все, или гигантский Core, от которого зависит каждая фича: перекомпилируются все зависимые. Интерфейсные модули защищают вызывающих от правок реализации.
Bridging header или сгенерированный заголовок, импортированный повсюду.
Высокая цепочка модулей, где каждый ждёт предыдущего; более плоский граф компилируется параллельно.
Только в Release: wholemodule переоптимизирует весь модуль при любом изменении (см. вопрос про Debug и Release); не выключайте его ради времени сборки.
Несколько выражений, на которых буксует проверка типов (длинные цепочки +, разнотипные литералы). Аннотируйте типы или разбейте выражение.
Мерить честно
Инкрементально, а не с чистого листа; берите медиану трёх запусков и меняйте по одному параметру: индексация и холодные кеши сдвигают цифры.
public означает обещание, а не удобство: каждый публичный символ становится API, который придётся поддерживать работающим, а широкая поверхность позволяет любому файлу приложения лезть во внутренности модуля, и это ровно та связность, от которой модуль должен был защищать.
Уровни доступа, включая тот, о котором забывают
internal (по умолчанию) виден всему модулю; правильный ответ почти для всего.
package (Swift 5.9) виден всем таргетам того же пакета, но не его клиентам. Именно отсутствие этого уровня раньше загоняло людей в public: хелпер, общий для двух таргетов одного пакета.
public доступен клиентам при импорте; классы всё ещё нельзя наследовать вне модуля.
open означает public плюс возможность наследовать и переопределять. Расширить public до open позже безопасно, а сузить обратно уже ломает клиентов.
Что должен выставлять фича-модуль
Одну точку входа (View или координатор) плюс value-типы из её сигнатуры и протоколы, которые нужно внедрить. Всё остальное остаётся internal.
Memberwise- и дефолтные инициализаторы имеют уровень internal, поэтому public struct нельзя создать снаружи, пока вы не напишете public init.
Тестам расширять доступ не нужно: @testable import и так видит internal.
@_exported import Networking выглядит как решение, но это ловушка: клиенты компилируются, не импортируя Networking, пока два модуля не экспортируют одинаковое имя или пока вы не попробуете убрать зависимость и не обнаружите, что на неё опираются все клиенты. Это атрибут с подчёркиванием, официально не поддерживаемый.
Ни одна фича не импортирует другую: вынесите нужный им контракт в интерфейсный модуль, от которого зависят обе, а реализации пусть связывает composition root приложения.
Почему SwiftPM прав, отказывая
Граф таргетов обязан быть ациклическим, поэтому цикл даёт жёсткую ошибку. Внутри одного большого таргета та же связность спокойно компилировалась, так она и разрослась; граница модулей делает свою работу: делает связность видимой.
Три выхода в порядке предпочтения
Разделить интерфейс: ProfileInterface содержит протоколы, типы маршрутов и простые значения и ни от чего не зависит. Profile его реализует, Checkout зависит только от ProfileInterface, а приложение линкует оба. Бонус: правка реализации больше не перекомпилирует вызывающих.
Реестр маршрутов в Core: фичи при запуске регистрируют обработчики типизированных маршрутов. Единообразно и дёшево, но ошибки связывания переезжают из компиляции в рантайм, поэтому тестируйте настоящий composition root.
Опустить общее вниз: если обеим нужен Address, это доменная модель, а не фича: ей место в Core, и цикл исчезает.
Ложные исправления
Внедрить замыкание, тип параметра которого всё равно взят из другого модуля: импорт никуда не делся.
Интерфейсный модуль, в котором завелись view model и UI, становится реализацией под другим именем; держите в нём только протоколы, идентификаторы и простые значения.
Если двум фичам нужно общаться, то, о чём они общаются, не принадлежит ни одной из них: оно ниже, в Core, или выше, в приложении.
ABI stability позволяет скомпилированному бинарнику работать с Swift-рантаймом ОС; module stability позволяет бинарный фреймворк импортировать более новым компилятором. BUILD_LIBRARY_FOR_DISTRIBUTION даёт второе через library evolution и превращает каждое public-объявление в обещание.
Две стабильности, которые часто путают
ABI stability: появилась вместе со Swift 5 на платформах Apple. Вы получаете её бесплатно.
Module stability: без неё бинарный .swiftmodule может прочитать только тот самый компилятор, который его создал, и следующий Xcode падает с «module compiled with Swift X cannot be imported by the Swift Y compiler». Настройка заставляет компилятор выпускать текстовый .swiftinterface.
Чего стоит resilience
Клиенты больше не вшивают раскладку ваших типов в свой код: к публичным структурам и enum обращаются через аксессоры, так что доступ к полю превращается в вызов, сквозь который оптимизатор не видит.
Ваш не-frozen public enum может получить новые кейсы, поэтому клиенты получают предупреждение, пока не добавят @unknown default.
@frozen возвращает раскладку клиентам и делает enum снова исчерпывающим, причём навсегда. @inlinable копирует тело функции в клиентов, поэтому старые приложения продолжают выполнять старую логику и после вашего фикса.
Каждый модуль, встречающийся в ваших публичных сигнатурах, тоже должен собираться для дистрибуции.
Включить легко; выключить после того, как клиенты выпустились, значит внести ломающее изменение. Зависимости в исходниках его не включают; всё, что поставляется бинарником, включает с первого дня.
Генераторы заменяют конфликтный project.pbxproj коротким манифестом, который можно ревьюить. Мигрируйте одним механическим, доказуемым шагом (сгенерируйте тот же проект, докажите, что он собирает идентичное приложение, удалите .xcodeproj из git) и перестраивайте таргеты только после этого.
Зачем их берут
Больше никаких конфликтов в pbxproj: проект становится артефактом сборки и перегенерируется после чекаута.
Граф модулей описан кодом: таргеты, зависимости и настройки следуют общим соглашениям, а не 200 вариациям, накликанным руками.
Tuist добавляет возможности, знающие о графе, вроде selective testing и бинарного кеширования модулей.
Порядок, сохраняющий обратимость
Сначала вынести build settings в .xcconfig-файлы; манифест останется тонким.
Описать существующие таргеты один к одному: без переименований и новых модулей.
Добавить CI-джобу, которая генерирует проект с нуля и падает, если .xcodeproj всё ещё в git, затем добавить его в .gitignore.
Только теперь делить, сливать или переносить таргеты, по одному пул-реквесту.
Как доказать, что всё сработало
xcodebuild -showBuildSettings для каждого таргета и конфигурации, дифф до и после.
Собранный .app, сравнённый пофайлово, плюс otool -L, чтобы поймать фреймворк, который тихо перестал линковаться.
Тег на последнем коммите с закоммиченным проектом: это и есть откат.
Чистый SwiftPM не даёт полного ответа: у него нет продуктов для iOS-приложения и расширений, поэтому оболочка остаётся (сгенерированным) Xcode-проектом, а фичи становятся пакетами. Главный риск связан с людьми: кто-то меняет настройки в UI Xcode, и следующая генерация их теряет; именно проверка в CI закрепляет новую привычку.