Search
Write a publication
Pull to refresh

Игра в SPM

Level of difficultyMedium
Reading time12 min
Views478

Привет, хабражители!

На Хабре уже немало статей об опыте перехода на Swift Package Manager — успешных и не очень. В моем случае переезд прошел удачно, но на каждом шагу возникали проблемы, которые было сложно нагуглить или вообще найти решения. Поэтому я решил собрать все накопленные знания и поделиться ими в этой статье, надеясь, что другие разработчики сэкономят кучу времени.

Немного предыстории

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

Первый звоночек прозвенел в прошлом году: CocoaPods объявили о прекращении развития проекта и переходе в режим поддержки. Это означает, что в какой-то момент, возможно через год-два, а может и через пять лет, инструмент может перестать поддерживать новые версии macOS или Xcode.

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

За день до "пожара" у всех стояла версия xcodeproj 1.25.1. Версия 1.26.0 вышла с таким описанием:

Add support for file system synchronized groups introduced in Xcode 16.

Update default build settings for Xcode 16.0.

Ребята добавили 10 новых флагов в проект, из-за чего при сборке начала появляться ошибка:

Double-quoted include "File.h" in framework header, expected angle-bracketed instead.

CocoaPods убрали часть новых флагов, но ситуация не улучшилась: по всему проекту начали появляться странные ошибки, например, что нельзя использовать @objc внутри extension, или что публичное свойство объявлено повторно, без указания места второго объявления.

После командного расследования мы поняли, что проблема не в CocoaPods, а в xcodeproj. Обновление произошло потому, что зависимость в CocoaPods была прописана так:

xcodeproj >= 1.23.0, < 2.0

Мы сразу откатили в Gemfile версию CocoaPods до 1.15.2, а xcodeproj до 1.25.1.

Через несколько дней разработчики xcodeproj откатили свои изменения в версии 1.27.0:

Revert default build settings changes from

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

Переносим внешние зависимости

Я решил начать с переноса сторонних зависимостей. Несколькими месяцами ранее мы обновили всё, что можно, до свежих версий в CocoaPods и проверили библиотеки на поддержку SPM. Всё прошло гладко: я добавлял очередную зависимость в SPM, удалял из Podfile и проверял запуск проекта. Под конец ситуацию осложнило то, что нам нужна поддержка iOS 12.4, а некоторые библиотеки уже требуют iOS 13, поэтому приходилось брать более старые версии без поддержки SPM.

Теперь самое интересное. Чтобы окончательно избавиться от CocoaPods, нужно перенести 10 внутренних зависимостей из нашего приватного репозитория. Признаюсь, раньше я думал, что SPM не дружит с приватными репозиториями. Но, как оказалось, эту проблему уже решили. Всё, что нужно — ввести логин и пароль от приватного репозитория при добавлении его в проект. Это придется сделать всем, кто впервые затягивает обновленный проект.

Ни одна из библиотек, которые предстояло перенести, не поддерживала SPM, то есть нужно было сгенерировать Package.swift для каждой.

Полезные советы

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

  1. Редактируйте Package.swift прямо в Xcode. Откройте его в папке проекта, и Xcode сразу покажет ошибки. Исправили — нажимаете ⌘S, проверка запустится снова. ⌘B проверит ошибки в самой библиотеке и соберет её. Решите все проблемы до добавления зависимости в основной проект, так как ошибки при добавлении не всегда информативны, а сам процесс занимает существенно больше времени.

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

Только после успешного переноса всех зависимостей начинайте коммитить изменения и подключать зависимости из репозиториев. Это сэкономит кучу времени.

Игра началась

В первой же библиотеке я столкнулся с тем, что после переноса на SPM она не смогла собраться из-за отсутствующих хедеров (проект на Objective-C). Тут два пути: создать umbrella header или симлинки на все хедеры в проекте. Оба варианта рабочие, иногда umbrella уже есть. Я выбрал второй способ, используя такой скрипт:

Скрытый текст
#!/bin/bash

# Путь к директории с заголовками
HEADERS_DIR="Headers"

# Удаляем старую директорию с заголовками, если она существует, и создаем новую
rm -rf "$HEADERS_DIR"
mkdir -p "$HEADERS_DIR"

find DBDebugToolkit -name "*.h" -not -path "$HEADERS_DIR/*" -exec ln -s ../../{} "$HEADERS_DIR"/ \;

