Привет, Хабр! Меня зовут Лена, я iOS-разработчик в inDriver. Расскажу о том, как мы выбирали менеджер зависимостей для проекта. Название «Борьба с зависимостями» может показаться пугающим, но на самом деле все не так страшно.

Сначала поделюсь тем, как у нас обстояли дела с интеграцией сторонних решений. Затем выделю проблемы и цели, которые перед нами стояли. После перейду к сравнению менеджеров зависимостей. И, наконец, расскажу, почему остановились на Swift Package Manager (SwiftPM).

Погнали!

Содержание
Дисклеймер

Эта статья — расшифровка моего доклада на CocoaHeads, его можно посмотреть здесь.

Проблемы и цели

Пару месяцев назад у нас в проекте было 46 зависимостей. Больше половины из них были добавлены через CocoaPods, около трети через Carthage и 3 — зашиты в коде. Так исторически сложилось.

Вот как все было
Вот как все было

С таким подходом что-то было не так. Выделю 3 проблемы:

  1. Не было ясно, какой из менеджеров зависимостей использовать при добавлении новой библиотеки или фреймворка.

  2. При обновлении Xcode регулярно возникали трудности с использованием CocoaPods и Carthage. Много проблем было и при переходе на Xcode 12. Всему виной — подготовка к работе с M1. Например, проект переставал собираться на симуляторе из-за изменений в build settings.

  3. У нас был запутанный граф зависимостей из-за структуры проекта, и иногда приходилось линковать лишние транзитивные зависимости к некоторым модулям. Хотелось бы сделать граф зависимостей наглядным.

Также хотелось бы удобнее контролировать время сборки локально и на CI, что было бы проще, будь у нас один менеджер зависимостей.

Зависимости

Мы решили, что нужно с этим что-то делать. Для начала проанализировали ситуацию, как зависимости интегрировались в проект.

Сторонние решения можно добавлять в проект в виде исходного кода, статических и динамических библиотек, фреймворков и XCFrameworks. Кратко расскажу об этих подходах.

  1. Через исходный код. Добавляем исходный код сторонних решений к себе, они собираются вместе с нашим кодом и замедляют пересборку проекта. Добавление и обновление происходят вручную. Для сторонних решений с крупной кодовой базой и активной разработкой и поддержкой этот способ неудобен.

  2. C помощью библиотек. По способу линковки они бывают статическими (.a) и динамическими (.dylib). Статические — архивы объектных файлов формата Mach-O. Это бинарный формат, внутри — инструкции для конкретной архитектуры. Когда статическая библиотека добавляется в проект, она «склеивается» с нашим бинарником в один. Чем больше статических библиотек в проекте, тем больше итоговый бинарник.

    Динамические библиотеки отличаются от статических тем, что не зашиваются в итоговый бинарник, а загружаются в память при необходимости — на старте приложения или при вызове ее API. За счет этого не увеличивают размер бинарника нашего приложения. Но тем не менее, влияют на вес архива, который отправится в стор; исключения составляют системные библиотеки, которые поставляются вместе с iOS и работа с которыми производится через файлы формата .tbd. Чем больше динамических библиотек в проекте, тем дольше запуск нашего приложения.

    Стоит отметить, что с помощью lipo можно собрать из библиотеки fat binary, который бы содержал инструкции сразу для нескольких выбранных нами архитектур.

  3. С помощью фреймворков. Фреймворк можно считать надстройкой над библиотеками. Это бандл, внутри которого лежит библиотека и, возможно, какие-то ресурсы.

    Существует его разновидность — universal framework, внутри которого находится fat binary. Этот формат был не очень удобен тем, что требовалось убирать лишние архитектуры при загрузке в стор.

    Но с Xcode 11 на замену universal framework пришел новый формат — XCFramework. Он позволяет использовать фреймворк как единую зависимость для разных архитектур и выбор нужной происходит автоматически. В теории это упростит переезд на М1 — не надо пересобирать зависимости под каждую платформу или архитектуру, которую мы поддерживаем. Это оптимальный вариант для собранных заранее зависимостей.

Сравнение менеджеров зависимостей

