Привет, Хабр!

Меня зовут Вильян Яумбаев, в этой статье я расскажу вам про наши приключения на пути к SPM.

В 2015 ПСБ начал разрабатывать проект для бизнеса. Для него, в свою очередь, было нужно приложение. Сперва всё находилось в одном репозитории одного проекта в одном воркспейсе. Первые авторы подключали сторонние зависимости через CocoaPods, поскольку проприетарного менеджера зависимостей ещё не существовало. Но в тот же год в Apple началась работа над Swift Package Manager. Им предстояло встретиться в нашем проекте.

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

Пора меняться

Изначально код проекта состоял из Objective-C. Затем разработчики перешли на Swift, но древний код атлантов никто не переписывал, ибо работает — не трогай. Количество разработчиков увеличивалось с каждым годом.

Пришло время, и к проекту присоединился автор этого текста. «Пора меняться», — вот что вспыхнуло в моей голове, когда я взглянул на проект. Тогда я увидел его ужасное будущее, уготованное ему историей Git’а, конфликтами слияния и ежесекундным обновлением мастер-ветки. Оставался лишь один путь избежать этой участи — взять курс на модульность.

Первый модуль

Главными вопросами стали «С чего начать?» и «Где взять время?». У всех нас были в работе собственные бизнес-юниты. Нужно было договориться о времени на такого рода задачи. В итоге решили, что на технические задачи уйдёт 20% времени каждый спринт, два дня в спринт.

Чтобы определиться, собрался совет верховных магов iOS-технологий. Ответ был взвешенным и давал надежду на дальнейшую жизнь проекта: начнём с простого и постепенно будем переходить к сложному.

Мы сформировали стратегию. Она подразумевала, что у нас будут общие модули утилит, а также основные продуктовые модули, относящиеся к конкретному бизнес-функционалу. Но для того, чтобы разделить проект на такие фреймворки, нужно сначала вынести все их зависимости.

Мы начинали с xcodeproj для отдельных модулей, чтобы со временем перейти на менеджер зависимостей. Первым стал CoreNetwork как самостоятельная единица — пример того, что проект ещё можно спасти, если двигаться в этом направлении. Тогда, 19 ноября 2019, был взят курс на перестройку монолитного проекта ПСБ в модульную систему. Мы представляли, какой объём работ нас ожидает, но не страшились его: если не преодолевать трудности, то зачем вообще писать код?

При выносе первого модуля без проблем не обошлось: проект был написан на Swift и на Objective-C. И если с первым было всё просто, то со вторым пришлось изрядно попотеть.

После выноса первого модуля мы выработали определённые прикладные практики по выносу модулей, по разделению функционала, по решению проблем, связанных с выносом. Модули полились рекой — или ручейком, маленьким и узким — и спустя год мы уже имели восемь общих и одиннадцать продуктовых фреймворков. Мы создали иерархию модулей по уровням  L0, L1, L2 и так далее, установив такое правило: модуль может иметь зависимость только на модули уровнями ниже, чтобы граф зависимостей не был цикличен. Чтобы удобно просматривать их, в Xcode workspace завели группы: L0, L1, L2 и так далее.

Менеджер зависимостей для мультирепозитория

Ещё в начале процесса мы поставили цель: разнести все модули в отдельные репозитории. Когда количество модулей более-менее устаканилось, начали думать в эту сторону. Вариантов было немного:

Git Submodules: до боли простой в использовании, но не совсем удобен в разработке, да и особой практики в использовании нет.

Carthage: у нас не было практики использования в проекте и казалось переходить на него будет не просто. Опять же, не было практики.

CocoaPods: хороший кандидат, но есть сложности с версионированием. Наши модули находятся в приватных репозиториях, а для того, чтобы устроить нормальное версионирование приватных репозиториев, нужно завести приватный репозиторий со спеками. Это и есть проблема: при выходе новой версии модуля мало отправить новую версию в репозиторий самого фреймворка, нужно ещё обновить её в репозитории со спеками. А ещё есть сложность с отладкой монолита приложения, когда надо проверить локальные изменения кода на лету. Для этого придётся изменять Podfile, проводить pod install и потом не забыть вернуть всё обратно.