echo "Символические ссылки на заголовки созданы в $HEADERS_DIR"

Скрипт ожидает, что вы положите его на один уровень с исходниками, то есть на уровень ниже, чем Package.swift. В результате создастся папка Headers, которую нужно прописать в Package.swift так:

targets: [
    .target(
        name: "Project",
        path: "path/to/project",
        publicHeadersPath: "Headers"
    )
]

Важно, что publicHeadersPath указывает путь относительно path. Не нужно писать туда "Project/Headers".

Работа с xcframework

В следующем проекте возникла проблема с зависимостью от фреймворка в формате xcframework, который лежит внутри. Решается это добавлением binaryTarget в массив targets и указанием его как зависимости основного таргета:

targets: [
    .target(
        name: "Project",
        dependencies: ["SDK"],
        path: "path/to/project"
    ),
    .binaryTarget(
        name: "SDK",
        path: "path/to/SDK.xcframework"
    )
]

Фиксация версий зависимостей

В наших внутренних зависимостях мы часто берем конкретный коммит. В Xcode это легко сделать при добавлении зависимости в окне package dependencies. А в Package.swift это выглядит так:

dependencies: [
    .package(url: "https://gitlab.com/team/project.git", revision: "ca16675b21c47a24d1c0304acf8adfadcabaea0c")
]

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

Если у проекта уже есть podspec файл, обратите внимание на различные флаги и перенесите их. Например, ENABLE_BITCODE из pod_target_xcconfig переносится в cSettings:

.target(
    name: "Project",
    path: "path/to/project",
    cSettings: [
        .define("ENABLE_BITCODE", to: "NO")
    ]
)

Смешанные библиотеки и старые зависимости

Следующая библиотека принесла две проблемы:

  1. Старые внешние зависимости без поддержки SPM. Решений несколько: скачать и положить их в подпапки проекта, сделать форк или перенести в свой приватный репозиторий с добавлением поддержки SPM. Я выбрал последнее, чтобы другие команды могли вносить свои доработки. Добавить эти зависимости внутрь другой локальной зависимости можно, указав локальный путь:

    dependencies: [
        .package(path: "../AnotherProject"),
    ]

    Не забудьте потом заменить локальные пути на ссылки на репозитории.

  2. Смешанный код (Swift + Objective-C). SPM не поддерживает смешанные исходники в одном таргете:

    **x-xcode-log://AF7657F4-1C95-46DB--35E2-933ED7C728E8 target at '/path/to/project' contains mixed language source files; feature not supported**

    Пришлось разделить исходники по папкам и таргетам. Таргет с Objective-C кодом указываем как зависимость основного таргета. Если Objective-C файлов много, возможно, придется пересмотреть структуру проекта, чтобы изолировать Objective-C и указать в Package.swift путь к папке, где лежит только он.

    targets: [
        .target(
            name: "ObjCProject",
            path: "path/to/ObjC",
            publicHeadersPath: "."
        ),
        .target(
            name: "Project",
            dependencies: ["ObjCProject"],
            path: "path/to/project"
        )
    ]

    Не забываем сгенерировать хедеры для publicHeadersPath.

Борьба с отсутствием импорта

Еще одна библиотека отказалась собираться, выдавая ошибки неизвестного типа CGFloat или UIImage. Это происходит из-за отсутствия umbrella header и генерации папки Headers. Компилятор рассматривает каждый файл отдельно. Решение — добавить недостающие импорты во все файлы. Я написал скрипт:

Скрытый текст
#!/bin/bash

SED_INPLACE_OPTION="-i ''"

# Поиск всех .h файлов рекурсивно
find . -type f -name "*.h" | while read -r file; do
  if grep -q '#import <Foundation/Foundation.h>' "$file"; then
    echo "Строка уже присутствует в файле $file. Пропускаем."
  else
    line_number=$(grep -n '*/' "$file" | head -n 1 | cut -d: -f1)
    if [ -n "$line_number" ]; then
      insert_line=$((line_number + 1))
    else
      insert_line=1
    fi

    sed -i '' "${insert_line}i\\
\\
#import <Foundation/Foundation.h>
" "$file"

    echo "Добавлена строка в файл $file."
  fi
done

