Как стать автором
Обновить
420.6
Рейтинг
Яндекс
Как мы делаем Яндекс

Тернистый путь внедрения Swift Package Manager. Доклад Яндекса

Блог компании ЯндексРазработка под iOSРазработка мобильных приложенийObjective CSwift
Доклад будет интересен iOS-разработчикам, которые хотят внедрить технологию Swift Package Manager (SPM) в существующий проект. Руководитель iOS-разработки Яндекс Go Вадим Белотицкий рассказал о причинах, по которым его команда решила внедрять SPM, и о решении возникших проблем, включая:

  • Проблемы с компиляцией
  • Сочетание Swift- и Objective-C-кода
  • Падения, связанные с некорректной линковкой проекта
  • Сочетание двух менеджеров зависимостей — CocoaPods и SPM
  • Проблемы сборки на CI (TeamCity)

Сам процесс внедрения рассмотрен поэтапно, начиная с примеров Apple и тестовых примеров к первым шагам по внедрению (созданию первого модуля с генерацией проекта) и вплоть до состояния приложения сейчас.

— Я хочу рассказать о механизме модуляризации, который мы выбрали в нашем проекте: Swift Package Manager. Расскажу о том, что такое SPM, как мы его внедряли, какие ошибки совершили и к какому результату пришли. Цель доклада — показать, что SPM — достаточно взрослая технология и ее можно использовать в продакшене iOS-разработки.

Доклад будет состоять из четырех частей. Сначала я постараюсь погрузить вас в контекст нашего приложения. Сначала это будет Яндекс.Такси, в конце оно превратится в Яндекс Go. Потом расскажу об SPM и менеджерах зависимости. Далее рассмотрю наш путь пошагово — какие ставились задачи, требования. И в конце подведу итог, к чему мы пришли.

Контекст


Итак, о нашем приложении. В Яндекс.Такси мы занимались разработкой не только Такси, но и многих других приложений. Например, Yango (это международный бренд Такси), Лавки, а также разрабатывали группу приложений MLU, которые тоже предназначены для вызова такси.



Все эти приложения мы собирали из одной ревизии, из одних и тех же исходников. Поэтому под ними была общая Core-часть, которая называлась YandexTaxiCore — статическая библиотека, сделанная в Xcode. Там была написана основная логика функциональности и общие компоненты, это был большой монолитный кусок. Его можно было сконфигурировать с помощью специальных дополнений, промежуточных статических Xcode-библиотек, тоже сделанные в виде Xcode-таргетов. Superapp — эта штука заезжала в Такси и превращала приложение для заказа такси в суперапп. Библиотека YandexTaxiLike использовалась в Такси и в Yango. Приложение Лавки не имело промежуточной библиотеки, потому что оно одно было standalone в семействе приложений и напрямую конфигурировалась из YandexTaxiCore. Приложения из группы MLU тоже имели под собой статическую библиотеку.

Помимо этой статической конфигурации у нас были внешние зависимости, такие как AppMetrica или Yandex Mapkit, мы подключали их с помощью CocoaPods.

Что же мы имели вначале нашего пути? Много таргетов, много приложений, которые мы собираем из одних исходников и отправляем в App Store, набор статических библиотек, сделанных с помощью Xcode-проекта, и внешние зависимости, подключаемые с помощью СocoaPods. А сам YandexTaxiCore представлял из себя большой монолит, написанный одновременно на Swift и на Objective-C. Физически там не было разделения на модули, но логически они были. Экран, поездки, summary заказа, выбор адреса, меню — это всё были логические модули.

Итак, мы хотели модуляризировать наш огромный кусок YandexTaxiCore. Зачем нам нужна была модуляризация? Чтобы:

  • Ускорить разработку. И этого ускорения мы хотели достичь не за счет того, что проект начнет быстрее компилироваться или индексироваться, а за счет того, что каждый модуль в отдельности можно будет разрабатывать с помощью example-приложения, где легко будет проверить всю доступную функциональность модуля, допилить новую и легко отладиться в случае нахождения багов.
  • Упростить сопровождение, в том числе за счет того, что у нас появятся физические границы между модулями и к этим физическим границам можно будет добавить code owners. Code owners смогут оперативно решать вопросы, которые возникают в процессе работы модуля, и смогут консультировать коллег, если в эти модули потребуется внести изменения.
  • Повысить качество приложения. Модуляризация — общепризнанная практика, которая позволяет улучшать качество. В бэкенде это тоже используется, микросервисная архитектура. И у нас бэкенд использовал микросервисы. Логично, если бы мы тоже разделили приложение на модули, которые бы хорошо работали. Изменения в каждом отдельном модуле не сломают приложение в целом. А еще можно построить систему feature toggles, которая может позволить отключать отдельные модули.