Начну с того, какие задачи решают менеджеры зависимостей. В первую очередь, это загрузка сторонних решений, разрешение графа зависимостей, а также их обновление и удаление. Некоторые из них также помогают выполнять другие задачи: интегрировать эти зависимости в проект, искать доступные сторонние решения.

Принцип работы менеджеров зависимостей прост. Есть Manifest-файл, куда заносится информация о требуемых зависимостях. Для CocoaPods это Podfile, для Carthage Cartfile, а для SwiftPM — Package.swift. Менеджер зависимостей использует данные из Manifest-файла, чтобы загрузить исходники или бинарники, а затем формирует слепок зависимостей в lock-файле — это Podfile.lock, Cartfile.resolved, Package.resolved. Дальше требуется интегрировать загруженные зависимости в проект.

Наглядная схема
Наглядная схема

Чем же отличаются менеджеры зависимостей? Можно сравнить их по следующим критериям:

  • насколько активно поддерживается и успевает за обновлениями Xcode.

  • сколько доступных сторонних решений и насколько удобен их поиск.

  • как происходит интеграция зависимостей в проект.

  • насколько гибко можно выбирать способ линковки зависимостей.

1. CocoaPods. Пожалуй, самый популярный среди iOS-разработчиков. Стабильно работает, активно поддерживается и обладает огромным коммьюнити, в нем доступно множество сторонних решений и искать их можно прямо на сайте в силу того, что CocoaPods централизован и информация о доступных сторонних решениях хранится в едином репозитории спецификаций. Главное отличие от остальных состоит в том, что интеграция зависимостей происходит «под капотом» и закрыта от нас.

Принцип работы. Мы заводим Podfile, делаем pod install, он разрешает граф зависимостей — выбирает нужные версии, смотрит, доступны ли решения, загружает их. А дальше берет загруженные исходники, формирует файл .xcodeproj, в котором каждой из зависимостей соответствует таргет. И затем он заворачивается в .xcworkspace, который мы используем для работы с проектом.

Принцип работы CocoaPods
Принцип работы CocoaPods

Проблемы. Из-за того, что CocoaPods скачивает исходники и собирает их, замедляется сборка. Нет контроля над интеграцией и меняется структура проекта — если мы изначально использовали .xcworkspace, потребуются правки, чтобы это все заработало.

2. Carthage. Это первый менеджер зависимостей для Swift. Отличается тем, что интеграция полностью лежит на нас — Carthage только скачивает и собирает зависимости, но не встраивает их в проектный файл. Соответственно, это простой и гибкий инструмент. Является децентрализованным: репозитории сторонних решений мы указываем напрямую, общего хранилища с информацией о них нет.

Сборка зависимостей происходит до проекта, причем Carthage по умолчанию собирает динамические фреймворки (причем universal). Потом они линкуются к нашему проекту — за счет чего мы экономим на скорости сборки. К сожалению, Carthage в последнее время не так активно поддерживается, и у нас были проблемы с ним при обновлении на Xcode 12.

Принцип работы. Сначала вносим ссылки на репозитории нужных зависимостей в Cartfile и вызываем команду, которая скачает зависимости — как исходники, так и бинарники, после чего загруженные исходники собираются. Теперь нужно вручную добавить их в проект. К счастью, мы используем XcodeGen, так что этот шаг автоматизирован. Поэтому Carthage нашим разработчикам нравился гораздо больше, чем CocoaPods.

Принцип работы Carthage
Принцип работы Carthage

Проблемы. В Xcode регулярно происходят изменения, связанные с настройкой проекта, из-за чего у нас не один раз ломалась сборка зависимостей для Carthage. Иногда переставали собираться и наши тесты: например, при линковке зависимостей к тестам в них оставались лишние архитектуры. Решения подобных проблем приходится ждать долго, так как Carthage в последнее время обновляется не так часто.

3. SwiftPM. Мы решили посмотреть в сторону SwiftPM. Он появился еще со Swift 3, но для iOS стал официально доступен только с Xcode 11. SwiftPM подразумевает нативную интеграцию и поставляется вместе со Swift. С ним можно работать в терминале, а можно добавлять и обновлять зависимости через интерфейс Xcode, и видеть их список в структуре проекта.

