В этой статье я расскажу о проблемах с которыми я столкнулся при подключении тяжелых зависимостей к iOS проекту с помощью Swift Package Manager и о способе их решения.
Тяжелые зависимости
Для начала давайте определим понятие тяжелой зависимости. Под тяжелой зависимостью я понимаю такую зависимость в которой есть большое кол-во исходного кода. Самый распространённый пример такой зависимости — Firebase. Это набор сервисов от Google которые используются при разработке веб и мобильных приложений. Самые часто используемые сервисы это Firebase Crashlytics — для сбора крэшей и их анализа и Firebase Analytics — для продуктовой аналитики.
Firebase содержит в основном Objective-C и С++ код. Но имеется также и Swift и Objective-C++ код.
Проанализировав исходный код с помощью утилиты cloc
, можно обнаружить что там 4 803 файла, 150 000 строчек Objective-C кода и 93 000 строчек С++ кода.
Ещё один пример тяжелой зависимости — AWS Swift SDK Это зависимость для работы с сервисами AWS с помощью Swift. Содержит Swift код, около 2 700 файлов и 3 миллиона строчек Swift кода.
Какие проблемы возникают при подключении тяжелых зависимостей через SPM
Если подключать тяжелые зависимости с помощью Swift Package Manager к Xcode проекту, то возникнут следующие проблемы:
1. Время холодной сборки проекта сильно увеличится
Если подключать тяжелую зависимость стандартным способом, через Swift Package Manager, то к проекту подключаются исходники выбранных библиотек. Из-за этого время сборки приложения сильно увеличится так как необходимо будет компилировать эти файлы.
Пример 1 — Очень простое приложение
Возьмем для примера простейшее приложение без тяжелых зависимостей.
После вызова Clean Build и Build With Timing Summary получаем время холодной сборки 0.4 сек.
Далее подключим тяжелую зависимость, например AWS SDK for iOS. Так как AWS SDK состоит из множества библиотек, я для примера подключил всего одну из них — AWS DynamoDB — NoSQL базу данных.
После вызова Clean Build и Build With Timing Summary получаем время холодной сборки 19 сек. Это произошло из-за того что в процессе сборки компилируются исходные файлы этой библиотеки.
Пример 2 — Много-модульное приложение среднего размера
Приложение среднего размера с несколькими десятками модулей без тяжелых зависимостей собирается за 50 секунд.
Если к этому приложению подключить Firebase Crashlytics, Analytics и Messaging, то оно станет собираться почти на 10 секунд дольше.
Итог
При подключении тяжелой зависимости через SPM холодное время сборки приложения увеличивается. Это приводит к тому что на CI приложение будет дольше собираться перед прохождением тестов. Разработчики будут дольше ждать проверок на CI.
2. Время индексации сильно увеличится
Из-за большого количества исходных файлов в тяжелых зависимостях, написанных на разных языках, время на индексацию проекта в Xcode сильно увеличится.
Чтобы понять какие файлы индексирует Xcode надо получить и изучить логи индексации. Для этого достаточно запустить Xcode следующим образом с помощью Terminal:SOURCEKIT_LOGGING=3 /Applications/Xcode.app/Contents/MacOS/Xcode &> ~/Desktop/indexing.log
Либо вызвать команду:defaults write com.apple.dt.Xcode IDEIndexShowLog -bool YES
Эта команда позволяет увидеть логи индексации прямо в Xcode, в панели Report Navigator.
Изучив логи можно увидеть что Xcode индексирует абсолютно все исходники которые есть в подключаемой зависимости. Если она разделена на сотни отдельных библиотек, а подключаем к проекту только одну, то Xcode все-равно будет индексировать исходники всех сотен библиотек.
Пример 1 — Очень простое приложение
Без тяжелых зависимостей индексация всего проекта занимает 12 сек. С подключенной тяжелой зависимостью AWS SDK for iOS индексация всего проекта занимает 7 мин 40 сек.
Пример 2 — Много-модульное приложение среднего размера
Приложение среднего размера с несколькими десятками модулей без тяжелых зависимостей индексируется 2 мин 15 секунд.
Если к этому же приложению подключить Firebase Crashlytics, Analytics и Messaging, то оно станет индексироваться 4 мин 20 сек.
Итог
Так как тяжелые зависимости подключаются как исходники, то Xcode их полностью индексирует. Если на CI это не так важно т.к. индексация происходит в процессе сборки, то на компьютерах разработчиков это серьезная проблема. Нормально писать код можно только после окончания индексации.
3. Баги в Xcode при использовании SPM
В Xcode есть серьезный баг, если к проекту подключены Swift пакеты. Если переключаться между ветками, что разработчики делают часто, то он начнет делать Resolving Package Graph, индексацию и Preparing Editor Functionality. И это несмотря на то что Package.resolved файл не изменился. А теперь самое главное: если к проекту подключены тяжелые зависимости, то сразу после переключения на другую ветку, Xcode начинает нагружать все ядра процессора и несколько десятков секунд, а то и минут невозможно ничего делать с проектом. На эту проблему многие жаловались в Twitter (раз, два, три, четыре) и на форуме Apple (раз, два).
Пример:
Переходим с одной ветки на другую, в которой добавились 2 новых Swift файла, а 8 других изменились.
Смотрим что происходит с Xcode:
Сначала Xcode делает Resolving Package Graph, потом индексацию и в конце Preparing Editor Functionality. В это время сначала нагружается одно ядро процессора на 100%, а потом все 8 на M1. А теперь о причинах. В папке DerivedData есть папка SymbolCache в которой расположен файл project.plist. В этом файле хранится информация обо всех символах в проекте. Этот файл для простого iOS проекта с подключенной тяжелой библиотекой у меня весит 141 Мб. Это чрезвычайно много. При переключении ветки, Xcode, по непонятной причине, начинает удалять из этого файла элементы что отнимает очень много времени. Во время удаления элементов из этого файла, Xcode полностью нагружает главный поток что приводит к его полному зависанию. Появляется так называемый Spinning Wheel. Ниже скриншот из Xcode Instruments → Time Profiler, где видно все проблемы о которых я сказал в этом абзаце.
Но, самое неприятное это то что после этого Xcode нагружает все 8 ядер на M1 маке что приводит к зависанию не только Xcode но и всего компьютера.
Из профайлера сложно понять что конкретно делает Xcode. Но из названий можно понять что он продолжает работать с SymbolCache который чрезвычайно тяжелый.
Итог
Xcode содержит серьёзные баги которые мешают пользоваться Xcode в случае проекта с подключенными тяжелыми библиотеками.
4. Отображение списка зависимостей
Тяжелые зависимости, как правило, разделены на большое кол-во библиотек. Кроме того, при попытке подключить одну библиотеку, например Analytics из Firebase, подключается 12 других. Или, например, если подключить AWS DynamoDB из AWS, то подключается 6 разных библиотек.
Здесь я вижу небольшую проблему в том что дополнительных зависимостей много и они засоряют список зависимостей отображаемых в Xcode.
Решение всех проблем
Чтобы решить все проблемы возникающие при подключении тяжелых зависимостей через SPM достаточно просто отказаться от их подключения в виде исходников. Их надо подключать в виде скомпилированных статических библиотек. Тогда их не нужно будет ни компилировать ни индексировать. И Firebase, и AWS, и возможно другие тяжелые зависимости для каждого релиза добавляют XCFramework файлы которые легко подключить к проекту. Даже если XCFramework файла нет его можно собрать самому.
Пример. XCFramework файлы для Firebase можно скачать с GitHub на странице с релизами. Они лежат в файле Firebase.zip.
Чтобы было удобнее подключать и обновлять XCFramework файлы их можно обернуть в Swift пакет. Об этом подробно написано в документации от Apple:
Distributing Binary Frameworks as Swift Packages
Далее я покажу на примере Firebase Crashlytics и Analytics как создать Swift Package с бинарной зависимостью.
Создаём новый локальный Swift пакет и подключаем его к проекту.
Переносим в корневую директорию этого пакета необходимые XCFramework файлы. Например, если мы хотим подключить Crashyltics и Analytics к проекту, то нужно перенести XCFramework файлы из директорий FirebaseCrashlytics и FirebaseAnalytics.
В Package.swift файле в разделе targets
удаляем весь код и добавляем ссылки на XCFramework файлы используя .binaryTarget
. Далее указываем эти бинарные таргеты у библиотеки которую будем распространять. В итоге Package.swift файл будет выглядеть так:
// swift-tools-version: 5.6
import PackageDescription
let package = Package(
name: "FirebaseBinaries",
platforms: [
.iOS(.v14)
],
products: [
.library(
name: "FirebaseBinaries",
targets: [
"FirebaseAnalytics",
"FirebaseCore",
"FirebaseCoreDiagnostics",
"FirebaseInstallations",
"GoogleAppMeasurement",
"GoogleDataTransport",
"GoogleUtilities",
"nanopb",
"PromisesObjC",
"FirebaseCrashlytics"
])
],
targets: [
.binaryTarget(name: "FirebaseAnalytics", path: "Frameworks/FirebaseAnalytics/FirebaseAnalytics.xcframework"),
.binaryTarget(name: "FirebaseCore", path: "Frameworks/FirebaseAnalytics/FirebaseCore.xcframework"),
.binaryTarget(name: "FirebaseCoreDiagnostics", path: "Frameworks/FirebaseAnalytics/FirebaseCoreDiagnostics.xcframework"),
.binaryTarget(name: "FirebaseInstallations", path: "Frameworks/FirebaseAnalytics/FirebaseInstallations.xcframework"),
.binaryTarget(name: "GoogleAppMeasurement", path: "Frameworks/FirebaseAnalytics/GoogleAppMeasurement.xcframework"),
.binaryTarget(name: "GoogleDataTransport", path: "Frameworks/FirebaseAnalytics/GoogleDataTransport.xcframework"),
.binaryTarget(name: "GoogleUtilities", path: "Frameworks/FirebaseAnalytics/GoogleUtilities.xcframework"),
.binaryTarget(name: "nanopb", path: "Frameworks/FirebaseAnalytics/nanopb.xcframework"),
.binaryTarget(name: "PromisesObjC", path: "Frameworks/FirebaseAnalytics/PromisesObjC.xcframework"),
.binaryTarget(name: "FirebaseCrashlytics", path: "Frameworks/FirebaseCrashlytics/FirebaseCrashlytics.xcframework")
]
)
Далее необходимо подключить к главному таргету библиотеку FirebaseBinaries
. Выбираем таргет, и в разделе Frameworks, Libraries and Embedded Content нажимаем на + и выбираем FirebaseBinaries
.
Кроме того, так как Firebase частично написан на C++, нужно подключить библиотеку libc++.tbd
.
В файле Firebase.zip в README.md так же указано что нужно добавить флаг линковки-ObjC
Все! Теперь можно использовать Crashlytics и Analytics в проекте.
Если не хотите держать Swift пакет с xcframework файлами рядом с проектом, то можете создать отдельный репозиторий, перенести туда этот пакет и подключить его к основному проекту указав адрес репозитория.
Результат
Время сборки простейшего приложения, после подключения тяжелой библиотеки, почти не изменилось. Было 0.4 сек стало 1 сек.
Время на индексацию никак не изменилось т.к. бинарная зависимость не индексируется.
Xcode больше не нагружает процессор при переключении веток.
Кол-во зависимостей увеличилось всего на одну — FirebaseBinaries.
Удовлетворенность разработчиков выросла. Больше никто не жалуется на то что Xcode тормозит, больно переключать ветки.
Минусы
Минус такого подхода один — станет сложнее обновлять зависимости. Необходимо скачивать zip файл с xcframework файлами и помещать их в Swift Package. К счастью это можно автоматизировать.
Вывод
Если вы хотите подключить к iOS проекту, через Swift Package Manager, зависимость содержащую огромное количество исходного кода, подключите её в скомпилированном виде, через XCFramework файл. Благодаря этому, скорость сборки вашего приложения не изменится, как и время на индексацию исходного кода, а самое главное, вы избежите багов Xcode при работе с SPM.