SPM: наш вариант, на который хотелось бы перейти, «пока не поздно». Те же преимущества, что и у CocoaPods, и нет проблем с выпуском: очень простая отладка мультирепозиторного приложения и всё версионирование идёт по тегам в Git'е. Для проверки локальных изменений достаточно папку с модулем переместить в workspace приложения, и можно редактировать модуль и отлаживать его на лету. Так же просто вернуть всё как было — удалить модуль из воркспейса, и подтянется версия из Git’а.

Из этих менеджеров зависимостей мы очень хотим перейти именно на SPM. О нём задумывались ещё в начале 2020 года, но у него были сложности с поддержкой Objective-C кода и ресурсов. Со временем SPM решил их.

SPM vs CocoaPods

В начале 2021 года по заверениям команды SPM и по опыту других компаний, проблема с Objective-C решена, и даже можно включить ресурсы в содержимое пакета. Эта фича нам необходима для модуля дизайн-системы, так как в ней находятся переиспользуемые картинки. Представим, что SPM — это бутерброд в прозрачном контейнере, и попробуем его на вкус.

Начинаем с небольшого модуля с утилитными объектами, которые используются по всему проекту. Называем его PSBCore (или просто корой). Пробуем его перевести на SPM. Для перевода модуля xcodeproj в SPM получился небольшой shell сниппет:

module="PSBCore"; \ 
swift package init; \ 
rm -rf "Sources/$module/$module.swift"; \ 
rm -rf "Sources/${module}Tests/${module}Tests.swift"; \ 
cp -rf $module Sources; \ 
rm -rf $module; \ 
cp -rf ${module}Tests Tests; \ 
rm -rf ${module}Tests;

Пробуем собрать сам пакет — всё ок, компилируется. У нас модули были пролинкованы вручную, поэтому с новым SPM-пакетом надо проделать то же самое — вручную пролинковать во все модули, в которых используется PSBCore.

Собираем монолит — не работает. Пишет про ошибки «Module ‘PSBCore' not found», при этом ошибка указывает на генерируемые файлы Module-Swift.h — там, где в строчках идёт импорт библиотеки @import PSBCore. Module-Swift.h нам нужны, поскольку в проекте Swift + ObjC.

Неужели проблема с Objective-C не решена? И почему он жалуется именно на импорт PSBCore?

На руках имеем такую картину: SPM на 100% работает с кодом ObjC, так как у коллег из розничного приложения ПСБ есть целый SPM-пакет на ObjC. Но у них полноценный пакет на ObjC и там нет смешивания Swift + ObjC. Значит, SPM может в ObjC — это выяснили.

Следующий момент: в одном из продуктовых модулей приложения для юридических лиц одна из внутрибанковских библиотек через SPM уже подключена, при этом компилятор на неё не жалуется. Хм. Значит, проблема не в SPM и не в Objective-C.

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

Возвращаемся к CocoaPods

Роем в сторону приватного репозитория с подспеками. Вроде ничего сверхъестественного.

  • Да, нужно каждую версию деплоить в репозиторий со спеками.

  • Да, это сложнее, чем просто поставить тег на мастер-ветку, как в SPM.

  • Да, придётся повозиться с удалением или перезаписью версии, если что-то пошло не так.

Есть перечень минусов CocoaPods, в которых он проигрывает SPM по удобству, но мы уже выяснили, что SPM не работает в проектах со смешанными языками (спойлер — это не так). Уже смирились, что возвращаемся в каменный век, где описание зависимостей проводится на Ruby в CocoaPods, и тут сталкиваемся с ещё одной проблемой, которая важна для нас — версионирование подов для разных релизов. Перед этим рассмотрим, как проходит контроль версий.

Про версионирование в релизном цикле

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

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

В такой диаграмме зависимостей, если номер версии в модуле А подняли до 2.0.0, нам придётся все связи upToNextMajor* тоже поднять до 2.0.0 — для каждой связи. Даже если мы внесли изменения в модуль А, которые хотели использовать в модуле С, а для модуля В они не нужны.

* upToNextMajor обозначает правило связи между модулями. upToNextMajor 1.0.0 от модуля B к модулю A обозначает, что модуль B может использовать все версии модуля A до следующей мажорной версии 2.0.0.

Иначе граф не скомпилируется, так как в конечном итоге приложение не может использовать одновременно две версии модуля. Соответственно нам необходимо в модуле В теперь тоже поддержать новую версию 2.0.0. Теперь, поскольку мы используем новую, обратно несовместимую версию, для модуля В мы тоже поднимаем номер версии — и так до самой вершины графа, до приложения.

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

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

Контроль кода, уходящего в релиз