Какие подходы мы могли использовать в модуляризации?

  1. Xcode-проект: нарезать существующий код с помощью workspaces, projects, targets.
  2. CocoaPods. Через него мы уже тащили внешние зависимости. Так почему бы с помощью него не напилить наш код на модули?
  3. Swift Package Manager.

Такие варианты, как Carthage, мы не рассматривали потому, что не хотелось тянуть еще какую-то не эппловскую third-party-технологию. Кастомные билд-системы тоже показались нам слишком сложными.

Почему мы не остановились на Xcode-проекте? Первая причина: сложно добавить модули. Необходимо завести новый таргет, настроить его, потом настроить граф зависимостей, чтобы правильные библиотеки зависели друг от друга. Сложно сделать из одного и того же таргета и динамическую, и статическую библиотеку, если понадобится. Нужно работать через графический интерфейс Xcode, а проектный файл сложно отредактировать. Xcode у нас уже тормозил, потому что в проектном файле было более 20 тысяч строк кода.

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

Почему не CocoaPods? У него свои особенности. Тоже иногда приходится что-то допилить, например, в post-install-фазе, чтобы проекты собирались. Второй недостаток: можно сделать Mixed Framework, который написан на двух языках одновременно — на Objective-C и Swift. С одной стороны, это кажется довольно удобным, и мы можем одновременно писать новую функциональность в легаси-коде на Objective-C, туда же добавлять что-то на Swift и таким образом постепенно переводить модули. Но могут встретиться проблемы при индексации таких проектов в Xcode и при работе с AppCode. В 2018 году он, по-моему, не переваривал mixed-фреймворки. А если переваривал, то все время что-то отваливалось и код в AppCode писать было невозможно, если подключались такие зависимости.

Третья проблема — Ruby. Если хочется что-то допилить в CocoaPods или понять, как оно работает, необходимо открывать исходники, читать их. И если они написаны на Ruby, то сделать это довольно сложно, даже несмотря на то, что Ruby — по крайней мере, пока не было Swift — считался вторым языком iOS-разработчиков. Последняя сложность — на мой взгляд, довольно сложно написать Podspec, там много параметров, надо выучить синтаксис. Процесс, наверное, можно автоматизировать, но тоже не хочется заниматься осваиванием языка Podspec.

Последний вариант — это SPM. У нас в проекте уже работали тулзы на SPM. Мы писали скрипты, которые автоматизировали нашу работу на Swift. Зависимости между этими скриптами мы устанавливали с помощью SPM, и технология работала. Показалось, что пора использовать SPM. Если в первых релизах SPM было невозможно использовать UIKit и нельзя было указать платформы, на которых должна работать библиотека, то когда мы планировали использовать SPM, все это стало возможно.

Третий плюс: SPM написан на Swift. Декларация зависимостей и описание пакетов происходит на Swift. Это очень близко iOS-разработчикам. Также есть интеграция с Xcode, очень удобно.

И последняя причина, по которой можно смотреть на SPM: с него можно откатиться на CocoaPods. Семантика Swift-пакетов не совпадает с семантикой spec CocoaPods, но они очень близки, и опыт опенсорсных библиотек, таких как Alamofire и Moya, показывает: можно иметь декларации пакетов для двух менеджеров зависимостей и все это работает. (...)

Немного об SPM


Начну я с того, что расскажу о менеджерах зависимостей в целом.


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

За вторую часть отвечает уже менеджер зависимостей, это код зависимостей, который нужно подтянуть. И lock-файл, в котором записывается результат resolve-кода зависимостей, записываются те версии, которые использовались при подключении зависимостей.

Как подключить зависимости на SPM к проекту?



Вот код проекта. Переходим во вкладку Build phases и добавляем бинарную зависимость.



Можно выбрать из тех фреймворков, которые предоставляет Xcode, а можно нажать на стрелочку, и появится выпадающая опция — package dependency.



После этого открывается окно, в нем мы можем указать путь к зависимости, путь к git-репозиторию. Но локальный пакет с помощью такой опции добавить нельзя, его нужно будет добавлять просто с помощью workspace. Я потом расскажу, как это делается.

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



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



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



У нас сформировался Manifest, он находится в проектном файле. Его можно найти, если нажать на Project, там есть вкладка Swift Packages. Manifest — это аналог pod-файла.



Посмотрим, где находится наш lock-файл. Он называется package.resolved. Его надо обязательно коммитить в репозиторий, чтобы все участники процесса разработки и сервера continuous integration использовали одни и те же версии зависимостей, чтобы можно было точно сказать, что именно отправляется пользователям в продакшен.



Посмотрим, где находится код зависимости. Предположим, нам захочется немного поотлаживать его или внести корректировки, это может быть важно. В файл-навигаторе в левой части экрана можем найти нашу зависимость, кликнуть правой кнопкой > «Показать в Finder».



Увидим, что разрезолвленные зависимости находятся в drive data.