Также, как и Carthage, это децентрализованный менеджер зависимостей. Но к сожалению, пока еще SwiftPM поддерживает не все сторонние решения, так как для его поддержки в репозитории должен лежать Package.swift. Но в большинстве случаев это легко исправить.

SwiftPM интегрирован в билд систему языка, а значит, изменения, которые происходят в Xcode, должны поддерживаться в нем из коробки. В теории, это облегчит поддержку новых архитектур и других изменений в будущем.

Рассмотрим устройство Package.swift (сниппет взят из документации). Сначала объявляется название самого пакета. Пакетом называется исходный код с Manifest-файлом. Дальше указываются продукты — артефакты, которые будут созданы при сборке пакета (библиотеки или бинарники). За ними следует список зависимостей пакета, а также таргеты, входящие в этот пакет, и их внутренние и внешние зависимости.

Вот как это выглядит в Xcode
Вот как это выглядит в Xcode

Принцип работы. Добавить пакет к себе в проект можно из меню в Xcode: выбрать таргет, в который она будет добавлена, указать название пакета и версию. Если все хорошо, эта зависимость попадет в проектный файл. Затем автоматически происходит разрешение зависимости, и она загружается. Сборка пакета будет происходить вместе со сборкой проекта.

Принцип работы SwiftPM
Принцип работы SwiftPM

Проблемы. Дело в том, что наш проект все еще содержит код на Obj-C. Взаимодействие кода на Obj-C и Swift вызывает ряд проблем при работе со SwiftPM: какие-то можно решить самостоятельно, какие-то требуют доработок со стороны SwiftPM.

Итак, если подытожить, сравнение менеджеров зависимостей приведено в таблице:

Для нас среди этих трех менеджеров зависимостей лидирует SwiftPM. Вернусь к таблице и раскрою ее чуть подробнее.

1. Централизованность. У CocoaPods есть единый репозиторий спецификаций, который в случае проблемы не позволит скачать наши зависимости — это минус. Carthage и SwiftPM просто берут исходный ход с сервисов хостинга репозиториев — например, GitHub, и это упрощает задачу загрузки сторонних решений. Работает GitHub — работаем и мы.

2. Интеграция зависимости. У CocoaPods есть автоматическая интеграция в проект. Пользоваться инструментом просто, если нам мало от него нужно. Но так как это черный ящик и CocoaPods меняет структуру проекта, могут быть проблемы, которые на первый взгляд не видны. C Carthage ситуация противоположная: интеграции нет, она происходит вручную — а значит гибко. В случае со SwiftPM у нас есть возможность ближе познакомиться с нашим проектным файлом и как-то на него влиять, а также иметь контроль над подключением зависимости.

3. Способы линковки. CocoaPods изначально подразумевает использование динамических фреймворков. До Xcode 9 нельзя было собирать статические библиотеки, содержащие Swift, но теперь это возможно и при использовании CocoaPods можно указать желаемый способ. С Carthage ситуация аналогичная — по умолчанию собираются динамические фреймворки, но при желании можно собирать и статические. А со SwiftPM все зависит от того, как разработчик стороннего решения объявил Package.swift для своего пакета и какой там указан способ линковки. Если при объявлении продукта-библиотеки ничего не указано, будет собираться статическая, а динамическая — только если указан тип .dynamic. Что же касается XCFramework, Carthage позволяет нам выбрать этот вариант самостоятельно, а в CocoaPods и SwiftPM этот способ интеграции стороннего решения будет выбран, только если его автор явно его задал.

Переезд на SwiftPM

Взвесив «за» и «против», мы решили, что будем постепенно переезжать на SwiftPM. План был такой: сначала составляем общий список зависимостей, добавленных через Carthage и CocoaPods; затем проверяем, доступны ли они через SwiftPM (есть утилиты, которые помогают сэкономить время на этом шаге — например, spmready). Дальше идем по полученному списку и переводим отдельные сторонние решения.