Скрипт заточен под мои файлы, которые начинаются с многострочного комментария и заканчивающегося строчкой */. Вы можете поправить условие, чтобы #import вставлялся, например, в первую строчку, либо после стандартного генерируемного Xcode комментария. Если Foundation будет недостаточно, в отдельных файлах можно вручную добавить UIKit или другие фреймворки.

Работа с ресурсами и макросами

В проекте для отладки мы используем DBDebugToolkit. Нам нужна поддержка iOS 12, поэтому пришлось дорабатывать зависимость самостоятельно. После добавления поддержки SPM проект собрался, но стал вылетать с ошибкой доступа к бандлу:

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[NSBundle initWithURL:]: nil URL argument'

Проблема была в файле NSBundle+DBDebugToolkit, где мы получаем debugToolkitBundle:

+ (instancetype)debugToolkitBundle {
    NSBundle *podBundle = [NSBundle bundleForClass:[DBDebugToolkit class]];
    NSURL *bundleURL = [podBundle URLForResource:@"DBDebugToolkit" withExtension:@"bundle"];
    return [NSBundle bundleWithURL:bundleURL];
}

Оказалось, что SPM собирает бандл иначе, и нужно использовать макрос SWIFTPM_MODULE_BUNDLE . Однако, как обычно, есть нюанс: этот макрос почему-то поддерживается только с iOS 13+. И действительно, при попытке использовать его компилятор сообщил мне, что такой макрос не найден. В целом это не проблема, можно разобраться, как устроен главный бандл, и найти, как нам обратиться к нужному. Как же я удивился, когда вообще не обнаружил бандл DBDebugToolkit.

Оказалось, я неправильно сконфигурировал Package.swift. В таргете нужно было указать путь на уровень выше, чтобы включить ресурсы. Сначала в таргете я указал на папку с исходниками path: "DBDebugToolkit/Classes", что казалось логичным. Однако правильнее было указать на уровень выше:

targets: [
    .target(
        name: "DBDebugToolkit",
        path: "DBDebugToolkit",
        publicHeadersPath: "Classes/Headers"
    )
]

После появления бандла стал доступен и макрос SWIFTPM_MODULE_BUNDLE, который находится в файле resource_bundle_accessor.h. Т. е. информация о поддержке только с iOS 13+ оказалась ложной или устаревшей. Но даже без макроса достучаться до бандла можно вот так:

+ (instancetype)debugToolkitBundle {
    NSURL *bundleURL = [[[NSBundle mainBundle] bundleURL] URLByAppendingPathComponent:@"DBDebugToolkit_DBDebugToolkit.bundle"];
    NSBundle *bundle = [NSBundle bundleWithURL:bundleURL];
    return bundle;
}

Если нужно сохранить совместимость с CocoaPods, используйте условную компиляцию:

+ (instancetype)debugToolkitBundle {
#if SWIFT_PACKAGE
    return SWIFTPM_MODULE_BUNDLE;
#else
    NSBundle *podBundle = [NSBundle bundleForClass:[DBDebugToolkit class]];
    NSURL *bundleURL = [podBundle URLForResource:@"DBDebugToolkit" withExtension:@"bundle"];
    return [NSBundle bundleWithURL:bundleURL];
#endif
}

Самое вкусное на десерт

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

Скрытый текст

Шаги по преобразованию статической библиотеки в xcframework

  1. Проверяем какие конкретно архитектуры в библиотеке:

    lipo -info libdtdev.a

    Получаем 3 архитектуры:  arm64 и armv7 — для физических устройств, x86_64 — для симулятора.

  2. Разделяем библиотеку по архитектурам:

    lipo -extract arm64 libdtdev.a -output libdtdev_arm64.a
    lipo -extract armv7 libdtdev.a -output libdtdev_armv7.a
    lipo -extract x86_64 libdtdev.a -output libdtdev_x86_64.a
  3. Создаем фреймворки для каждой архитектуры:

    Если у нас нет папки headers, то её нужно подготовить, просто скопировать туда все хедеры библиотеки. А так же создать umbrella header, если его нет.

    Простое создание фреймворка через xcodebuild -create-xcframework не работает, потому что у нас 2 архитектуры относящиеся к одной платформе (armv7 и arm64). Будем объединять их предварительно в один бинарник.

    Добавляем 2 папки: ios_arm64_armv7, ios_x86_64 с таким содержимым:

    Скрытый текст
    dtdev.framework
    ├── dtdev (переименованный libdtdev_*.a)
    ├── Headers
    │   └── *.h
    ├── Info.plist
    └── Modules
        └── module.modulemap

    Содержимое файла modulemap:

    Скрытый текст
    framework module dtdev {
        umbrella header "DTDevices.h"
    
        export *
        module * { export * }
    }

    Содержимое Info.plist:

    Скрытый текст
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/
    PropertyList-1.0.dtd">
    <plist version="1.0">
    <dict>
        <key>CFBundleName</key>
        <string>dtdev</string>
        <key>CFBundlePackageType</key>
        <string>FMWK</string>
        <key>CFBundleExecutable</key>
        <string>dtdev</string>
        <key>CFBundleShortVersionString</key>
        <string>1.0</string>
        <key>CFBundleVersion</key>
        <string>1</string>
        <key>MinimumOSVersion</key>
        <string>12.0</string>
    </dict>
    </plist>
  4. Объединяем архитектуры в xcframework:

    Скрытый текст
    xcodebuild -create-xcframework \
        -framework ios_arm64_armv7/dtdev.framework \
        -framework ios_x86_64/dtdev.framework \
        -output dtdev.xcframework