Также предлагаю взглянуть, где находятся органы управления SPM. Они находятся в Xcode во вкладе «Файл». Можно зарезолвить зависимости. Это аналог pod install. Вы получите те версии зависимости, которые находятся в файле package.resolved. Либо можно обновить зависимости. Это аналог pod update. Единственная особенность в том, что через интерфейс Xcode невозможно обновить зависимости по одной — они обновятся все сразу.

На что бы я хотел обратить тут внимание? В первую очередь на то, как написан .gitignore. Как я уже ранее сказал, важно, чтобы package.resolved был добавлен в репозиторий и закоммичен. Поэтому надо убедиться, что когда вы добавляете первую зависимость на SPM, файл package.resolved попадает в git-репозиторий. Но может встретиться ошибка, сообщающая, что все находящееся в workspace добавлено в .gitignore и ничего нового не добавляется. Будьте внимательны.

Второе, на что стоит обратить внимание: код зависимости находится в DerivedData. Это может быть важно при сборке на серверах continuous integration или в те моменты, когда вы чистите DerivedData. Не стоит удивляться, почему все репозитории чекаутятся заново.



Что же такое Swift Package Manager, из чего он состоит? Swift-пакет состоит из модулей. Модули — это наборы исходных файлов. Между модулями могут быть зависимости, одни могут зависеть от других.

Далее модули объединяются в продукты. Продукт — это либо библиотека, как статическая, так и динамическая, либо исполняемый файл. Надо сказать, что можно собрать только приложение для macOS. С помощью SPM под iOS все еще придется использовать Xcode-проект.

Еще есть файл package.swift. Это описание того, что находится в нашем пакете. В этом описании могут быть зависимости от других пакетов.

Package.swift выглядит так. У него есть имя, в нем перечисляются продукты:

// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "ExamplePackage",
    products: [
        .library(name: "Superapp", targets: ["SuperApplication"]),
        .library(name: "FoodKit", targets: ["FoodOrder", "OrderHistory"]),
    ],
    dependencies: [.package(url: "https://github.com/Alamofire/Alamofire", from: "5.3.0")],
    targets: [
        .target(name: "SuperApplication", dependencies: ["FoodOrder", "OrderHistory", "TaxiOrder"]),
        .target(name: "TaxiOrder", dependencies: ["Alamofire", "UIComponents"]),
        .target(name: "FoodOrder", dependencies: ["Alamofire", "UIComponents"]),
        .target(name: "OrderHistory", dependencies: ["Alamofire", "UIComponents"]),
        .target(name: "UIComponents")
    ]
)

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

Потом можно указывать зависимости. Они могут быть как в git-репозиториях, так и в локальных.

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

У нас есть описание файла. И если нам неизвестно, что можно указать и мы редактируем package.swift с помощью Xcode, то всегда можем кликнуть на какую-то часть package.swift, перейти к сигнатуре методов и структур, и там все будет видно, все параметры функции. Это очень удобно, и в этом несомненный плюс SPM. А также можно прочитать документацию прямо в Xcode.

Предлагаю двинуться дальше к следующей части моего доклада — уже непосредственно к тому, что мы делали, к шагам по внедрению SPM.

Тернистый путь


До внедрения SPM наш проект выглядел совершенно обычно.



В нем были внешние зависимости, которые подключались с помощью CocoaPods. Мы собирали много таргетов: как приложения, так и статические библиотеки. И у нас было несколько конфигураций Xcode-проекта: Debug, AdHoc и AppStore. Конфигурацию AppStore мы отправляли в продакшен на пользователя, AdHoc отдавали в тестирование, а в Debug — отлаживались. Везде был немного разный код. В Debug срабатывали асёрты. В AdHoc асёрты не срабатывали, но они логировались в метрику. А в конфигурации AppStore по этим асёртам ничего не происходило. Такая система довольно удобна, можно debug-меню иметь только в AdHoc-конфигурации, закрыв его флагами условной компиляции. Так что мы обоснованно использовали несколько конфигураций.

Что мы хотели сделать с помощью SPM? Нашей задачей было научиться делить наш код на модули локально. Не подключать зависимости, а именно разбивать наш код.

Также требовалось, чтобы у нас работало три Xcode-конфигурации: AppStore, AdHoc и Debug. И у нас работал флаг TREAT_WARNINGS_AS_ERRORS, который превращает все ворнинги в ошибки. Это очень удобно, уменьшает число ошибок в продакшене, и приводит к тому, что в проекте в принципе нет ворнингов.

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



В файл-навигаторе выбираем новый Swift-пакет, создаем его.



Он добавляется в workspace.



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

Посмотрим, сможем ли мы сделать Treat Warnings as Errors в этом таргете. Таргет — это модуль unsafeflag, и в unsafeflag можно указать опцию -warnings-as-errors. Она приводит к тому, что все ворнинги превращаются в ошибки.