Причем переезд на SwiftPM может быть поэтапным: не требуется делать из своих модулей пакеты, чтобы добавлять зависимости в виде пакетов SwiftPM. Сделать пакеты из модулей своего проекта можно будет тогда, когда переведены все зависимости, но и это не обязательно.

Естественно, при переезде мы столкнулись с проблемами.

В первую очередь, это были проблемы в самих сторонних решениях. Например, часть из них не была доступна через SwiftPM. В таком случае, требовалось либо ждать, пока там появится поддержка, либо добавлять ее самостоятельно. Если использовался свой форк стороннего решения, его перевод ускорялся – нужно было только добавить Package.swift и поднять версию. Помимо этого, была проблема при переводе сторонней зависимости на Obj-C, у которой в Package.swift были неверные пути к заголовочным файлам — требовались исправления в ее репо.

Также были трудности из-за организации нашего проекта. Например, не получилось добавить пакет на Obj-С в модуль на Swift, который потом использовался в модуле на Obj-С. Тут мы получали ошибку поиска заголовочных файлов. А добавить пакет сразу в оба эти модуля нельзя, иначе была бы ошибка дублирования символов, так как он подключался статически. Таких случаев было несколько и мы решили оставить их на потом. Хотя в целом, если требуется подключить статическую библиотеку в несколько независимых модулей, можно добавить ее в свой модуль, который будет подключаться динамически в нужных местах.

Проблемы все еще есть и в самом SwiftPM. Раньше к ним относилось отсутствие поддержки ресурсов, но она появилась в Xcode 12. Правда мы словили проблему с загрузкой ресурсов в одном из сторонних решений, поэтому его перевод на SwiftPM также отложили на потом.

Как теперь
Как теперь

Может показаться, что стало еще хуже, чем было. Но на самом деле больше половины зависимостей удалось перевести на SwiftPM. А скоро мы совсем избавимся от Carthage, что давно хотели сделать. CocoaPods какое-то время еще будем использовать: подождем, пока часть сторонних решений оттуда начнет поддерживать SwiftPM, а часть станет ненужной из-за удаления легаси на Obj-C, в котором она используется.

Вывод

SwiftPM — новый стандарт в iOS-разработке. В отличие от CocoaPods, он не меняет структуру проекта, нативно интегрирован в Xcode и поставляется вместе с ним. Правда, стоит учитывать, что в SwiftPM еще есть недоработки. В частности, возможны трудности в совместном использовании кода на Swift и Obj-C. Но, в любом случае, переезд может быть поэтапным.

Спасибо, что дочитали статью, буду рада ответить на вопросы в комментариях.

