Разработка
Как в Swift Package Manager сэкономить гигабайты трафика и места на диске
В этой статье я покажу, как использовать SPM для хранения зависимостей в репозитории и реализовать это лучше, чем в CocoaPods.
Многие из вас сталкивались с неприятной ситуацией — открываешь проект или переключаешь ветку, и видишь печальную картину того, как SPM ресолвит пакеты.
Одним из преимуществ CocoaPods по сравнению с SPM является то, что проверки зависимостей хранятся вместе с проектом непосредственно в репозитории. Это позволяет безболезненно запускать проект из любого коммита и не тратить время на CI для загрузки зависимостей и их разрешения.
В этой статье я покажу, как использовать SPM для хранения зависимостей в репозитории и реализовать это лучше, чем в CocoaPods.
Прежде чем начать, давайте определим список требований к будущему решению:
- Мы продолжаем жить в парадигме пакетов Swift
- Внешние зависимости пакетов становятся локальными
- Требуется механизм локального клонирования внешних зависимостей
- Храним локально только те файлы из репозиториев зависимостей, которые необходимы проекту
Получив такую конфигурацию, мы можем приступать к реализации.
Клонирование зависимостей
Начнем с клонирования зависимостей. Для этого создадим отдельный локальный пакет и добавим все необходимые зависимости в 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, содержащим клоны локальных зависимостей:
Обратим внимание на размер каталога .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.
Исходный код из этой статьи можно найти в этом репозитории.
Хорошего программирования!