Смотрим, что получается с нашим прилинкованным проектом. К сожалению, выскакивает ошибка. Xcode говорит, что нельзя подключать к таргетам Xcode зависимости, которые имеют unsafe-флаги. Плохо. Что мы с этим сделали, я расскажу чуть позже.

Кастомные конфигурации AdHoc и AppStore. Зачем нужны кастомные конфигурации? Например, чтобы использовать флаги условной компиляции и не отправлять в AppStore конфигурацию дебаг-меню ни в каком виде. Экспериментальную функциональность тоже можно закрывать флагами условной компиляции, использовать ее только в тестовых или дебаг-сборках. Здесь мы сталкиваемся с ограничением SPM: он поддерживает только две конфигурации. Их имена захардкожены в коде SPM: это Release и Debug.

swift package generate-xcodeproj --xcconfig-overrides adhoc.xcconfig

Но у консольной утилиты Swift Package есть опция generate xcodeproject, которая позволяет генерировать Xcode-проекты. И есть параметры, которые позволяют переопределять файлы конфигураций.



Что с этим можно сделать? Можно написать файлы конфигураций, сгенерировать проекты по пакетам и добавить эти пакеты в workspace.



Далее — подключить к нашему приложению продукты Xcode-проектов, а не продукты Swift Package.

sed -i '.bak' "s/\"Release\"/\"Adhoc\"/g" ${PACKAGE_NAME}.xcodeproj/project.pbxproj
rm ${PACKAGE_NAME}.xcodeproj/project.pbxproj.bak

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



Мы сгенерировали проект, указали кастомный файл конфигурации, и у нас появляются флаги условной компиляции для Swift — то что надо.



Также можно добавить опции ворнингов в конфигурации. И Treat Warnings as Errors, у нас начинает работать такой проект. Можно подключить локальную зависимость с помощью Swift пакета, где будут работать все требования, которые были нам нужны. Мы умеем делить код локально. Работают кастомные имена схем, флаги условной компиляции и Treat Warnings as Errors. Все хорошо.

У нас появился первый модуль. Далее мы начали активно их писать.



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

Зависимости между модулями указывали следующим образом — путь зависимости указывали локально.

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

#--type json|text
swift package describe
#Name: UIComponents
#Path: /Users/vadim-b/Projects/thorny-path/MarseilleTaxi/UIComponents
#Modules:
#    Name: UIComponents
#    C99name: UIComponents
#    Type: library
#    Module type: SwiftTarget
# Path: /thorny-path/MarseilleTaxi/UIComponents/Sources/UIComponents
# Sources: Buttons/YellowButton.swift, Labels/CostLabel.swift
 #--format text|dot|json|flatlist
 swift package show-dependencies
#.
#├── UIComponents</Users/vadim-b/Projects/thorny-path/MarseilleTaxi/>
#│ └── UIHelpers</Users/vadim-b/Projects/thorny-path/MarseilleTaxi/>
#└── UIHelpers</Users/vadim-b/Projects/thorny-path/MarseilleTaxi/>

Как мы с этим поступили? Генерировали проекты на лету. Написали скрипт, который вычислял разницу между тем, что находится в сгенерированном Xcode-проекте, и тем, что находится в git-репозитории. Когда происходил pull, то сравнивалось содержимое и, если нужно, происходила перегенерация кода.

На серверах continuous integration генерация Xcode-проектов происходила безусловно. Это была одна из начальных фаз билда.

Тут нам помогли две терминальные команды Swift Package — swift package describe, которая перечисляет всё, что есть внутри пакета, все исходные файлы; и swift package show dependencies, которая показывает граф зависимостей swift-пакета. Это довольно удобные команды. Если у вас уже образовалась сложная конфигурация модулей, то можно вызвать swift package show dependencies и посмотреть, как устроен проект. Удобная и интересная штука.

Мы научились вырезать модули локально и создавать модули. Все работает, все очень хорошо.

Следующая проблема, с которой мы столкнулись, — multiple commands produce same product. Как эта проблема возникла?

По мере роста числа модулей граф зависимостей усложнялся. Однажды он превратился в граф, содержищий циклы.

Предположим, есть приложение Такси, которое зависит от UI-компонентов и от модуля адресов, и оба этих модуля зависят от общего foundation. Тогда при подходе, который я показал ранее, компиляция проекта приведет к тому, что продукт Такси foundation будет создаваться два раза.

Как мы решили эту проблему? Мы придумали сущность, которую назвали umbrella. Это статическая библиотека, которая прилинковывается к такси. Все зависимости от других библиотек, используемых в конечном таргете, мы убрали и добавили только одну библиотеку Umbrella, а уже она перечисляла все те зависимости, которые нужно использовать в такси. Соответственно, задача разрешения графа зависимостей легла на плечи Swift Package Manager.