Теперь можно подключить xcframework через binaryTarget.

Скрипт для полностью автоматической конвертации:

Скрытый текст
#!/bin/bash

if [ -z "$1" ] || [ -z "$2" ]; then
  echo "Укажите путь к статической библиотеке и umbrella header. Пример: ./create_xcframework.sh path/to/libdtdev.a DTDevices.h"
  exit 1
fi

LIB_PATH="$1"
UMBRELLA_HEADER="$2"
LIB_FILENAME=$(basename "$LIB_PATH")
LIB_NAME=${LIB_FILENAME%.a}
OUTPUT_LIB_NAME=${LIB_NAME#lib}
OUTPUT_DIR="${OUTPUT_LIB_NAME}_build"
OUTPUT_XCFRAMEWORK="${OUTPUT_LIB_NAME}.xcframework"

# Проверяем наличие папки headers в текущей директории
HEADERS_DIR="./headers"
if [ ! -d "$HEADERS_DIR" ]; then
  echo "Папка с заголовками headers не найдена в текущей директории. Убедитесь, что она существует."
  exit 1
fi

# Удаляем предыдущий XCFramework, если он существует
if [ -d "$OUTPUT_XCFRAMEWORK" ]; then
  rm -rf "$OUTPUT_XCFRAMEWORK"
fi

# Создаем временную папку для сборки
mkdir -p "$OUTPUT_DIR"

# Получаем список архитектур из статической библиотеки
ARCHS=$(lipo -info "$LIB_PATH" | rev | cut -d ':' -f1 | rev)

# Обрабатываем архитектуры для iOS и iOS-simulator отдельно
IOS_ARCHS=()
SIMULATOR_ARCHS=()

for ARCH in $ARCHS; do
  if [ "$ARCH" == "arm64" ] || [ "$ARCH" == "arm64e" ] || [ "$ARCH" == "armv7" ] || [ "$ARCH" == "armv7s" ]; then
    IOS_ARCHS+=("$ARCH")
  elif [ "$ARCH" == "x86_64" ]; then
    SIMULATOR_ARCHS+=("$ARCH")
  else
    echo "Неизвестная архитектура $ARCH, пропускаем."
  fi
done

# Функция для создания временного фреймворка с именем dtdev.framework для указанной платформы
create_framework() {
  local platform=$1
  shift
  local archs=("$@")

  # Папка для фреймворка (например, ios_arm64 или ios_x86_64)
  FRAMEWORK_DIR="${OUTPUT_DIR}/${platform}/dtdev.framework"
  mkdir -p "$FRAMEWORK_DIR/Headers"
  mkdir -p "$FRAMEWORK_DIR/Modules"

  # Извлекаем и сохраняем архитектуры в отдельные подпапки
  INPUT_ARCH_FILES=()
  for ARCH in "${archs[@]}"; do
    ARCH_FILE="${OUTPUT_DIR}/${platform}/dtdev_${ARCH}.a"
    lipo -extract "$ARCH" "$LIB_PATH" -o "$ARCH_FILE"

    if [ -f "$ARCH_FILE" ]; then
      echo "Извлечена архитектура $ARCH в файл $ARCH_FILE"
      INPUT_ARCH_FILES+=("$ARCH_FILE")
    else
      echo "Ошибка: не удалось извлечь архитектуру $ARCH"
    fi
  done

  # Объединяем архитектуры в один бинарник с именем dtdev
  lipo -create "${INPUT_ARCH_FILES[@]}" -output "${FRAMEWORK_DIR}/dtdev"
  if [ ! -f "${FRAMEWORK_DIR}/dtdev" ]; then
    echo "Ошибка: не удалось создать объединённый бинарник для платформы $platform"
    exit 1
  fi
  echo "Объединённый бинарник создан: ${FRAMEWORK_DIR}/dtdev"

  # Копируем заголовочные файлы в фреймворк
  cp -R "${HEADERS_DIR}/" "${FRAMEWORK_DIR}/Headers/"

  # Создаем module.modulemap
  cat > "${FRAMEWORK_DIR}/Modules/module.modulemap" <<EOF
framework module ${OUTPUT_LIB_NAME} {
    umbrella header "${UMBRELLA_HEADER}"

    export *
    module * { export * }
}
EOF

  # Создаем Info.plist для каждого фреймворка
  cat > "${FRAMEWORK_DIR}/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/
PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleName</key>
    <string>${OUTPUT_LIB_NAME}</string>
    <key>CFBundlePackageType</key>
    <string>FMWK</string>
    <key>CFBundleExecutable</key>
    <string>dtdev</string>
    <key>CFBundleShortVersionString</key>
    <string>1.0</string>
    <key>CFBundleVersion</key>
    <string>1</string>
    <key>MinimumOSVersion</key>
    <string>12.0</string>
</dict>
</plist>
EOF

  echo "Фреймворк ${FRAMEWORK_DIR} создан успешно."

  # Добавляем путь к фреймворку в массив для создания XCFramework
  INPUT_PARAMS+=("-framework" "${FRAMEWORK_DIR}")
}

# Подготовка массива INPUT_PARAMS для создания XCFramework
INPUT_PARAMS=()

# Создаем фреймворк для iOS
if [ ${#IOS_ARCHS[@]} -gt 0 ]; then
  create_framework "ios_arm64_armv7" "${IOS_ARCHS[@]}"
else
  echo "Архитектуры iOS не найдены. Пропускаем создание фреймворка для iOS."
fi

# Создаем фреймворк для iOS-simulator
if [ ${#SIMULATOR_ARCHS[@]} -gt 0 ]; then
  create_framework "ios_x86_64" "${SIMULATOR_ARCHS[@]}"
else
  echo "Архитектуры iOS-simulator не найдены. Пропускаем создание фреймворка для iOS-simulator."
fi

# Создаем XCFramework
xcodebuild -create-xcframework "${INPUT_PARAMS[@]}" -output "$OUTPUT_XCFRAMEWORK"

# Проверка успешного создания XCFramework
if [ -d "$OUTPUT_XCFRAMEWORK" ]; then
  echo "XCFramework успешно создан: $OUTPUT_XCFRAMEWORK"
else
  echo "Ошибка: не удалось создать XCFramework"
fi

# Удаляем временную папку
rm -rf "$OUTPUT_DIR"
echo "Временная папка $OUTPUT_DIR удалена."

Пример вызова:

./create_xcframework.sh libdtdev.a DTDevices.h

libdtdev.a - библиотека для конвертации
DTDevices.h - umbrella header

Заключение

Пару слов о совместимости CocoaPods и SPM. Они могут сосуществовать в одном проекте в ограниченном виде, но только пока не пересекаются зависимости. Например, если одна библиотека через SPM тащит Alamofire, а другая через CocoaPods — тоже, то при запуске приложения получите кучу ошибок вида:

Скрытый текст

Class _TtC9Alamofire11DataRequest is implemented in both /private/var/containers/Bundle/Application/7AC9F854-BBAC-437A-B556-4DB94E6017DA/Foo.app/Frameworks/Alamofire.framework/Alamofire (0x10193aaf0) and /private/var/containers/Bundle/Application/7AC9F854-BBAC-437A-B556-4DB94E6017DA/Foo.app/Foo.debug.dylib (0x1075719b8). One of the two will be used. Which one is undefined.

Надеюсь, эта информация сэкономит вам часы, а то и дни переезда на SPM. Все скрипты можно найти в моем репозитории SPMMigrationKit.

Удачной миграции и поменьше вам зависимостей!

Tags:
Hubs:
+2
Comments15

Articles