Представим, что у нас есть те же модули А, В, С и приложение. Мы провели фиксацию приложения по всем модулям, и связи изменились. Назовём этот релиз Alpha.

При фиксации релиза нам нужно закрепить версии пакетов, используемые в проекте. Делать это лучше напрямую в зависимостях приложения. Какие связи мы установим в приложении, такие и будут использоваться во всём проекте. Мы указали версию модуля А exact 1.0.0 — значит, даже если в нём будет повышена версия, модули С и В тоже будут использовать 1.0.0.

Теперь разработчики в своих модулях могут продолжать работу, поднимать версии, добавлять фичи для следующего релиза. Назовём его Beta. Затем в модуле А добавили крупные правки; номер его версии подняли до 2.0.0. Граф для обоих релизов корректный, для Alpha у нас используется модуль А 1.0.0, а для Beta — 2.0.0.

А теперь представим ситуацию, когда нам прилетел баг с регресс-тестирования Alpha. Нужны изменения в модуле А, но там небольшой фикс, который не ломает обратную совместимость. Нужно только поднять патчевую версию до 1.0.1. По GitLabFlow все изменения сначала делаются на мастер-ветке, и дальше фикс доносится до релизных веток. 

Делаем изменения в мастер-ветке модуля А, поднимаем версию до 2.0.1 (в мастер-ветке модуля уже была версия 2.0.0, а мы внесли наш багфикс). Но т.к. багфикс относится к релизу Alpha в котором нам не нужны изменения в коде из следующего релиза Beta, нам остаётся только создать новую ветку regress_Alpha в модуле А от версии 1.0.0 и уже на ней сделать багфикс. Ставить версию 1.0.1 на ветку regress_Alpha нельзя — в дальнейшем это приведёт к путанице. Нужно относится к версионированию как к Стеку, мы можем добавлять версии поверх последней, мы не можем добавлять версии в середину стека. Теперь расстановка версий выглядит примерно таким образом:

Повторяем то же самое во всех модулях, где понадобятся хотфиксы в релизе Alpha. Версии контролируются, функционал из Beta не попадает в Alpha.

Возвращаемся к CocoaPods

У нас двухнедельные релизы, и пока тестируется зафиксированный релиз, мы всегда работаем над следующим. Иногда бывает, что для разграничения функционала нужны дополнительные релизные ветки. Пример подобного — в предыдущем разделе статьи.
В таком случае в SPM можно ставить разные связи для разных репозиториев. То есть для модуля А поставили ветку, для других модулей просто зафиксировали конкретную версию. У SPM-пакетов есть особенность: в их описании можно ставить зависимости не только по версиям, но и по веткам и отдельным тегам. То есть в описании самого пакета мы можем устанавливать зависимости на конкретные ветки. В CocoaPods спеках так нельзя, в нём можно использовать только версии.

Pod::Spec.new do |s| 
 # package requirements 
 # Валидный вариант 
 s.dependency 'ModuleA', '~> 1.0' 
 # Невалидный вариант 
 s.dependency 'ModuleA', :branch => 'regress_Alpha', :git => 'https://link-to-git/modulea.git' end
end

На самом деле такая запись и не нужна. В SPM-пакетах её тоже стоит избегать и всегда основываться на версиях, потому что и в SPM, и в CocoaPods мы задаём правила в самом приложении.

Для SPM это делается в настройках проекта в разделе Swift Packages, в CocoaPods же — внутри Podfile.

target 'Application' do 
 pod 'ModuleA', :branch => 'regress_Alpha', :git => 'https://link-to-git/modulea.git'  pod 'ModuleB', '1.0' 
 pod 'ModuleC', '1.0' 
end 

Мирное соглашение SPM and CocoaPods

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

  1. Поддерживать и SPM, и CocoaPods для общих репозиториев.

  2. Переводить существующие зависимости на SPM.

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

У нас уже есть выделенные продуктовые модули и общие модули сетевого слоя и дизайн-системой. Все эти модули организованы как проекты Xcode в виде динамических библиотек, и их как-то нужно перевести на SPM. Чтобы организовать рабочие модули в SPM, надо перевести на него все зависимые библиотеки: как наши локальные, так и внешние библиотеки свободного ПО. Если в них это не сделано, конечно же.

Вернёмся к PSBCore и его оформлению в виде SPM. В прошлый раз мы видели ошибки «Module ‘PSBCore' not found» и подумали, что проекты со смешанными языками не будут работать.