Umbrella выглядела следующим образом:

// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "Umbrella",
    products: [
        .library(name: "Umbrella", targets: ["Umbrella"]),
    ],
    dependencies: [
        .package(path: "../Address"),
        .package(path: "../TaxiFoundation"),
        .package(path: "../UIComponents"),
        /*...*/
    ],
    targets: [
        .target(
            name: "Umbrella",
            dependencies: [
                "Address",
                "TaxiFoundation",
                "UIComponents",
                /*...*/
            ]),
    ]
)

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



Мы убрали подключение всех написанных у нас библиотек и подключали ровно одну библиотеку. Ура, все работает!

Следующая проблема, с которой мы столкнулись, — ошибка Module Not Found. Почему она происходила?



Мы подключаем к нашему проекту какой-нибудь модуль, ровно один.



И дальше в compatibility header можем наблюдать ошибку Module Not Found. У нас есть compatibility header, потому что код приложения написан на двух языках — на Swift и на Objective-C.

Как эта проблема воспроизводилась? В коде приложения был объявлен некоторый протокол:

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@protocol ApplicationProtocol

@property (nonatomic, copy, readonly) NSString *name;

@end

NS_ASSUME_NONNULL_END

Из библиотеки подъезжал некоторый класс, который хотелось законформить этому протоколу:

//

import MyLibrary

extension MyLibraryModel: ApplicationProtocol {
    public var name: String {
        "constant_name"
    }
}

Если протокол объявлен на языке Objective-C, а из библиотеки написан код на Swift и мы конформим этому протоколу с помощью экстеншена, то модуль прорастает в compatibility header, который мы заимпортили, и ничего не компилируется.

Почему эта проблема важна? Наверное, потому, что важна сама история возникновения проблемы. Был некоторый код, его выделили в модуль. Этот код прекрасно работал в example-приложении, все было хорошо. В основном приложении было две реализации: та, что не еще выехала (точная копия того, что уехало в модуль), и код из модуля. Заменили ровно в одном месте, где этот модуль использовался. Все работает, все подключилось.

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

Как мы ее решили? Мы просто взяли и не стали писать подобные экстеншены. И адаптировали классы из модуля протокола с помощью промежуточной сущности.

Но, как оказалось позднее, проблема связана именно с генерацией проектов по модулям из Swift-пакета — с командой swift package generate-xcodeproject. Но тогда мы этого не знали. Если подключать зависимости как package-зависимости, проблема не возникает.

Далее приложение начало падать. Причем в самый неподходящий момент — ровно перед моментом, когда нам нужно было собрать релиз. А поскольку мы релизы собираем раз в неделю, то такие моменты возникают довольно часто и нужно эти проблемы оперативно решать. У нас было два варианта: либо решить ее, либо откатиться назад, выпилить Swift Package Manager и собрать всё с помощью Xcode-таргетов.

Как же все-таки выглядел crash? Вот так:

swift::swift50override_conformsToProtocol(swift::TargetMetadata)

Что-то упало. В консоли было сообщение, что манглирование произошло неправильно:

failed to demangle superclass of TypedProtocolProxy from mangled name ‘\^A\M^GR\M-A\M^?y12ProtocolType\^A\M^CO\M-A\M^?QzG'

Манглирование — это процесс кодирования внешних имен модуля. Соответственно, эта проблема происходила где-то в момент сборки.

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

Был объявлен некоторый протокол, он имел ассоциированный тип.

public protocol TypedProtocol {
    associatedtype ProtocolType
    func foo() -> ProtocolType?
}

Далее была написана дефолтная реализация этого протокола с помощью класса, а не с помощью средств протоколов.

public class NilTypedProtocolImpl<ClassType>: TypedProtocol {
    public func foo() -> ClassType?
}

Был написан наследник этой дефолтной реализации в модуле. Наследник был приватным.

private final class TypedProtocolProxy<Subject: TypedProtocol>: NilTypedProtocolImpl<Subject.ProtocolType> {

    let subject: Subject
    override func foo() -> Subject.ProtocolType?
}

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

public struct TypedProtocolBox<TestType>: TypedProtocol {
    private let base: NilTypedProtocolImpl<TestType>

    public init<T: TypedProtocol>(_: T) where T.ProtocolType == TestType

    public func foo() -> TestType?
}

В таком кейсе проблема легко воспроизводилась.

Проблема была где-то в сложной конфигурации с дженериками. Надо было что-то делать, собирать релиз.

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

Какие у нас были варианты? Выпилить генерацию Xcode-проекта и попробовать собрать динамическую библиотеку. Это и сработало. Как следствие, у нас пропали кастомные имена схем и Treat Warnings as Errors, а сама Umbrella оказалась превращена в динамическую библиотеку.



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

А все модули были перечислены внутри одного пакета.

