Недавно мы перешли с CocoaPods на другой менеджер зависимостей — Carthage. Оказалось, что у этой простой на первый взгляд задачи много подводных камней.
Чтобы сменить менеджер зависимостей и не страдать, нужны две вещи:
заранее понимать, какая конечная цель у этой задачи,
знать, какие подводные камни ожидают на пути.
Этой статьей мы как раз хотим помочь с этими двумя пунктами тем, кто захочет повторить переезд на Carthage. По ходу статьи будем составлять список требований, чтобы точнее определиться с целью и рассказывать о граблях, на которые наступили. В ней не будет пошагового руководства, так как в каждом проекте есть свои особенности и потребности. А еще мы не будем объяснять базовые действия, иначе статья перерастет в учебник.
Мотивация и цель
Немного подробностей о проекте, чтобы вы понимали нашу мотивацию. В нашем приложении была 61 зависимость, подключеная через CocoaPod (включая subspec). Среди них были утилиты, наши фреймворки (сторонние и несколько форков, которые мы делали, чтобы исправить баги в мертвых репозиториях). Помимо этого через девелоперские поды мы подключали внутренние модули приложения. Пока их не много, но мы наращиваем их количество. Модули находились в том же репозитории, что и само приложение. Все эти зависимости линковались в приложение статически для ускорения запуска. Время сборки после очистки проекта около 10 минут. Измерить точнее сложно, так как из-за тротлинга макбука время сборки сильно плавает.
Когда мы начинали работу над этой задачей, у нас было несколько вариантов того, как ее можно решить, и наши требования к решению были довольно расплывчаты:
уменьшить время сборки проекта,
уменьшить влияние менеджера зависимостей на проект,
сохранить статическую линковку.
Медленная сборка — это основная наша мотивация. Планировали ее ускорить за счет того, что фреймворки будут собираться отдельно от проекта и их не нужно будет пересобирать даже после очистки проекта. Влияние CocoaPods на проект (а точнее, воркспейс) — не такая большая проблема, но нам было не очень комфортно, что он делает с приложением какую-то магию. Плюс его манипуляции создавали ненужный diff в файлах Xcode.
Было бы хорошо в этот момент понять, каким будет финальный вариант и сразу приступить к его реализации. Но так как требования у нас изначально были не очень строгие, и с другими менеджерами зависимостей были мы знакомы не так хорошо, пришлось действовать итеративно.
Менеджер зависимостей нужен
Вариант отказаться от менеджера зависимостей даже не рассматривался. Мы можем подключать локальные модули напрямую, но для сторонних зависимостей нужен удобный механизм подключения и версионирования. Любой менеджер зависимостей это умеет, поэтому не будем записывать как требования, но учтем, что для локальных модулей это не обязательно:
локальные модули в репозитории приложения можно подключать просто как дочерний проект к воркспейсу или вынести в таргеты в проекте приложения.
Работать с сабмодулями неудобно
Наверно, это субъективное мнение, но мы в нем сразу сошлись и отбросили любые варианты, предполагающие использование git сабмодулей:
не использовать git сабмодули.
Swift Package Manager довольно сырой
Конечно, одним из первых вариантов было использование нативного Swift Package Manager (SPM). Мы создали тестовый проект и попробовали подключить несколько зависимостей. Делается это прямо в Xcode — нужно лишь указать ссылку на репозиторий. SPM позволяет подключить и локальные пакеты из того же репозитория, что и само приложение.
У этой простоты есть и обратная сторона — возможностей SPM нам явно недостаточно:
в нем нельзя менять тип линковки зависимостей,
нельзя обновить конкретную зависимость (только все сразу),
нельзя закэшировать собранные зависимости (так как они сохраняются в недрах DerivedData и удаляются при очистке проекта),
нет хуков или какого-то другого механизма для выполнения скриптов во время компиляции зависимостей (но скоро будут).
А еще он тоже вносит изменения в файл проекта, так как вся информация о подключенных зависимостях хранится в нем.
Для поддержки SPM нужно добавить в репозиторий фреймворка файл Package.swift — аналог pod.spec. Его формат проще, но хотелось бы обходиться вообще без файла со спецификацией, так как во многих фреймворках нет поддержки SPM и его пришлось бы добавлять самостоятельно в форке.
SwiftPM постоянно развивается и, возможно, в будущем он вытеснит другие решения, но текущем виде нам он не подходит. Добавляем ещё требования:
иметь возможность обновить конкретную зависимость,
иметь возможность кэширования собранных зависимостей,
иметь возможность запускать скрипты до компиляции зависимостей,
минимальные изменения в фреймворке для поддержки решения.
Делать все самим очень долго
Конечно, можно написать свой менеджер зависимостей, но на это нужно довольно много ресурсов. Скорее всего, рациональным решением будет выбрать что-то готовое и попытаться решить существующие проблемы. Главное, чтобы готовое решение имело какие-то точки для интеграции.
С такими мыслями мы начали изучать Carthage. В отличие от SwiftPM, он достаточно гибок и не прячет свою логику внутри Xcode. Дальше мы будем описывать сложности, с которыми мы столкнулись при использовании Carthage, и как их решили.
Не все зависимости поддерживают Carthage
Из трех готовых решений Carthage требует меньше всего доработок во фреймворке. Все, что нужно — это сделать схему в проекте с фреймворком публичной. Но это в теории. На практике некоторые фреймворки отказываются собираться из-за ошибок при компиляции. Например, в одном из них имена методов в категории для UIImage пересекались с системными. Пока мы собирали его как часть приложения, компилятор прощал эту небрежность. Еще в некоторых проектах не было xcodeproj файлов, так как для CocoaPods они не нужны. Нам пришлось сделать несколько форков сторонних зависимостей чтобы исправить ошибки.
Carthage не поддерживает локальные фреймворки
Подключить локальные фреймворки через Carthage не получится — он так не умеет. Это значит, что придется либо выносить внутренние модули в отдельный репозиторий, либо подключать их как-то иначе. Мы остановились на втором способе, так как первый требует слишком много действий. Ниже остановимся на этом чуть подробнее.
Нельзя положить несколько Carthage зависимостей в репозиторий
Для Carthage один репозиторий — одна зависимость. Можно добавить в проект несколько таргетов, но с этим будет неудобно работать. Этот вариант скорее для случая когда фреймворк поддерживает несколько платформ, и каждая из них выделяется в отдельный таргет. Также это означает, что мы не могли бы положить все внутренние модули в один отдельный репозиторий и подключить его к приложению. Пришлось бы создавать отдельный репозиторий для каждого модуля.
В итоге мы положили каждый модуль в отдельную папку со своим xcodeproj файлом и просто подключили их к воркспейсу приложения как дочерний проект. Такое решение не позволяет собрать их заранее, но так как модулей у нас пока немного — это не критично. В будущем вернемся к этому вопросу.
Нельзя выбрать статическую линковку
К сожалению, поменять тип линковки фреймворка может только CocoaPods, так как он оборачивает в себя все зависимости и линкует свой мега фреймворк как вам нужно. Однако Carthage поддерживает статические фреймворки (но для этого они должны изначально быть статическими, т.е иметь в build settings параметр mach-o type в значении static library).
Можно было бы сделать форки всех зависимостей и поменять им этот параметр, но это много лишней работы. Если знать, как работает Carthage, эту операцию можно автоматизировать.
Carthage устроен довольно просто. Он скачивает все зависимости, указанные в Cartfile в папку Checkouts. Потом он собирает все публичные схемы в этих зависимостях и кладет результат в папку Builds. Далее вы вручную добавляете эти фреймворки в проект с приложением и все. Скачивание и сборку зависимостей можно выполнять разными командами: checkout и build. Благодаря этому и тому, что папка Checkouts лежит на видном месте, мы можем вносить в зависимости любые изменения между вызовом этих команд.
carthage checkout
carthage build --cache-builds --no-use-binaries --configuration Debug --platform iOS
Параметр --no-use-binaries указывает, что Carthage не должен использовать фреймворки в бинарном формате, которые мейнтейнеры могут прикладывать к релизу. Нам нужно собирать их самостоятельно, так как все они динамические (на самом деле некоторые фреймворки поставляются со статическим вариантом, но мы их игнорируем, так как остальные все равно придется модифицировать).
Поменять настройки проекта оказалось довольно просто. Для этого мы написали небольшой скрипт на Swift с помощью фреймворка Xcodeproj.
Небольшое отступление по поводу скриптов. Так уж случилось, что у нас есть скрипты на пяти языках, но мы активно переносим все что можем на Swift. На момент переезда на Carthage у нас уже был проект с несколькими скриптами для CI, к которым через SwiftPM подключен swift-argument-parser. Поэтому нужно было лишь добавить новую зависимость и таргет в Package.swift.
private enum Constants {
static let carthageBuildPath = "$(PROJECT_DIR)/Carthage/Build/iOS/Static"
}
// let checkoutsPathURL: URL // Injected path
private func transformFrameworksToStatic() throws {
try enumerateXcodeProjects { projectURL in
let projectPath = Path(projectURL.path)
let project = try XcodeProj(path: projectPath)
for target in project.pbxproj.nativeTargets where target.productType == .framework {
for configuration in target.buildConfigurationList?.buildConfigurations ?? [] {
configuration.buildSettings["MACH_O_TYPE"] = "staticlib"
configuration.buildSettings["GCC_INSTRUMENT_PROGRAM_FLOW_ARCS"] = "NO"
configuration.buildSettings["CLANG_ENABLE_CODE_COVERAGE"] = "NO"
configuration.buildSettings["SWIFT_VERSION"] = "5.0"
if let searchPaths = configuration.buildSettings["FRAMEWORK_SEARCH_PATHS"] {
if
let searchPathString = searchPaths as? String,
searchPathString != Constants.carthageBuildPath,
!searchPathString.isEmpty
{
configuration.buildSettings["FRAMEWORK_SEARCH_PATHS"] = [
searchPathString,
Constants.carthageBuildPath
]
} else if
var searchPathArray = searchPaths as? [String],
!searchPathArray.contains(Constants.carthageBuildPath)
{
searchPathArray.append(Constants.carthageBuildPath)
configuration.buildSettings["FRAMEWORK_SEARCH_PATHS"] = searchPathArray
}
}
}
}
try project.writePBXProj(path: projectPath, outputSettings: PBXOutputSettings())
}
}
private func enumerateXcodeProjects(_ closure: (URL) throws -> Void) throws {
try enumerateDependecyFiles { fileURL in
guard fileURL.pathExtension == "xcodeproj" else { return }
try closure(fileURL)
}
}
private func enumerateDependecyFiles(_ closure: (URL) throws -> Void) throws {
let fm = FileManager.default
for dependency in try fm.contentsOfDirectory(atPath: checkoutsPathURL.path) {
guard dependency != ".DS_Store" else { continue }
let dependencyURL = checkoutsPathURL.appendingPathComponent(dependency, isDirectory: true)
guard let enumerator = fm.enumerator(atPath: dependencyURL.path) else { return }
for case let relativeFilePath as String in enumerator {
try closure(dependencyURL.appendingPathComponent(relativeFilePath))
}
}
}
Как видно из кода, есть еще несколько параметров кроме MACH_O_TYPE, которые нам пришлось подправить. В частности, нужно было пробросить путь к фреймворкам в FRAMEWORK_SEARCH_PATHS. Остальные параметры в неправильных значениях просто не позволяли зависимостям скомпилироваться.
Эта функция, как и сами команды Carthage, вызывается из основного скрипта. В него передается название команды и тип сборки. Чтобы было еще проще все это запускать, мы добавили небольшой makefile:
build_carthage:
cd Scripts/CLI && WORKSPACE="$(PWD)/.." swift run Carthage --command build --configuration debug $(D)
build_carthage_release:
cd Scripts/CLI && WORKSPACE="$(PWD)/.." swift run Carthage --command build --configuration release $(D)
update_carthage:
[ ! -z $(D) ] && cd Scripts/CLI && WORKSPACE="$(PWD)/.." swift run Carthage --command update $(D)
checkout_carthage:
cd Scripts/CLI && WORKSPACE="$(PWD)/.." swift run Carthage --command checkout
Как проверить, что фреймворки получились действительно статическими? Carthage собирает их в папку static, но для надежности можно воспользоваться командой file.
file Carthage/Build/iOS/Static/RxSwift.framework/RxSwift
Carthage/Build/iOS/Static/RxSwift.framework/RxSwift: Mach-O universal binary with 4 architectures: [arm_v7:current ar archive] [i386] [x86_64] [arm64]
Carthage/Build/iOS/Static/RxSwift.framework/RxSwift (for architecture armv7): current ar archive
Carthage/Build/iOS/Static/RxSwift.framework/RxSwift (for architecture i386): current ar archive
Carthage/Build/iOS/Static/RxSwift.framework/RxSwift (for architecture x86_64): current ar archive
Carthage/Build/iOS/Static/RxSwift.framework/RxSwift (for architecture arm64): current ar archive
Для динамического фреймворка вместо [arm_v7:current ar archive] будет что-то вроде [arm_v7:Mach-O dynamically linked shared library arm_v7].
Ошибка с дублированием архитектур
До выхода маков на процессоре М1 с архитектурами бинарников было просто: если ARM, то это для девайса, если x86_64 — для симулятора. Но с Xcode 12 появилась версия симулятора под arm для новых маков. Carthage, конечно же, собирает фреймворки и для этого симулятора, что приводит к тому, что lipo пытается создать fat binary с двумя вариантами бинарника для одной архитектуры и падает с ошибкой.
Для решения этой проблемы есть скрипт от авторов Carthage. Он просто запрещает собирать фреймворк под симулятор для М1. Это исправляет ошибку, но приводит к тому, что приложение не будет запускаться на новом симуляторе (возможно, будет под Rosetta), но так как мы еще не начинали переход на Apple silicon, нас пока это устраивает.
Нужно кэшировать собранные зависимости
Carthage собирает зависимости в папку build. Можно, конечно, добавить её в Git, но нам не хотелось грузить lfs этими файлами. На машинах разработчиков собранные зависимости останутся между чекаутами (нужно только следить за версиями), но для CI нам это не подходит, т. к. мы делаем очистку репозитория и все файлы, которые находятся в .gitignore удаляются.
Мы решили воспользоваться Rome. Эта утилита кэширует собранные фреймворки и подсовывает во время сборки нужную версию. Кэшировать можно как локально, так и настроить выгрузку в удаленное хранилище. Второе лучше, т. к. позволяет собрать фреймворк один раз и в дальнейшем просто скачивать готовый как на CI, так и на машинах разработчиков:
иметь возможность кэшировать собранные зависимости.
Для релизных и дебажных сборок нужны разные фреймворки
Во время разработки мы хотим использовать фреймворки, собранные с параметром Debug, а на CI — Release. Для этого в основной скрипт передается тип конфигурации. Далее он пробрасывается в Carthage и Rome, чтобы фреймворки не перезаписывали друг друга в кэше.
rome update --cache-prefix Debug
использовать разные конфигурации для релизных и дебажных сборок.
Неудобно дебажить
Так как при использовании Carthage в проекте нет исходного кода фреймворков, то отладка становится менее удобной. Вы все еще можете провалиться во время дебага в сторонний код или использовать символьный брейкпоинт, но просто открыть файл с кодом, чтобы изучить или поставить брейкпоинт мышкой будет нельзя.
Чтобы это исправить, пришлось добавить в воркспейс папку Checkouts. При этом нужно соблюдать два важных пункта:
добавлять код нужно как папку, а не группу,
не добавлять файлы в таргет.
В противном случае код зависимостей будет собираться еще раз, а Xcode будет в шоке от количества схем в воркспейсе :)
Для работы с Xcode проектами мы используем xcodegen. Вот небольшой кусок конфигурации с примером добавления папки Checkouts и одной из зависимостей.
targets:
TargetName:
sources:
- path: ../Shared/Dependencies/Carthage/Checkouts
buildPhase: none
type: folder
dependencies:
- carthage: RxRelay
linkType: static
findFrameworks: false
удобная отладка зависимостей.
Xcode добавляет SwiftPM пакеты зависимостей даже если они не добавлены в таргет
После описанной выше операции в Xcode появился раздел SwiftPM. Все потому, что некоторые зависимости поддерживают SwiftPM и имеют зависимости, подключенные через него. Из-за этого при запуске проекта Xcode начинает скачивать эти зависимости. Чтобы избавиться от этой ненужной операции, достаточно удалить все Package.swift файлы в зависимостях. Для этого мы добавили в скрипт еще одну функцию, которая удаляет все эти файлы и заодно кэш SwiftPM.
private func removeSwiftPMSupport() throws {
let fm = FileManager.default
try enumerateDependecyFiles { fileURL in
if fileURL.lastPathComponent == "Package.swift" || fileURL.lastPathComponent == ".swiftpm" {
try fm.removeItem(atPath: fileURL.path)
}
}
}
не скачивать лишние SwiftPM пакеты.
Итог
В итоге получился вот такой список требований. Если хотите сменить менеджер зависимостей, можете воспользоваться им при составлении своего плана перехода. С ним будет проще решить поставленную задачу.
уменьшить время сборки проекта,
уменьшить влияние менеджера зависимостей на проект,
сохранить статическую линковку,
локальные модули в репозитории приложения можно подключать просто как дочерний проект к воркспейсу или вынести в таргеты в проекте приложения,
не использовать git сабмодули,
иметь возможность обновить конкретную зависимость,
иметь возможность кэширования собранных зависимостей,
иметь возможность запускать скрипты до компиляции зависимостей,
минимальные изменения в фреймворке для поддержки решения,
иметь возможность кэшировать собранные зависимости,
использовать разные конфигурации для релизных и дебажных сборок,
удобная отладка зависимостей,
не скачивать лишние SwiftPM пакеты.
Для нас решением стала реализация нескольких скриптов на Swift поверх Carthage + Rome. На данный момент это закрывает наши потребности в работе с зависимостями. Время сборки сократилось примерно на минуту. Это не много, но мы пока перенесли на Carthage не все зависимости. Плюс мы разделяем приложение на модули и, возможно, получится какие-то из них тоже хранить в собранном виде. Но это уже тема отдельной статьи.