Комментарии (16)


  1. Gargo
    21.10.2021 20:44

    У CocoaPods есть единый репозиторий спецификаций, который в случае проблемы не позволит скачать наши зависимости — это минус. Carthage и SwiftPM просто берут исходный ход с сервисов хостинга репозиториев — например, GitHub, и это упрощает задачу загрузки сторонних решений.

    Не совсем понял. Вы хотите сказать, что нельзя залить свой pod на свой сервер и прилинковать его к проекту?


    1. le-go Автор
      22.10.2021 09:50

      Нет, можно сделать свой приватный под, гайд вот тут. В этом случае спецификация будет храниться в нашем репо, и в Podfile будет указан к нему путь. Но приватные поды – это, скорее, инструмент для организации своих проектов: мы выносим свой код, для того, чтобы его можно было переиспользовать. В статье же я описывала работу со сторонними решениями.


  1. akaDuality
    25.10.2021 13:12

    Как анализируете связи между фреймворками?


    1. le-go Автор
      27.10.2021 15:59

      С визуализацией графа зависимостей были трудности: во-первых, из-за того, что объявление зависимостей у нас в проекте размазано по Podfile и yml-конфигам модулей для XcodeGen, а во-вторых, существующие решения с открытым кодом не всегда работают так, как хотелось бы (например, не поддерживают какой-то из менеджеров зависимостей; строят неинформативный граф; и тд)

      Склоняюсь к тому, что для этой задачи потребуется реализовать собственную утилиту, как это например сделали в Badoo.

      Может быть, у вас есть какие-то рекомендации на этот счет?


      1. akaDuality
        27.10.2021 16:03

        Я пишу утилиту для анализа, мой комментарий это такой каст-дев :-) Могу потом прислать бета-билд


        1. le-go Автор
          27.10.2021 16:57

          звучит здорово)


  1. maxkazakov
    25.10.2021 13:15
    +1

    Если при объявлении продукта-библиотеки ничего не указано, будет собираться статическая, а динамическая — только если указан тип .dynamic.

    Документация:

    Leave this parameter unspecified to let the Swift Package Manager choose between static or dynamic linking (recommended).

    На практике SPM выберет динамику, если библиотека слинкована с несколькими таргетами


    1. le-go Автор
      26.10.2021 12:32
      +1

      Спасибо за замечание!

      Действительно, большинство сторонних решений, которые поддерживают интеграцию через SwiftPM, следуют этой рекомендации и используют тип .automatic: он выбирается по умолчанию, если не указан явно тип .static или .dynamic.

      Но (как минимум, начиная с Xcode 11.4) в таком случае выбиралась статическая линковка и этому даже посвящено несколько тредов на форумах Swift, вот например, один из них. Мы сами столкнулись с проблемой, что даже при линковке зависимости с несколькими нашими таргетами автоматически выбиралась статическая, что приводило к ошибке дублирования символов. И чтобы исправить эту ошибку приходилось заворачивать зависимость в модуль, который бы линковался динамически. Я даже находила описание такого решения на GitHub.

      Вероятно, такое поведение могло быть исправлено в новых версиях Xcode, но на момент доклада мы использовали Xcode 12.5.1.

      А в какой версии Xcode удалось получить такое поведение?

      На практике SPM выберет динамику, если библиотека слинкована с несколькими таргетами


      1. maxkazakov
        12.11.2021 15:03

        Согласен, проблемы начинаются, когда появляются тесты.
        Я делал простые эксперименты без тестов: создавал проект с несколькими фреймворками, подключал SPM библиотеку в одному фреймворку, затем к нескольким. Поведение было корректное, как описано в документации, на любом Xcode начиная с 12.5.


  1. tercteberc
    25.10.2021 16:07

    Carthage только скачивает и собирает зависимости, но не встраивает их в проектный файл. Соответственно, это простой и гибкий инструмент

    А скоро мы совсем избавимся от Carthage, что давно хотели сделать.

    Получается вы давно мечтали избавиться от гибкого инструмента?) (шутка)

    Вопрос №1: Может быть стоило написать свою систему управления линковкой?) Некую генерацию конфигов для XcodeGen-а. ИМХО - это заняло бы намного меньше времени, чем переезжать на новый пакетный менеджер (как я понял еще и не закончили переезд).

    Вопрос №2: SPM складывает все в DerivedData. У iOS-разработчиков, со временем, вырабатывается привычка делать clean проекта / чистить кеш XCode. Соответсвенно все 40+ (!) зависимостей (пока их 25, но когда-то же там будут все, видимо) зачищаются и происходит их вытягивание по-новой + переиндексация проекта. Как живется с этим?)
    И сюда же - как правило, на CI перед сборкой тестов/прогоном сборки обязательно зачищается DerivedData. Каждый раз вытягиваете, или филигранно кешируете только то, что нужно?)


    1. le-go Автор
      27.10.2021 11:50

      Получается вы давно мечтали избавиться от гибкого инструмента?) (шутка)

      Да, звучит слегка контринтуитивно) Но гибкость как раз и состоит в том, что часть работы по настройке Carthage мы выполняли сами. Какое-то время он не требовал особых доработок, но уже ближе к Xcode 12 регулярно начали возникать проблемы, решение которых требовало бОльших временных затрат. Вот и решили от него избавиться.

      Вопрос №1: Может быть стоило написать свою систему управления линковкой?)

      Не очень поняла вопрос: что подразумевается под системой управления линковкой и какие конкретно проблемы она должна решить?

      Вопрос №2: SPM складывает все в DerivedData. ... Как живется с этим?)


      Локальная сборка:

      При холодной сборке шаг Resolve Swift Packages занимает в среднем около 70 сек. Чистить Derived Data нашим разработчикам требуется не часто, и пока что жалоб не было.

      Сборка на CI:

      Для команды xcodebuild можно указать путь, куда будут загружаться пакеты spm (это слепки репозиториев и артефакты):

      -clonedSourcePackagesDirPath PATH                        specifies the directory to which remote source packages are fetch or expected to be found

      Это и будет кэшом наших пакетов.

      Если используется Fastlane, то эту настройку можно передать прямо команде gym или в Gymfile (cloned_source_packages_path "PATH"). Для CI мы используем связку Fastlane + Github Actions, работа с кэшом производится с помощью экшена actions/cache@v2, которому мы передаем путь, указанный в cloned_source_packages_path. Так что этот кэш не затирается при очистке DerivedData.


      1. tercteberc
        27.10.2021 12:35

        Какое-то время он не требовал особых доработок, но уже ближе к Xcode 12 регулярно начали возникать проблемы, решение которых требовало бОльших временных затрат

        А каких именно проблем, если не секрет?)

        что подразумевается под системой управления линковкой и какие конкретно проблемы она должна решить?

        Опять же, не хочется додумывать за вас, но по всей видимости, если я правильно понял, то появились проблемы с тем, как линкуются статические и динамические зависимости, и что нужно в XCodeGen-конфиге руками указывать какую встраивать в ваш модуль/фреймворк, а какую нет например. И со временем, и кол-вом сторонних зависимостей становится сложно регулировать, что куда и как линковать.
        На что я и предложил, как идею, написать вспомогательное решение, которое занималось бы генерацией конфигов для XcodeGen-а (и как следствие проверкой что куда и как слинкованно в проекте).


        1. le-go Автор
          27.10.2021 14:45

          А каких именно проблем, если не секрет?)

          Из того, что первым приходит на ум:

          1) при переезде на Xcode 12 Carthage не мог собрать ни одну зависимость, их сборки падали с ошибкой: "the same architectures (arm64) and can't be in the same fat output file". Исправилось все вот этим костылем, потому что мы не были готовы перевести зависимости в xcframework-и на тот момент.

          2) при переезде на Xcode 12.3 перестали собираться тестовые таргеты на симулятор, вылезала ошибка:

          Building for iOS Simulator but the linked library was built for Mac OS + iOS

          Связано это было с тем, что зависимости собирались в universal framework-и, и для тестовых таргетов из них не убирались слайсы для лишних архитектур. Самостоятельно доработать в разумные сроки шаг удаления лишних архитектур не удалось, поэтому ждали обновления Carthage, чтобы поднять версию Xcode.

          Также в большей части зависимостей был конфликт ключа VALID_ARCHS (deprecated с Xcode 12) и нового ключа EXCLUDED_ARCHS. Это была проблема самих зависимостей, но так вышло, что именно те, что были подключены через Carthage, еще долго не поддерживали Xcode 12 и нам приходилось использовать форки с исправлениями.

          Какие-то проблемы с переездом были у CocoaPods, но решались быстрее. А делать и в будущем такую двойную работу больше не хотелось


        1. le-go Автор
          27.10.2021 15:04

          если я правильно понял, то появились проблемы с тем, как линкуются статические и динамические зависимости, и что нужно в XCodeGen-конфиге руками указывать какую встраивать в ваш модуль/фреймворк

          Нет, с самим XcodeGen, к счастью, проблем вообще не возникло, там и поддержка xcframework быстро появилась (мы в итоге зависимости из Carthage перевели на xcframework-и для Xcode 12.5), и можно легко выбрать нужный способ интеграции зависимости:

          dependencies:
          # можно так
            - carthage: Alamofire
          # или так
            - package: Alamofire
          # или так
            - framework: ${C_BUILD_PATH}/Alamofire.xcframework
          # или вот так
            - framework: ${C_BUILD_PATH}/Alamofire.xcframework
            - linkType: dynamic # | static


    1. kdavydenko
      27.10.2021 11:57

      В SPM можно настроить директорию где хранить зависимости, и ни чего вытягивать каждый раз не придется


      1. tercteberc
        27.10.2021 12:41

        А можете рассказать, как это сделать?)