И у нас пропали зависимости. Тут мы убрали такой косяк, что Umbrella — статическая библиотека, которая во что-то компилируется, отдельный бинарник. И сказали, что Umbrella — это просто продукт, который перечисляет все те модули, которые мы хотим использовать.

То, что у нас не работают кастомные имена схем, не стало очень большой проблемой. У нас кастомные имена остались в собираемых приложениях. И проблему, что какой-то код не должен работать в продакшене, мы решали с помощью средств ООП. А по поводу того, что не работало Treat Warnings as Errors, тоже не стали запариваться. В приложении и так не было ворнингов. Когда появляется ворнинг, это сразу становится заметно. И у нас есть сервер continuous integration, на нем можно проверять, есть ли в сборке ворнинги.

Что мы делали с ресурсами? Когда мы начали внедрять Swift Package Manager, ресурсы он не поддерживал. Начали мы это делать в феврале 2020 года. Какие виды ресурсов были у нас в приложении?

// строки берем из Bundle.main
let string = NSLocalizedString("Hello World!", comment: "")

Первый вид ресурсов — строки. Мы их просто начали вычитывать из main bundle в модулях. Поскольку мы нарезали наш локальный код на модули, не выносили их ни в какие репозитории, никуда не отдавали, такой вариант нас устраивал и прекрасно работал.

Для строк мы не используем никакой кодогенерации. У нас построена автоматизированная система, которая подтягивает переводы из внутренних репозиториев, и обычно мы не ошибаемся, поэтому как оно тут написано — Localized.strings, так мы в коде и пишем. Удобно.

// Swift Module: SuperappModule

import UIKit

public protocol Images {
    var smallWhereToIcon: UIImage? { get }
    var bigWhereToIcon: UIImage? { get }
    var smallEatsIcon: UIImage? { get }
    var bigEatsIcon: UIImage? { get }
}

// Host Application

import UIKit
import SuperappModule

final class SuperappModuleImages: SuperappModule.Images {
    var smallWhereToIcon: UIImage? { ImageRepository().widgetWhereToIcon.image }
    var bigWhereToIcon: UIImage? { ImageRepository().widgetWhereToIconBig.image }
    var smallEatsIcon: UIImage? { ImageRepository().widgetEatsIcon.image }
    var bigEatsIcon: UIImage? { ImageRepository().widgetEatsIconBig.image }
}

Следующий вид ресурсов — картинки. С картинками мы делали следующее. Во-первых, их можно было просто вычитывать из main bundle так же, как мы поступали со строками. Но нас такой вариант не устраивал, поскольку мы собираем много приложений из одних и тех же исходников. Сделали следующую конструкцию. Каждый модуль явно объявлял те картинки, которые ему нужны, а уже конечное приложение настраивало эти картинки с точки зрения того, в каком виде они должны быть использованы. Тут у нас была еще кодогенерация картинок. Мы не стали протаскивать кодогенерацию в эти модули в Swift Package, оставили всё как есть. Это подошло как быстрое решение. Оно работает до сих пор и всех устраивает. Явно объявляем те зависимости из картинок, которые нужны модулю, и подключаем их.

Следующий вид ресурсов, который был в проекте на момент начала внедрения, — xib и storyboard. Мы их просто не стали использовать. Если какие-то куски вьюх или view-контроллеров были написаны в xib и storyboard, мы просто переписывали этот код на Swift и клали в модуль. Тут никаких проблем не возникало.

Следующая вещь, которую мы использовали, — подключали внешние зависимости через CocoaPods. И продолжили это делать, потому что не все зависимости, которые мы использовали, поддерживались в Swift Package Manager.

Тут мы воспользовались паттерном ООП adapter — структурным паттерном, который позволяет использовать объекты с несовместимыми интерфейсами.



У нас есть два варианта использования этого паттерна. Первый: мы создаем отдельный модуль, в котором объявляем все протоколы, которые хотим адаптировать. Например, можно для сетевого слоя определить свой протокол. Такой подход довольно удобен, потому что если есть свой протокол сетевого слоя и потом мы хотим переехать с одной сетевой библиотеки на другую, например, с Restkit на Alamofire, и если мы имеем такой промежуточный набор из протоколов, под которые нам закрыта эта сеть, то нам будет намного проще это сделать, чем если мы будем использовать сетевую зависимость напрямую.



Далее пишем реализацию этого протокола в коде нашего приложения. И всё, получается, решается средствами ООП. Но, конечно, есть overhead на разработку.

Следующий вид зависимостей — небольшие зависимости, которые не требуют написания огромной библиотеки со своими интерфейсами. Но тут мы поступали более простым способом — явно объявляли необходимые зависимости и инжектировали их в те модули, в которые нужно.

// Swift Module

public protocol ImagesProvider {
    func image(with _: URL, completion: (UIImage?) -> Void)
}

public class ViewController {
    private let imagesProvider: ImagesProvider

