Connect with us

Разработка

Как в Swift Package Manager сэкономить гигабайты трафика и места на диске

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

Опубликовано

/

     
     

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

Одним из преимуществ CocoaPods по сравнению с SPM является то, что проверки зависимостей хранятся вместе с проектом непосредственно в репозитории. Это позволяет безболезненно запускать проект из любого коммита и не тратить время на CI для загрузки зависимостей и их разрешения.

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

Прежде чем начать, давайте определим список требований к будущему решению:

  1. Мы продолжаем жить в парадигме пакетов Swift
  2. Внешние зависимости пакетов становятся локальными
  3. Требуется механизм локального клонирования внешних зависимостей
  4. Храним локально только те файлы из репозиториев зависимостей, которые необходимы проекту

Получив такую конфигурацию, мы можем приступать к реализации.

Клонирование зависимостей

Начнем с клонирования зависимостей. Для этого создадим отдельный локальный пакет и добавим все необходимые зависимости в Package.swift:

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

import PackageDescription

let package = Package(
    name: "VendorPackages",
    platforms: [
        .iOS(.v15),
        .macOS(.v12),
    ],
    products: [
        .library(
            name: "VendorPackages",
            targets: ["VendorPackages"]
        ),
    ],
    dependencies: [
        .package(
            url: "https://github.com/devicekit/DeviceKit.git",
            exact: "5.0.0"
        ),
        .package(
            url: "https://github.com/kean/Nuke.git",
            exact: "12.1.2"
        ),
        .package(
            url: "https://github.com/groue/GRDB.swift.git",
            exact: "6.15.1"
        ),
        .package(
            url: "https://github.com/SnapKit/SnapKit",
            exact: "5.6.0"
        ),
        .package(
            url: "https://github.com/airbnb/lottie-ios",
            exact: "4.2.0"
        ),
    ],
    targets: [
        .target(
            name: "VendorPackages",
            dependencies: [
                "DeviceKit",
                .product(name: "Nuke", package: "Nuke"),
                .product(name: "NukeUI", package: "Nuke"),
                .product(name: "NukeExtensions", package: "Nuke"),
                "SnapKit",
                .product(name: "GRDB", package: "GRDB.swift"),
                .product(name: "Lottie", package: "lottie-ios"),
            ]
        ),
    ]
)

Этот пакет не содержит никакого кода. Его единственная ценность — манифест зависимостей.

Открыв пакет в Xcode или выполнив команду swift package resolve, можно заметить, что в каталоге пакета появился скрытый каталог .build с подкаталогом checkouts, содержащим клоны локальных зависимостей:

Как в Swift Package Manager сэкономить гигабайты трафика и места на диске

Обратим внимание на размер каталога .build. Он равен 993 МБ, из которых 328 МБ занимают checkouts и 665 МБ — repositories. Я специально выбрал популярные пакеты (GRDB, Lottie) с длительной историей, чтобы продемонстрировать масштаб проблемы.

Минимальные файлы

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

Нас совершенно не интересуют ни тестовые target файлы, ни различные демонстрационные ресурсы. Иными словами, все то, что просто занимает место на диске и не используется при компиляции проекта. При необходимости эти «лишние» файлы всегда можно использовать отдельно.

Определение этого минимума файлов на первый взгляд может показаться нетривиальной задачей, но на практике SPM CLI предоставляет нам все необходимое:

swift package describe --type=json

Используем эту команду в каталоге, где находится, например, пакет Nuke:

{
  "dependencies" : [

  ],
  "manifest_display_name" : "Nuke",
  "name" : "Nuke",
  "path" : ".build/checkouts/Nuke",
  "platforms" : [...],
  "products" : [...],
  "targets" : [
    {
      "c99name" : "NukeVideo",
      "module_type" : "SwiftTarget",
      "name" : "NukeVideo",
      "path" : "Sources/NukeVideo",
      "product_memberships" : [
        "NukeVideo"
      ],
      "sources" : [
        "AVDataAsset.swift",
        "ImageDecoders+Video.swift",
        "VideoPlayerView.swift"
      ],
      "target_dependencies" : [
        "Nuke"
      ],
      "type" : "library"
    },
    {
      "c99name" : "NukeUI",
      "module_type" : "SwiftTarget",
      "name" : "NukeUI",
      "path" : "Sources/NukeUI",
      "product_memberships" : [
        "NukeUI"
      ],
      "sources" : [
        "FetchImage.swift",
        "Internal.swift",
        "LazyImage.swift",
        "LazyImageState.swift",
        "LazyImageView.swift"
      ],
      "target_dependencies" : [
        "Nuke"
      ],
      "type" : "library"
    },
    {
      "c99name" : "NukeExtensions",
      "module_type" : "SwiftTarget",
      "name" : "NukeExtensions",
      "path" : "Sources/NukeExtensions",
      "product_memberships" : [
        "NukeExtensions"
      ],
      "sources" : [
        "ImageLoadingOptions.swift",
        "ImageViewExtensions.swift"
      ],
      "target_dependencies" : [
        "Nuke"
      ],
      "type" : "library"
    },
    {
      "c99name" : "Nuke",
      "module_type" : "SwiftTarget",
      "name" : "Nuke",
      "path" : "Sources/Nuke",
      "product_memberships" : [
        "Nuke",
        "NukeUI",
        "NukeVideo",
        "NukeExtensions"
      ],
      "sources" : [/* a lot of sources here */],
      "type" : "library"
    }
  ],
  "tools_version" : "5.6"
}

