Привет, Хабр!
Меня зовут Вильян Яумбаев, в этой статье я расскажу вам про наши приключения на пути к 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 нам тоже совершенно необходимо поддерживать, потому что наши коллеги из приложения розничного банка используют только его. У нас есть два пути:
Поддерживать и SPM, и CocoaPods для общих репозиториев.
Переводить существующие зависимости на 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-пакеты, но придётся отказаться от проверки покрытия. Это нас не устраивает. Что остаётся?
Отключить учёт покрытия.
Остановиться и бросить SPM.
Отказаться от AFNetworking.
Первый неприемлем: теряем представление о покрытии тестами. Второй проигрышный априори, сдаться проще всего. Третий вариант непрост в реализации. У нас используется древняя версия AFNetworking 2.7.0, поэтому просто отказаться не получится, нужно перейти с него на что-то другое.
Идём сложным — третьим — путём и отказываемся от AFNetworking. Вы можете сказать, что это затратное решение, потому что сетевой слой пронизывает всё приложение. Но наши славные предки не просто подключили AFNetworking в проект, а сделали свою обёртку над сетевым слоем в виде CoreNetwork. Значит, мы сможем избавиться от него с большими усилиями: подменим реализацию работы с сетью внутри самого CoreNetwork.
Спойлер про ___llvm_profile_runtime
Как позже выяснилось, можно использовать Objc библиотеки с подсчетом ковераджа. Нагуглив похожую проблему, мы попробовали реализовать у себя этот подход на других Objc библиотеках, и у нас получилось. Что конкретно меняется — можно увидеть в этом коммите, в уже знакомом репозитории. Но переход от AFNetworking к своей реализации был обусловлен не только ошибкой компиляции при коверадже, поэтому переход все равно состоялся.
Можно было обратиться к готовым решениям, таким как Alamofire — или написать своё на URLSession. Выбрали второе. Про процесс переписывания и что нужно учитывать при разработке своего сетевого слоя, напишу позже в отдельной статье.
Мы уже переписали сетевой слой на свою реализацию. Кажется, дальше у нас карт-бланш на SPM во всём проекте.
Хотелось бы верить, но верится с трудом. Никогда не знаешь, что тебя ждёт в будущем: вдруг уже на следующем модуле появится новая проблема, которая всё сломает, и мы опять вернёмся на CocoaPods. Надеюсь, такого не случится. А отбросить эти сомнения мне помогает моя команда ПСБ.
Комментарии (5)
Speakus
01.07.2022 22:16+1Прочитал всю статью с интересом. Напомнило мне времена когда многие начинали использовать swift 2.0 (ведь модно!) но на практике баги были в самом языке. :) что-то подобное например прикрутить будет в spm не так легко, как в подах: https://habr.com/ru/post/674550/
WillianLike Автор
02.07.2022 01:18Спасибо. Была бы необходимость, реализация не заставит ждать ????
У Cocoapods насколько помню в подспеках можно указывать лицензию, благодаря этому можно с небольшими усилиями написать даже свой парсер. А вот у SPM такого свойства нет, ничего лишнего, только код ????
ws233
02.07.2022 17:15Кажется, что одно из перечисленных приложений (или даже оба) должно справиться с этим:
1. https://github.com/nicklockwood/Tribute
2. https://github.com/carloe/LicenseGenerator-iOS
DevilDimon
Я правильно понял, что у вас разные модули iOS-приложения лежат в разных git-репозиториях?
Если да, то плюсов у этого решения не то что бы много, зато неудобств столько, что борьба с ними занимает полстатьи. В монорепозитории как минимум можно было бы не думать о версионировании, а Cocoapods бы завёлся с первой попытки.
Однако, тут возникают свои проблемы - Cocoapods (как и SPM и даже Carthage) плохо умеет в кэширование модулей между машинами разработчиков, поэтому начиная с определенного размера приложения и команды они перестают скейлится (из-за времени чистой сборки). Я видел, что в такой ситуации обычно переходят на более высокоуровневые сборочные системы (Buck, Bazel, Tuist). Не рассматривали такие?
WillianLike Автор
Да, часть модулей находится в выделенных репозиториях. Это необходимо для переиспользования библиотек между приложениями. Да к сожалению мультирепа дает неудобства в виде контроля версионирования, но это палка с двумя концами, где на втором конце это контроль кода и разделение на домены. Поэтому тут нельзя дать однозначно верного ответа, к сожалению “depends on”. Если только одно приложение, то конечно проще будет использовать монорепу.
Мы не выносим все модули в отдельные репозитории, но оставляем такую возможность в будущем. По поводу того что поды заведутся в монорепе легко - да. В целом это простая альтернатива для модульности по сравнению с модулями на хкод проектах. На самом деле и SPM хорошо работает в монорепе, только есть другие проблемы, с которыми мы как раз и сталкивались.
По поводу Bazel и прочих, нет не пробовал, даже на маленьком тестовом проекте. Возможно углубившись подумаем над переходом, но что-то подобное должно произойти в будущем, либо SPM станет настолько же крутым ????.
Для сокращения времени сборки думаю можно использовать любой из менеджеров который позволяет использовать скомпилированные библиотеки, то биш бинари. А для быстрых локальных правок должна быть возможность переключения между использованием готового бинаря и компилируемого локального кода. Как конкретно это можно сделать пока не знаю, но навскидку кажется выполнимой задачей.
Также для косвенного сокращения чистой сборки можно не держать одно большое приложение. Как я писал в статье у нас деление на продуктовые модули и для каждого модуля есть свое демо приложение, которое компилируется гораздо быстрее в тестовом энвайроменте. Чаще всего мы ведем разработку в них, нежели в основном приложении, но да, проблема скорости компиляции всего приложения никуда не уходит ????