    public init(imagesProvider: ImagesProvider) {
        self.imagesProvider = imagesProvider
    }
}

// Application

extension Application.ImagesProvider: Module.ImagesProvider {
}

И если сигнатура зависимости, которую мы использовали, объявили в модуле, совпадает с сигнатурой зависимости, которая подъезжает из CocoaPods, то реализовать адаптацию не составляет труда. Можно просто написать экстеншен, который не будет иметь никакой реализации, и все заработает.

Но с зависимостями мы еще через CocoaPods разобрались. Они были, мы не могли от них отказаться. Но что с зависимостями, которые можно подключить через Swift Package Manager? Надо пробовать подключить.



Мы просто указываем эти зависимости в нашей Umbrella, и все работает.



Umbrella у нас подключена уже к основному приложению.

Я в примерах показал Alamofire. Его подключить таким образом через Swift Package Manager не составляет труда. Но в Яндекс Go мы не используем Alamofire, а подключали нашу внутреннюю зависимость — библиотеку EatsKit, которая нужна для работы Еды и Лавки внутри приложения. Тут мы столкнулись с проблемами.

Первый ряд проблем был связан со сборками на серверах continuous integration, с тем, что на этих серверах DerivedData была шаренная. То есть казалось, что она не шаренная. В настройках TeamCity все чекаутилось в специальную папку сборки.

В fastlane, где мы собирали наш проект, тоже была указана опция «Использовать локальную DerivedData» и путь к этой DerivedData.

Но оказалось, что зависимости, которые подключаются через Swift Package Manager, резолвятся не просто в DerivedData, а в специальный путь clonedSourcePackagesDirPath:

#!/bin/sh

xcrun xcodebuild \
    -workspace DependenciesFromSPM.xcworkspace \
    -scheme DependenciesFromSPM \
    -derivedDataPath ./LocalDerivedData \
    -clonedSourcePackagesDirPath ./LocalClonedSources

Эту опцию можно указать как в Xcode-билде, так и в fastlane. Проблема возникла в том, что в этой DerivedData скопилась непонятные сущности, половина агентов отвалилась, непонятно почему не собиралась. Проблему нашли случайно. Попросили админов разобраться. Начали чистить все кеши. Проверяли уже, казалось, совсем нереальный сценарий — решили удалить DerivedData. Удалили, и все заработало, хотя DerivedData была локальная. И потом уже вскрылась эта опция — что можно указать путь, куда будут зарезолвлены пакеты.

Следующая проблема тоже возникла на серверах continuous integration. С Alamofire такой проблемы не возникнет — это публичный репозиторий, все работает. Но конкретно в нашем случае зависимость в какой-то момент переехала из внутреннего GitHub-репозитория в Bitbucket-репозиторий. Он был закрытым и требовал авторизации по SSH. Соответственно, с авторизацией возникла проблема.

Почему-то при запуске билда из терминала credential из этого терминала с доступами к репозиториям они не прорастали в команду Xcode .build.

#!/bin/sh

cd ./SwiftModules
xcrun swift package resolve
cd ..

xcrun xcodebuild \
    -workspace DependenciesFromSPM.xcworkspace \
    -scheme DependenciesFromSPM \
    -derivedDataPath ./LocalDerivedData \
    -clonedSourcePackagesDirPath ./SwiftModules/.build

Тут нам снова помогла эта опция — clonedSourcePackagesDirPath. Мы разрезолвили пакеты с помощью локальной команды swift package resolve, они зарезолвились в скрытую папку Swift-модуля — .build, и мы указали Xcode-билду путь к этой папке. Workaround сработал — все стало ОК. Но есть подозрение, что проблема связана только с Xcode версии 11, потому что мы переподключили эти все зависимости, проверили, и на Xcode версии 12 проблемы не возникло. Похожая ситуация возникла при ошибке со сборкой на CI с Xcode 12, но ее удалось решить: мы прописали в файл hosts публичные ключи GitHub-репозиториев. Кажется, тут все стало немножко лучше.

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

В workaround, который я показал, есть проблема: он порождает второй файл swift package.resolved. Первый файл swift package.resolved находится внутри workspace, а второй появляется в папке swift-модуля:

${WorkspaceName}.xcworkspace/swiftpm/Package.resolved
${PackageDir}/Package.resolved

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

С другой стороны, возможно, что такая штука — это не какой-то сделанный не до конца хак, workaround, а правильный путь работы со Swift Package Manager, пока в Xcode нет опции обновить конкретную зависимость.

Есть в командной тулзе Swift Package опция swift package update --dry-run:

swift package update --dry-run
# Updating https://github.com/Alamofire/Alamofire # 1 dependency has changed:
# ~ Alamofire 5.3.0 -> Alamofire 5.4.0

swift package update Alamofire