Создав тестовый проект для воспроизведения ошибки и перебрав варианты, я нашел, что проблема возникает, когда есть как минимум три модуля. Модуль A — SPM-пакет. Модуль B — Xcodeproj модуль с зависимостью на модуля А и приложение, которое использует модули А и B одновременно.

И если в модуле А есть типы, которые могут быть доступны в ObjC (например, наследники NSObject, протоколы или enum’ы @objc), а в модуле B идёт наследование от этих типов из модуля А, мы получаем ошибку. Пример можно посмотреть в репозитории.

Ок, проблему поняли. Начинаем избавляться от наследования ObjC-классов. Но это непросто, потому что все базовые UIKit сущности — наследники NSObject, а в DSKit таких сущностей пруд пруди.

Наследование с некоторыми ухищрениями можно заменить композицией — таким путём мы и пошли, создав себе кучу задач по избавлению от наследования из-за ObjC. Но тут наш руководитель нашёл корень проблемы: если убрать публичный заголовок ObjC в модуле B, то проблема с наследованием уходит и можно спокойно переходить на SPM. В этом коммите можно посмотреть проделанные изменения.

Потом мы поймали другой баг, связанный с использованием смешанных языков. Например, у нас есть тип в модуле А или B, попадающий в ObjC. При использовании forward declaration в хедере ObjC-класса мы можем указать там этот тип. Например, в модуле А будет swift класс SwiftClassA наследник NSObject, в приложении будет ObjC-класс и в его заголовке будет объявлено @property SwiftClassA *swiftclass; .

Тогда в коде ObjC это свойство будет видно в полной мере, все другие классы ObjC смогут к нему обратиться, но в Swift оно будет недоступно. Я не понял, с чем это связано, и завел вопросы на Stack Overflow и Swift Forum. В этом коммите можно посмотреть, как выглядит ошибка.

Может быть, ко времени, когда вы это прочтёте, вопрос будет решён. Но я сомневаюсь: учитывая актуальность проблемы, а точнее её отсутсвие, в большинстве проектов, проще переписать на Swift, либо оставаться в монолите. Эту проблему удалось решить только смекалкой и переписыванием таких классов с ObjC на Swift.

Лайфхак такой: если у класса ObjC есть такие свойства, то их можно перенести в сущность Swift. Тогда они будут доступны как в ObjC, так и в Swift. Пример действий в этом коммите. Так же можно поступить с объявленными функциями ObjC, или поступить более радикально и переписать на Swift весь класс.

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

AFNetworking

Казалось, можно идти дальше, все беды позади, а дальше — радужные пони, смузи и все модули на SPM. Но эти мечты разбились об айсберг https://github.com/apple/swift/issues/57137 — linker error when code coverage is turned on for Swift Package with Objective-C code.

Пример, где добавляется Objc библиотека с выключенным ковераджем, в этом коммите. В нем все запускается и тестируется. А вот в этом коммите включаем коверадж для таргета тестов, и у нас появляется ошибка компиляции.

Ошибка выглядит таким образом:

Undefined symbols for architecture x86_64:
  "___llvm_profile_runtime", referenced from:
      ___llvm_profile_runtime_user in ObjcPackage.o
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

Мы пишем тесты, учитываем их покрытие и на мердж реквестах учитываем это самое покрытие. Если привнесённый код ухудшает покрытие, то сливать ветки нельзя. Исключения бывают, но они редки. Основной поток задачи такой: пишешь код, потом пишешь тесты, пайплайн проверяет, что покрытие не ухудшилось, и пускает тебя в мастер-ветку.

Так вот, соль этого бага SPM: тесты не работают с включенным учётом покрытия, если одна из зависимостей — модуль на ObjC. Понятно, что код ObjC можно оборачивать в SPM-пакеты, но придётся отказаться от проверки покрытия. Это нас не устраивает. Что остаётся?

  1. Отключить учёт покрытия.

  2. Остановиться и бросить SPM.

  3. Отказаться от AFNetworking.

Первый неприемлем: теряем представление о покрытии тестами. Второй проигрышный априори, сдаться проще всего. Третий вариант непрост в реализации. У нас используется древняя версия AFNetworking 2.7.0, поэтому просто отказаться не получится, нужно перейти с него на что-то другое.

