7 Кругов SPM или как сделать модульное приложение на Swift Package Manager / Хабр

0 Favorite

[

Спасибо Jackie Zhao @jiaweizhao за фото на Unsplash
Спасибо Jackie Zhao @jiaweizhao за фото на Unsplash

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

Возможно, у многих возникнет вопрос:  “Зачем разбивать с помощью SPM?”. Ведь можно просто создавать подпроекты. Можно, но в использовании SPM для разбиения есть несколько преимуществ:

  • С помощью SPM мы избавляемся от .xcodeproj файлов (забываем про конфликты в них);

  • Добавляем нативную возможность в будущем распространять наши модули. Допустим, вашему новому приложению понадобится использовать уже существующую авторизацию, тогда вам практически ничего не будет стоить распространение этого функционального модуля;

  • Нет альтернатив для проектов под разные операционные системы Linux/Windows;

  • Пакеты не требуют xcode для разработки.

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

Для начала создаём Package.swift файл, в котором будут храниться все необходимые зависимости и информация о том, что из себя этот модуль представляет.

Пример файла с параметрами, с которыми вы скорей всего столкнётесь при работе:

import PackageDescription
 
let package = Package(
    // 1. Название нашего пакета
    name: "Resources",
    // 2. Платформы, которые поддерживаются нашим пакетом
    platforms: [
        .iOS(.v11),
    ],
    // 3. То, что другие программы будут брать в себя
    // Продуктов может быть огромное колличество, хороший пример для этого Firebase SPM пакет
    products: [
        .library(
            name: "Resources",
            // Динамический или статический продукт
          	// по дефолту значение nil - SPM сам будет понимать что лучше подходит
            //  преференция скорей всего будет отдаваться .static
            type: .dynamic,
            targets: ["Resources"]),
    ],
    // 4. Зависимости необходимые для работы нашего пакета,
  	// здесь они просто загружаются, добавляются они в targets
    dependencies: [
        // name - это название пакета(пункт 1), здесь нельзя указать кастомное название, необязательный параметр
        .package(name: "R.swift.Library", url: "https://github.com/mac-cain13/R.swift.Library", .branch("master")),
        // Пример подключения локального пакета
        .package(path: "../Core")
    ],
    targets: [
        // Это то из чего мы будем складывать наш продукт
        // Для таргета обязательно нужно создать папку 
      	// в Sources/имя_таргета для его работы
      	// либо если мы не хотим размещать его в Sources, можем указать "path:"
        .target(
            name: "Resources",
            dependencies: [
                // Здесь мы указываем зависимости которые мы хотим использовать в таргете
                // name(пункт 3), package(пункт 1)
                .product(name: "RswiftDynamic", package: "R.swift.Library")
            ],
            resources: [
                // Все ресурсы которые мы хотим использовать нужно явно указать
                // Путь к ним относительный от Sources/имя_пакета/то_что_мы_указали
                // Если указываем папку, поиск идет рекурсивно
                .process("Resources")
            ])
    ]
)

После  создания модуля, подключаем его к  проекту (либо к другому модулю, если модуль который вы делали лишь вспомогательный для других). Для удобства можно добавить пакет в workspace, просто drag and drop корневую папку с пакетом в Project navigator.

Введение на этом можно закончить и перейти к основной части статьи, где мы рассмотрим проблемы, с которыми вы можете столкнуться. На момент написания статьи мною использовались swift-tools-version:5.3, Xcode Version 12.2

Круг 1. Небольшое комьюнити.

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

Круг 2. Отсутствует фаза скриптов SPM пакета.

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

Круг 3. R.swift и SPM.

Так как у нас нет .xcodeproject файла, вызвать R.swift скрипт для генерации не получится, для этого нам нужно этот файл создать.

Для создания можно использовать XcodeGen и его аналоги. Либо swift package generate-xcodeproj, стандартного скрипта для генерации .xcodeproj файла в SPM.

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

error: [R.swift] Project file at 'file:///Users/.../Resources.xcodeproj/' could not be parsed, is this a valid Xcode project file ending in *.xcodeproj?

Чтобы локализовать проблему и найти почему файл проекта не может распарситься нужен XcodeEdit — фреймворк который R.swift использует для парсинга. Билдим его и получаем exec файл, которому нужно передать .xcodeproj. Вызываем его:

pathtoexec/XcodeEdit-Example Resources.xcodeproj

и видим

Fatal error: 'try!' expression unexpectedly raised an error: XcodeEdit_Example.AllObjectsError.fieldMissing(key: "buildRules"): file XcodeEdit_Example/main.swift, line 21

Вот как можно это решить:

sed -i '' -e 's/isa = "PBXNativeTarget";/isa = "PBXNativeTarget";buildRules = ();/' Resources.xcodeproj/project.pbxproj

К сожалению, на этом проблемы не заканчиваются. generate-xcodeproj не добавляет файлы ресурсов в проект. То есть R.swift будет парсить .xcodeproj/.pbproject, но нужных файлов ресурсов там нет.

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

Пример минимального конфиг файла для XcodeGen:

# Название генерируемого xcodeproj
name: Resources
targets:
 Resources:
   # Не важно что вы тут укажите, но это обязательные параметры
   type: framework
   platform: iOS
   # Root folder с которого начинать генерировать
   sources:
     - Sources

Команда которая создаст .xcodeproj файл:

xcodegen generate --spec Resources.yml

Проблема с .xcodeproj решена, вот пример полного скрипта для генерации R.swift:

# Создание xcodeproj
generateXcodeProject() {
 xcodegen generate --spec Resources.yml
}
 
# Получаем buildSettings с помощью логов xcodebuild команды, лучше проверять, создан ли этот файл
# Чтобы сократить время скрипта и не билдить каждый раз
getBuildSettings() {
 xcodebuild -project "Resources.xcodeproj" -target "Resources" -showBuildSettings > buildSettings.txt
}
 
# Создание enviroment переменных без которых R.swift скрипт работать не будет
parseEnvironmentVariables() {
 export SRCROOT="$(cat buildSettings.txt | grep -m1 "SRCROOT" | sed 's/^.*= //' )"
 export TARGET_NAME="$(cat buildSettings.txt | grep -m1 "TARGET_NAME" | sed 's/^.*= //' )"
 export PROJECT_FILE_PATH="$(cat buildSettings.txt | grep -m1 "PROJECT_FILE_PATH" | sed 's/^.*= //' )"
 export TARGET_NAME="$(cat buildSettings.txt | grep -m1 "TARGET_NAME" | sed 's/^.*= //' )"
 export PRODUCT_BUNDLE_IDENTIFIER="$(cat buildSettings.txt | grep -m1 "PRODUCT_BUNDLE_IDENTIFIER" | sed 's/^.*= //' )"
 export PRODUCT_MODULE_NAME="$(cat buildSettings.txt | grep -m1 "PRODUCT_MODULE_NAME" | sed 's/^.*= //' )"
 export TEMP_DIR="$(cat buildSettings.txt | grep -m1 "TEMP_DIR" | sed 's/^.*= //' )"
 export BUILT_PRODUCTS_DIR="$(cat buildSettings.txt | grep -m1 "BUILT_PRODUCTS_DIR" | sed 's/^.*= //' )"
 export DEVELOPER_DIR="$(cat buildSettings.txt | grep -m1 "DEVELOPER_DIR" | sed 's/^.*= //' )"
 export SOURCE_ROOT="$(cat buildSettings.txt | grep -m1 "SOURCE_ROOT" | sed 's/^.*= //' )"
 export SDKROOT="$(cat buildSettings.txt | grep -m1 "SDKROOT" | sed 's/^.*= //' )"
 export PLATFORM_DIR="$(cat buildSettings.txt | grep -m1 "PLATFORM_DIR" | sed 's/^.*= //' )"
 export INFOPLIST_FILE="$(cat buildSettings.txt | grep -m1 "INFOPLIST_FILE" | sed 's/^.*= //' )"
 export SCRIPT_INPUT_FILE_COUNT=1
 export SCRIPT_INPUT_FILE_0="$TEMP_DIR/rswift-lastrun"
 export SCRIPT_OUTPUT_FILE_COUNT=1
 export SCRIPT_OUTPUT_FILE_0="$SRCROOT/Sources/Resources/Generated/R.generated.swift"
}
 
# Вызов скрипта для генерации
rswift() {
 R.swift generate --accessLevel public "$SCRIPT_OUTPUT_FILE_0"
}
 
# Заменяем бандл в котором R.swift пытается найти ресурсы по дефолту
# Bundle.module - расширение генерируется SPM, если вы в пакете указываете для таргета ресурсные зависимости
replaceRSwiftHostingBundle() {
 sed -i '' -e 's/Bundle(for: R.Class.self)/Bundle.module/' ./Sources/Resources/Generated/R.generated.swift
}
 
mkdir Sources/Resources/Generated
generateXcodeProject
getBuildSettings
parseEnvironmentVariables
rswift
replaceRSwiftHostingBundle

Круг 4. Cocoapods зависимость в SPM пакете.

Основная сложность в том, что нет нативного способа подключения к пакету Pod или fat фреймворков, а некоторые разработчики свои библиотеки на SPM переводить не торопятся. Здесь нам поможет proxy пакет-обертка с XCFramework. Если Pod, который нужно подключить к SPM — опенсорсный, то можно скачать исходный код библиотеки и собрать XCFramework по этому гайду

Если Pod распространяется как собранный бинарник через .framework, то придется действовать немного по-другому.

Как и в предыдущем случае, нужно собрать XCFramework. Для начала из fat фреймворка, который поставляется Pod-ом необходимо собрать 2 фреймворка — для симулятора и для девайса. После этого объединим их в универсальный фреймворк. Вот скрипт, который делает XCFramework из фремворка с архитектурами arm64 и x86_64. Узнать архитектуры, которые поддерживает фреймворк можно с помощью команды lipo -info pathtoframework.

# Делаем 2 копии фреймворка для симулятора и устройства
cp -a YandexMapsMobile YandexMapsMobile_sim
cp -a YandexMapsMobile YandexMapsMobile_device
 
cd YandexMapsMobile_sim/YandexMapsMobile.framework/Versions/A
# Здесь мы выбираем какая архитектура нам нужна из fat фреймворка и выносим ее в отдельный фреймворк
lipo -thin x86_64 YandexMapsMobile -output YandexMapsMobile_x86_64
# Создание universal framework-a для симулятора
# Если вы хотите собрать не под одну архитектуру симулятора
# а под несколько (i386), для этого нужно будет сделать два раза thin
# и соединить их в create
lipo -create YandexMapsMobile_x86_64 -output YandexMapsMobile_sim
rm -rf YandexMapsMobile YandexMapsMobile_x86_64
mv YandexMapsMobile_sim YandexMapsMobile
cd ../../../..
 
# Просто дублирование кода, но сборка под девайс
cd YandexMapsMobile_device/YandexMapsMobile.framework/Versions/A
lipo -thin arm64 YandexMapsMobile -output YandexMapsMobile_arm64
lipo -create YandexMapsMobile_arm64 -output YandexMapsMobile_device
rm -rf YandexMapsMobile YandexMapsMobile_arm64
mv YandexMapsMobile_device YandexMapsMobile
cd ../../../..
 
# Объединяем в xcframework
xcodebuild -create-xcframework -framework YandexMapsMobile_sim/YandexMapsMobile.framework -framework YandexMapsMobile_device/YandexMapsMobile.framework -output YandexMapsMobile.xcframework

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

let package = Package(
    name: "YandexMapsMobileWrapper",
    platforms: [
        .iOS(.v11),
    ],
    products: [
        .library(
            name: "YandexMapsMobileWrapper",
            type: .static,
            // Используем таргет обертку над XCFramework и его зависимостями
            targets: ["YandexMapsMobileWrapper"]),
    ],
    dependencies: [
    ],
    targets: [
        // Подключаем наш фреймворк локально, также его можно добавлять и через url
        .binaryTarget(name: "YandexMapsMobileBinary", path: "YandexMapsMobile.xcframework"),
        // обертываем наш фреймворк зависимостями
        .target(
            name: "YandexMapsMobileWrapper",
            dependencies: [
                .target(name: "YandexMapsMobileBinary"),
            ],
            linkerSettings: [
                .linkedFramework("CoreLocation"),
                .linkedFramework("CoreTelephony"),
                .linkedFramework("SystemConfiguration"),
                .linkedLibrary("c++"),
                .unsafeFlags(["-ObjC"]),
            ]),
    ]
)

Круг 5. Краш при попытке получить бандл SPM пакета (Bundle.module).

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

private class BundleFinder {}
 
 
// Это копия автосгенерированого SPM расширения, есть 2 отличия
public extension Bundle {
    
    // Отличие №1, другое название, чтобы не было конфликтов
    static var resourceBundle: Bundle = {
 
        let bundleName = "Resources_Resources"
        let candidates = [
            Bundle.main.resourceURL,
            Bundle(for: BundleFinder.self).resourceURL,
            Bundle.main.bundleURL,
            // Отличие №2, еще один путь где может лежать бандл с ресурсами
            Bundle(for: BundleFinder.self).resourceURL?.deletingLastPathComponent().deletingLastPathComponent(),
        ]
        for candidate in candidates {
            let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle")
            if let bundle = bundlePath.flatMap(Bundle.init(url:)) {
                return bundle
            }
        }
        fatalError("unable to find bundle named (bundleName)")
    }()
    
}

Круг 6. Блокирующая Resolve Swift Packages стадия.

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

Круг 7. Other Linker Flags и SPM.

Может возникнуть ситуация, когда пакет должен линковаться со специальными флагами. Частый пример — это ObjC флаг. Эти флаги для линковщика можно указать с помощью likerSettings: [.unsafeFlags([“ObjC”])] в таргете вашего пакета. К сожалению, если ваш пакет использует unsafeFlags, то его нельзя подключить к проекту напрямую, только через прокси-пакет, и только с указанием его как локальной или branch зависимости. 

Вывод

Swift Package Manager —  удобный и интуитивно понятный нативный менеджер зависимостей. Однако, если вы планируете использовать его для разбивки приложения на подпроекты-пакеты, то нужно быть готовым к временным рискам и проблемам. К счастью, почти любая проблема связанная с SPM имеет свой workaround, но готовы ли вы идти на них и искать их?

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



Перейти в источник

Похожие статьи

О классах Program и Startup — инициализация ASP.NET приложения. Часть II: IWebHostBuilder и Startup / Хабр

0 Favorite [ Введение Это – продолжение статьи, первая часть которой была опубликована ранее. В той части был рассмотрен процесс инициализации, общий для любого приложения…

Инвентаризация ИТ-активов штатными средствами Windows с минимальными правами доступа

0 Favorite [ Коллеги, в предыдущей статье мы обсудили принципы эффективной работы с событиями аудита ОС Windows. Однако, для построения целостной системы управления ИБ важно…

Ответы