Она позволяет увидеть зависимости, которые можно обновить. Если в вашем пакете много зависимостей, не стоит обновлять все сразу и отдавать это в тестирование — наверняка где-то что-то отломается. Лучше двигаться небольшими шагами и обновлять зависимости по одной. А чтобы видеть, что зависимости необходимо обновить, периодически стоит запускать команду swift package --dry-run. И есть команда, которая позволяет обновлять зависимости по одной.

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

Что еще интересного мы сделали с помощью Swift Package Manager в нашем проекте? Модуль на Objective-C.



Чтобы сделать модуль на Objective-C, надо просто создать такой же модуль, как со Swift, но, единственное, нужно указать публичные хедеры. Их можно указать двумя способами — положить их в папку include, либо указать к ним пути уже в Swift Package. Но мы выбрали путь через папку include, поскольку он, кажется, немного проще.



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

#!/bin/sh

set -e

readonly DIR=`dirname $0`
readonly PROJECT_ROOT=${DIR}
readonly MODULES_DIR=SwiftModules

readonly MODULE_NAME=$1
readonly MODULE_SOURCES_PATH=${MODULES_DIR}/Sources/${MODULE_NAME}

cd ${MODULE_SOURCES_PATH}/include
rm -f ../include/*

find .. -name '*.h' | while read header; do
    ln -s "${header}"
done

Чтобы собирать папку include, мы написали небольшой скрипт, который собирает все хедеры, которые есть в модуле, строя для них набор симлинков. Нам этот путь подошел, поскольку мы в Objective-C модуле отделили легаси DTO-слоя. Приложение довольно большое и старое, много DTO, много ручек, которые мы используем. Старые ручки все еще используются, но на Swift не переписываются. И даже туда не всегда необходимо вносить изменения. Чаще всего они просто работают, этот код таким кусочком как был, так и заехал в модуль. В моменте это нам позволило ускорить сборку на серверах continuous integration на 10%.

Локально сборка также ускорилась — с нуля, наверное, также на 10%, если сборка холодная. Но поскольку работает инкрементальная сборка, то в локальных сборках этот прирост не так уж заметен.

Я уже подхожу к концу нашего тернистого пути. Расскажу об опции в CocoaPods, которую мы используем. Она также связана с кастомными конфигурациями, а в Swift Package Manager мы такую штуку еще не сумели сделать.

  pod 'FLEX', :configurations => ['Debug', 'AdHoc']

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

Итоги


Что нам дал Swift Package Manager, как изменился проект?



Пока мы занимались модуляризацией, нарезкой нашего локального кода на модули, приложение превратилось из Такси в Go. Мы по-прежнему собираем много таргетов из одних и тех же исходников. У нас по-прежнему есть код, который сделан с помощью статических Xcode-библиотек. Мы по-прежнему тащим зависимости из CocoaPods.

Но мы активно пилим модули, модуляризуем наше приложение с помощью Swift Package Manager, и уже значительная часть функциональности заехала в модули. То есть SPM решает нашу основную задачу — нарезку локального кода на модули.

Также мы научились с помощью SPM как пилить Swift-код на модули, так и выносить в них код на Objective-C. Поэтому я надеюсь, что в скором будущем весь код у нас окажется модуляризован.

Из плюсов модуляризации, которые явно заметны: в каких-то моментах сборка действительно ускорилась, стало проще и быстрее разрабатывать, появились example-приложения для существенных кусков приложения, для UI-компонентов, истории поездок и всего такого. Это очень круто.

Общие итоги, которые я бы хотел подвести для всех:

  1. Я думаю, что Swift Package Manager — достаточно взрослая технология, и наш опыт использования это доказывает и показывает.
  2. SPM — развивающаяся технология. Если еще в феврале 2020 года нельзя было подключить в свой проект ресурсы, полноценные библиотеки, то в сентябре вышел очередной релиз SPM вместе со Swift 5.3 и возможность их подключения появилась.
  3. SPM по-прежнему имеет ограничения, связанные именно с конфигурациями, с невозможностью использования unsafe-флагов. Если для вас это критично, я бы не рекомендовал использовать SPM. Но эти ограничения можно обойти. И риски, которые несет с собой SPM, на мой взгляд, не настолько велики, чтобы вообще его не рассматривать.
  4. SPM нужно попробовать. Если вы еще ни разу не смотрели в его сторону, не заглядывали, не пробовали, то все-таки нужно его пощупать, посмотреть.

Для этого я собрал набор примеров в своем GitHub-репозитории, о котором я рассказывал. Можно покрутить и потом пообсуждать.
Теги:swift package managercocoapods
Хабы: Блог компании Яндекс Разработка под iOS Разработка мобильных приложений Objective C Swift
Всего голосов 9: ↑8 и ↓1+7
Просмотры2.1K
Комментарии Комментарии 1

Похожие публикации

Лучшие публикации за сутки