Идём сложным — третьим — путём и отказываемся от AFNetworking. Вы можете сказать, что это затратное решение, потому что сетевой слой пронизывает всё приложение. Но наши славные предки не просто подключили AFNetworking в проект, а сделали свою обёртку над сетевым слоем в виде CoreNetwork. Значит, мы сможем избавиться от него с большими усилиями: подменим реализацию работы с сетью внутри самого CoreNetwork.

Спойлер про ___llvm_profile_runtime

Как позже выяснилось, можно использовать Objc библиотеки с подсчетом ковераджа. Нагуглив похожую проблему, мы попробовали реализовать у себя этот подход на других Objc библиотеках, и у нас получилось. Что конкретно меняется — можно увидеть в этом коммите, в уже знакомом репозитории. Но переход от AFNetworking к своей реализации был обусловлен не только ошибкой компиляции при коверадже, поэтому переход все равно состоялся.

Можно было обратиться к готовым решениям, таким как Alamofire — или написать своё на URLSession. Выбрали второе. Про процесс переписывания и что нужно учитывать при разработке своего сетевого слоя, напишу позже в отдельной статье.

Мы уже переписали сетевой слой на свою реализацию. Кажется, дальше у нас карт-бланш на SPM во всём проекте.

Хотелось бы верить, но верится с трудом. Никогда не знаешь, что тебя ждёт в будущем: вдруг уже на следующем модуле появится новая проблема, которая всё сломает, и мы опять вернёмся на CocoaPods. Надеюсь, такого не случится. А отбросить эти сомнения мне помогает моя команда ПСБ.

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


  1. DevilDimon
    01.07.2022 14:17
    +2

    Я правильно понял, что у вас разные модули iOS-приложения лежат в разных git-репозиториях?

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

    Однако, тут возникают свои проблемы - Cocoapods (как и SPM и даже Carthage) плохо умеет в кэширование модулей между машинами разработчиков, поэтому начиная с определенного размера приложения и команды они перестают скейлится (из-за времени чистой сборки). Я видел, что в такой ситуации обычно переходят на более высокоуровневые сборочные системы (Buck, Bazel, Tuist). Не рассматривали такие?


    1. WillianLike Автор
      02.07.2022 01:13

      Да, часть модулей находится в выделенных репозиториях. Это необходимо для переиспользования библиотек между приложениями. Да к сожалению мультирепа дает неудобства в виде контроля версионирования, но это палка с двумя концами, где на втором конце это контроль кода и разделение на домены. Поэтому тут нельзя дать однозначно верного ответа, к сожалению “depends on”. Если только одно приложение, то конечно проще будет использовать монорепу.

      Мы не выносим все модули в отдельные репозитории, но оставляем такую возможность в будущем. По поводу того что поды заведутся в монорепе легко - да. В целом это простая альтернатива для модульности по сравнению с модулями на хкод проектах. На самом деле и SPM хорошо работает в монорепе, только есть другие проблемы, с которыми мы как раз и сталкивались.

      По поводу Bazel и прочих, нет не пробовал, даже на маленьком тестовом проекте. Возможно углубившись подумаем над переходом, но что-то подобное должно произойти в будущем, либо SPM станет настолько же крутым ????.

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

      Также для косвенного сокращения чистой сборки можно не держать одно большое приложение. Как я писал в статье у нас деление на продуктовые модули и для каждого модуля есть свое демо приложение, которое компилируется гораздо быстрее в тестовом энвайроменте. Чаще всего мы ведем разработку в них, нежели в основном приложении, но да, проблема скорости компиляции всего приложения никуда не уходит ????


  1. Speakus
    01.07.2022 22:16
    +1

    Прочитал всю статью с интересом. Напомнило мне времена когда многие начинали использовать swift 2.0 (ведь модно!) но на практике баги были в самом языке. :) что-то подобное например прикрутить будет в spm не так легко, как в подах: https://habr.com/ru/post/674550/


    1. WillianLike Автор
      02.07.2022 01:18

      Спасибо. Была бы необходимость, реализация не заставит ждать ????

      У Cocoapods насколько помню в подспеках можно указывать лицензию, благодаря этому можно с небольшими усилиями написать даже свой парсер. А вот у SPM такого свойства нет, ничего лишнего, только код ????


    1. ws233
      02.07.2022 17:15

      Кажется, что одно из перечисленных приложений (или даже оба) должно справиться с этим:
      1. https://github.com/nicklockwood/Tribute
      2. https://github.com/carloe/LicenseGenerator-iOS