JSON-вывод содержит массив целей, в котором буквально находятся все необходимые исходные файлы, а также продукты, включающие эти цели (если таргет содержит ресурсы, то они будут находиться по ключу «resources»).

Вот! 100% то, что нам нужно.

Копируем файлы

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

Я сделал это с помощью простого shell-скрипта и утилиты jq. Вы можете использовать привычные для вас инструменты, опираясь на мою реализацию в качестве образца:

rm -rf Sources && mkdir -p Sources

cd _Proxy # directory for local package with remote dependencies

swift package clean
swift package update

required_products=$(swift package describe --type json | jq -c '(.targets[] | select(.name=="VendorPackages")) | .product_dependencies[]')

for repo in $(ls .build/checkouts); do
    echo $repo

    mkdir -p ../Sources/$repo

    cp -r .build/checkouts/$repo/Package.swift ../Sources/$repo/_Package.swift

    package_json=$(swift package --package-path .build/checkouts/$repo describe --type json | jq -c)

    targets=$(jq -c '(.targets[] | select(.product_memberships != null))' <<< $package_json)

    echo "$package_json" | jq -c '(.targets[] | select(.product_memberships != null))' | while read -r target; do
        required_target=false

        target_products=$(jq -c '.product_memberships[]' <<< "$target")

        for target_product in $target_products; do
            for required_product in $required_products; do
                if [ $required_product == $target_product ]; then
                    required_target=true
                fi
            done
        done

        if ! $required_target; then
            continue
        fi

        name=$(jq -r '.name' <<< $target)
        path=$(jq -r '.path' <<< $target)
        type=$(jq -r '.type' <<< $target)

        if [ $type == "system-target" ]; then
            mkdir -p ../Sources/$repo/$path
            cp -r .build/checkouts/$repo/$path/. ../Sources/$repo/$path
        fi

        if [ $type == "library" ]; then
            echo "$target" | jq --raw-output '.sources[]' | while read -r source; do
                mkdir -p ../Sources/$repo/$path/"$(dirname "$source")"
                cp .build/checkouts/$repo/$path/"$source" ../Sources/$repo/$path/"$source"
            done
        fi
    done
done

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

Используем их

Последний вопрос, на который необходимо ответить, — как на самом деле использовать скопированные файлы в нашем проекте.

Здесь нет единого ответа. Например, можно развернуть скрипт и скопировать манифесты пакетов вместе с другими файлами, каким-то образом удалив оттуда неиспользуемые цели: с помощью регулярных выражений, модификаций AST и т.д.

Я решил не автоматизировать этот шаг и добавил еще один локальный пакет, в котором зарегистрировал все зависимости — указав локальные пути к скопированным файлам:

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

import PackageDescription

let package = Package(
    name: "VendorPackages",
    platforms: [
        .iOS(.v15),
        .macOS(.v12),
    ],
    products: [
        .library(name: "DeviceKit", targets: ["DeviceKit"]),
        .library(name: "GRDB", targets: ["GRDB"]),
        .library(name: "Lottie", targets: ["Lottie"]),
        .library(name: "Nuke", targets: ["Nuke"]),
        .library(name: "NukeUI", targets: ["NukeUI"]),
        .library(name: "NukeExtensions", targets: ["NukeExtensions"]),
        .library(name: "SnapKit", targets: ["SnapKit"]),
    ],
    targets: [
        .target(
            name: "DeviceKit",
            path: "Sources/DeviceKit/Source"
        ),
        .target(
            name: "Nuke",
            path: "Sources/Nuke/Sources/Nuke"
        ),
        .target(
            name: "NukeUI",
            dependencies: ["Nuke"],
            path: "Sources/Nuke/Sources/NukeUI"
        ),
        .target(
            name: "NukeExtensions",
            dependencies: ["Nuke"],
            path: "Sources/Nuke/Sources/NukeExtensions"
        ),
        .target(
            name: "GRDB",
            dependencies: ["CSQLite"],
            path: "Sources/GRDB.swift/GRDB"
        ),
        .systemLibrary(
            name: "CSQLite",
            path: "Sources/GRDB.swift/Sources/CSQLite"
        ),
        .target(
            name: "Lottie",
            path: "Sources/lottie-ios/Sources"
        ),
        .target(
            name: "SnapKit",
            path: "Sources/SnapKit/Sources"
        ),
    ],
    swiftLanguageVersions: [.v5]
)

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

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

В 211 раз меньше

Невероятно, но факт — размер скопированных файлов составляет 4.7 МБ — это в 211 раз меньше, чем наш каталог .build.

Исходный код из этой статьи можно найти в этом репозитории.

Хорошего программирования!

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.

Наши партнеры:

LEGALBET

Мобильные приложения для ставок на спорт
Хорошие новости

Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: