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

Доклад будет интересен 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, которые тоже предназначены для вызова такси.

zpylrac_urhme3u0cndlvvwaagc.jpeg

Все эти приложения мы собирали из одной ревизии, из одних и тех же исходников. Поэтому под ними была общая 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


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

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

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

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

puk8wyxzp72ebzkjqq_e8iwbjam.jpeg

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

togvfcjutyxzusjks1cik_bxcag.jpeg

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

ofmlmjucla4eoxf1oy2_k8pdtim.jpeg

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

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

sgnfxf3y7iq6xo9_1d7zxrkrhqk.jpeg

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

6f3-lidh69i_pdyjwe3oackrhr8.jpeg

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

7jzn_48keectocegltazilatbri.jpeg

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

yhyazblqdqrbj5cqggb6r_b0kpi.jpeg

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

busdekmvma5hpqngas2sghhwyqi.jpeg

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

owbvvomobxc_bm1brawagcch0cm.jpeg

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

a0qp0cxb8izi84ne-ayric5fpnc.jpeg

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

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

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

faea-hsae56ctb6qoocmyt10msa.jpeg

Что же такое 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 наш проект выглядел совершенно обычно.

ura_wiwz9n48pjhftxyv8ckexsq.jpeg

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

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

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

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

y5idixu-nvdxfm-z3_yrzwa3dx8.jpeg

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

immxkye4tpflxecqozdrw6w3g6u.jpeg

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

kr-hm2toj5-q8crah24q7c6kti8.jpeg

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

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

d5t1apsjriebo1tupnu6qp94chy.jpeg

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

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

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

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

zjrynrhgrw60luxn5j0h_kyjtwq.jpeg

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

zf8btsnpyyjci349ivoseini7dc.jpeg

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

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

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

nc_txst9cr6exvrpzm1vbmrb14e.jpeg

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

x1d1osqujdqc7otr5esxi-vy0q4.jpeg

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

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

dkw6spalsihwz_ne8cq9fuv9at0.jpeg

Каждый модуль мы создавали в локальном пакете. В каждом пакете находился ровно один модуль, каждый пакет мы добавляли в 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
#│ └── UIHelpers
#└── UIHelpers

Как мы с этим поступили? Генерировали проекты на лету. Написали скрипт, который вычислял разницу между тем, что находится в сгенерированном 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. Как эта проблема возникла?
u2nxbva3j9jxaax-teaujmuzk3w.jpeg
По мере роста числа модулей граф зависимостей усложнялся. Однажды он превратился в граф, содержищий циклы.

Предположим, есть приложение Такси, которое зависит от UI-компонентов и от модуля адресов, и оба этих модуля зависят от общего foundation. Тогда при подходе, который я показал ранее, компиляция проекта приведет к тому, что продукт Такси foundation будет создаваться два раза.
-gofzd2c1u_6bc9m2t-9h_kri80.jpeg
Как мы решили эту проблему? Мы придумали сущность, которую назвали 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. Тут мы пользовались тем, что все транзитивные зависимости у модулей видны в исходном модуле и вся эта конфигурация работает.

iixxtourr2aldn5abfzczwznzge.jpeg

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

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

rg-cxlvdqx0z-xrvgmilmx7n9vo.jpeg

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

5p-glvfr6jma_rlks1dxslnsokk.jpeg

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

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

#import 

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: TypedProtocol {
    public func foo() -> ClassType?
}

Был написан наследник этой дефолтной реализации в модуле. Наследник был приватным.
private final class TypedProtocolProxy: NilTypedProtocolImpl {

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

И была написана публичная структура, которая использовала этого приватного наследника дефолтной имплементации.
public struct TypedProtocolBox: TypedProtocol {
    private let base: NilTypedProtocolImpl

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

    public func foo() -> TestType?
}

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

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

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

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

plmiy9-rjkvrh2pwnwpgyhia5nk.jpeg

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

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

И у нас пропали зависимости. Тут мы убрали такой косяк, что 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 — структурным паттерном, который позволяет использовать объекты с несовместимыми интерфейсами.

hoaeaqfyef1o6jxuffd2_e3uwqm.jpeg

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

m4jyz8a6w3kcc-6etq510o2gwpo.jpeg

Далее пишем реализацию этого протокола в коде нашего приложения. И всё, получается, решается средствами ООП. Но, конечно, есть 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? Надо пробовать подключить.

sujuxbsri5c3bfvniiwmtxb0krc.jpeg

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

vtj-o4b3tyslpc74cf9vcqnfrve.jpeg

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

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

© Habrahabr.ru