Не так давно прогремела новость, что Cocoapods переходит в режим поддержки. В связи с этим встал вопрос, что дальше. В начале мы склонялись к чисто Swift Package Manager, но потом пришло понимание, что неплохо было бы уйти от конфликтов в project файле и сделать задел на модульность. В этой статье мы пройдем от нашего старого приложения к новому и закончим там, где останется перенести исходный код и все заработает.
Подготовка
Для начала я рекомендую сделать файл, куда с одной стороны поместить зависимости из Podfile, а с другой URL для SPM с номером версии, так будет проще вносить их в Tuist.
Скрытый текст
pod 'GoogleMaps' https://github.com/googlemaps/ios-maps-sdk 9.0.1
pod 'Google-Maps-iOS-Utils' https://github.com/googlemaps/google-maps-ios-utils 6.0.0
pod 'Firebase/Crashlytics' https://github.com/firebase/firebase-ios-sdk.git 11.0.0
pod 'Kingfisher' https://github.com/onevcat/Kingfisher 7.12.0
pod 'Moya' https://github.com/Moya/Moya.git 15.0.3
pod 'ObjectMapper' https://github.com/tristanhimmelman/ObjectMapper.git 4.4.3
pod 'SideMenu' https://github.com/jonkykong/SideMenu.git 6.5.0
pod 'FloatingPanel' https://github.com/scenee/FloatingPanel.git 2.8.5
pod 'YandexMobileMetrica/Dynamic' https://github.com/appmetrica/appmetrica-sdk-ios 5.0.0
pod 'AppsFlyerFramework' https://github.com/AppsFlyerSDK/AppsFlyerFramework-Static 6.15.0
pod 'SkeletonView' https://github.com/Juanpe/SkeletonView.git 1.31.0
pod 'Swinject' https://github.com/Swinject/Swinject.git 2.9.1
pod 'SwinjectStoryboard' https://github.com/Swinject/SwinjectStoryboard.git 2.2.3
pod 'SnapKit' https://github.com/SnapKit/SnapKit.git 5.7.1
pod 'RxSwift' https://github.com/ReactiveX/RxSwift.git 6.7.1
pod 'RxCocoa' входит в RxSwift
pod 'RxDataSources' https://github.com/RxSwiftCommunity/RxDataSources.git 5.0.2
Как видите тут зависимости, которые содержатся почти во всех iOS приложениях(Firebase, Kingfisher) и еще есть старые, которые пора заменить на новые(YandexMobileMetrica/Dynamic)
Теперь поставим Tuist, все достаточно просто:
Ставим Mise
curl https://mise.run | sh
Ставим Tuist
mise install tuist
и активируем его в папке с проектомmise use tuist
Начало
Я рекомендую потренироваться на новом проекте, прежде чем править боевой.
Создадим новый проект:tuist init --name Demo
Приступим к настройке проекта:tuist edit
Займемся сразу зависимостями, это самый долгий шаг, откроем файл Manifests/Tuist/Package.swift и начнем:
В массив dependencies добавляем наши зависимости, указывая url и версию из нашего файла.package(url: "URL", .upToNextMajor(from: "VERSION")),
в итоге получим наши зависимости
Скрытый текст
let package = Package(
name: "demo",
dependencies: [
.package(url: "https://github.com/onevcat/Kingfisher", .upToNextMajor(from: "7.12.0")),
.package(url: "https://github.com/firebase/firebase-ios-sdk.git", .upToNextMajor(from: "11.0.0")),
.package(url: "https://github.com/googlemaps/ios-maps-sdk", .upToNextMajor(from: "9.0.1")),
.package(url: "https://github.com/googlemaps/google-maps-ios-utils", .upToNextMajor(from: "6.0.0")),
.package(url: "https://github.com/Moya/Moya.git", .upToNextMajor(from: "15.0.3")),
.package(url: "https://github.com/ReactiveX/RxSwift.git", .upToNextMajor(from: "6.7.1")),
.package(url: "https://github.com/RxSwiftCommunity/RxDataSources.git", .upToNextMajor(from: "5.0.2")),
.package(url: "https://github.com/tristanhimmelman/ObjectMapper.git", .upToNextMajor(from: "4.4.3")),
.package(url: "https://github.com/jonkykong/SideMenu.git", .upToNextMajor(from: "6.5.0")),
.package(url: "https://github.com/scenee/FloatingPanel.git", .upToNextMajor(from: "2.8.5")),
.package(url: "https://github.com/SwiftKickMobile/SwiftMessages.git", .upToNextMajor(from: "10.0.0")),
.package(url: "https://github.com/appmetrica/appmetrica-sdk-ios", .upToNextMajor(from: "5.0.0")),
.package(url: "https://github.com/AppsFlyerSDK/AppsFlyerFramework-Static", .upToNextMajor(from: "6.15.0")),
.package(url: "https://github.com/Juanpe/SkeletonView.git", .upToNextMajor(from: "1.31.0")),
.package(url: "https://github.com/Swinject/Swinject.git", .upToNextMajor(from: "2.9.1")),
.package(url: "https://github.com/Swinject/SwinjectStoryboard.git", .upToNextMajor(from: "2.2.3")),
.package(url: "https://github.com/SnapKit/SnapKit.git", .upToNextMajor(from: "5.7.1"))
]
)
Выполняем tuist install
для установки зависимостей.
Настройка проекта
Переходим в Manifests/Project.swift тут нас ждет самое интересное. Удалим пока target с тестами, займемся приложением.
В Project кроме имени можно указать organizationName - организацию для copyright.
Теперь перейдем к target и ее настройке, что мы тут видим:
destinations - это поддерживаемые устройства, это множество и можно выбрать все, что необходимо, у меня это destinations: [.iPhone],
product - во что превратится эта target, у меня это product: .app,
bundleId - id нашего приложения, можно сразу указать id исходного, чтобы было меньше проблем с firebase и другими зависимостями
deploymentTargets - минимальная версия iOS, почему-то в темплейте этот параметр отсутствовал, зададим deploymentTargets: .iOS("15.0"),
infoPlist - главный plist нашего приложения, рекомендую сразу перенести исходный infoPlist: .file(path: "demo/Info.plist"),
sources - то, где будет наш код, рекомендую оставить пока без изменений, в будущем перенести все туда sources: ["demo/Sources/**"],
resources - место хранения наших ресурсов, рекомендую вынести выше в отдельную переменную т.к. там будут не только путь до наших картинок(Assets.xcassets), xib, storyboard(если они у вас есть) и GoogleService-Info.plist , но и PrivacyManifest. После заполнения PrivacyManifest я рекомендую сверится с оригиналом
let resources: ProjectDescription.ResourceFileElements =
.resources(
[
"demo/Resources/**",
"demo/**/*.storyboard",
"demo/**/*.xib"
],
privacyManifest: privacyManifest
)
Скрытый текст
let privacyManifest: ProjectDescription.PrivacyManifest = .privacyManifest(
tracking: true,
trackingDomains: [
"firebase-settings.crashlytics.com",
"report.appmetrica.yandex.net",
"usccgg-launches.appsflyersdk.com",
"firebaselogging-pa.googleapis.com"
],
collectedDataTypes: [
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeName",
"NSPrivacyCollectedDataTypeLinked": true,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeProductPersonalization",
],
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeEmailAddress",
"NSPrivacyCollectedDataTypeLinked": true,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeDeveloperAdvertising",
"NSPrivacyCollectedDataTypePurposeProductPersonalization",
"NSPrivacyCollectedDataTypePurposeAppFunctionality"
]
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypePhoneNumber",
"NSPrivacyCollectedDataTypeLinked": true,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAppFunctionality"
]
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypePaymentInfo",
"NSPrivacyCollectedDataTypeLinked": true,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAppFunctionality"
]
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeCrashData",
"NSPrivacyCollectedDataTypeLinked": false,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAnalytics"
]
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypePreciseLocation",
"NSPrivacyCollectedDataTypeLinked": false,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAppFunctionality"
]
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeDeviceID",
"NSPrivacyCollectedDataTypeLinked": false,
"NSPrivacyCollectedDataTypeTracking": true,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeDeveloperAdvertising",
"NSPrivacyCollectedDataTypePurposeAnalytics",
"NSPrivacyCollectedDataTypePurposeAppFunctionality",
]
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeProductInteraction",
"NSPrivacyCollectedDataTypeLinked": false,
"NSPrivacyCollectedDataTypeTracking": true,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAppFunctionality",
"NSPrivacyCollectedDataTypePurposeAnalytics",
]
]
],
accessedApiTypes: [
[
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime",
"NSPrivacyAccessedAPITypeReasons": [
"35F9.1",
],
],
[
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
"NSPrivacyAccessedAPITypeReasons": [
"CA92.1",
],
],
[
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace",
"NSPrivacyAccessedAPITypeReasons": [
"E174.1",
],
],
[
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
"NSPrivacyAccessedAPITypeReasons": [
"3B52.1",
],
],
]
)
entitlements - наши entitlements, если они есть, то рекомендую так же перенести из старого приложения entitlements: Entitlements(stringLiteral: "demo/demo.entitlements"),
scripts - наши скрипты, в данном случае только Firebase скрипт, от документации ("${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run")
его отличает путь. Так же тут можно добавить скрипт для SwiftLint, главное задать в .swiftlint.yml проверку только наших исходников(demo/Sources)
Скрытый текст
let firebaseScript = """
if [ "${CONFIGURATION}" != "Debug" ]; then
"$SRCROOT/Tuist/.build/checkouts/firebase-ios-sdk/Crashlytics/run"
fi
"""
let scripts: [ProjectDescription.TargetScript] = [
.post(script: firebaseScript, name: "firebase", inputPaths: [
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}",
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}",
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist",
"$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist",
"$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)"
], basedOnDependencyAnalysis: false)
]
dependencies - вот мы и подошли к нашим зависимостям. Нужно сказать, что с ними не все так просто, иногда недостаточно просто скопировать названия из Package.swift, тогда файл проект сгенерируется с ошибкой, например:
AppsFlyerFramework, мы указываем в Package.swift как
.package(url: "https://github.com/AppsFlyerSDK/AppsFlyerFramework-Static", .upToNextMajor(from: "6.15.0")),
но указывая зависимость как AppsFlyerFramework-Static мы получим ошибку. При таких обстоятельствах я рекомендую создать новый проект и добавить эту зависимость через File -> Add Package Dependencies..., перейти в Package зависимости и найти поле name, в данном случае это name: "AppsFlyerLib-Static", что и нужно будет указать в нашем Project.swiftFirebase, он нам не нужен весь, тут нам необходимо указать только нужные части, в нашем случае это FirebaseCrashlytics, но об этом почему-то молчит Get started библиотеки(или я не нашел, но там сказано про флаг
-ObjC
к нему мы вернемся когда будем разбираться с полем settings)С Appmetrica так же как с FirebaseCrashlytics, необходимо указать только то, что нужно AppMetricaCore
Скрытый текст
dependencies: [
.external(name: "Kingfisher"),
.external(name: "FirebaseCrashlytics"),
.external(name: "GoogleMaps"),
.external(name: "GoogleMapsUtils"),
.external(name: "Moya"),
.external(name: "RxSwift"),
.external(name: "RxDataSources"),
.external(name: "ObjectMapper"),
.external(name: "SideMenu"),
.external(name: "FloatingPanel"),
.external(name: "SwiftMessages"),
.external(name: "AppMetricaCore"),
.external(name: "AppsFlyerLib-Static"),
.external(name: "SkeletonView"),
.external(name: "Swinject"),
.external(name: "SwinjectStoryboard"),
.external(name: "SnapKit"),
]
settings - настройки проекта
с configurations все ясно: debug и release
с base настройками все веселее:
Для запуска на устройстве, нужно указать CODE_SIGN_STYLE:
manualCodeSigning
,.automaticCodeSigning(devTeam: "КОМАНДА")
и так далее, мы предпочтем пока.codeSignIdentityAppleDevelopment
для Firebase, согласно инструкции, необходимо указать
.otherLinkerFlags(["-ObjC"])
так же для Firebase указываем
.debugInformationFormat(.dwarfWithDsym)
Чтобы приложение не упало при запуске, необходимо указать:
.marketingVersion("1.0.0")
+.currentProjectVersion("1")
4. Рекомендую указать
.otherSwiftFlags(["-D IS_PRODUCTION"])
, чтобы иметь возможность через #if проверять какой target используется, если он у вас не один
Осталось запустить tuist generate
,если все сделано верно, то откроется проект в Xcode:
Вот и все, вам останется только удалить ContentView.swift + DemoApp.swift и перенести весь свой код в Sources. Спасибо за внимание.
Скрытый текст
import ProjectDescription
let privacyManifest: ProjectDescription.PrivacyManifest = .privacyManifest(
tracking: true,
trackingDomains: [
"firebase-settings.crashlytics.com",
"report.appmetrica.yandex.net",
"usccgg-launches.appsflyersdk.com",
"firebaselogging-pa.googleapis.com"
],
collectedDataTypes: [
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeName",
"NSPrivacyCollectedDataTypeLinked": true,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeProductPersonalization",
],
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeEmailAddress",
"NSPrivacyCollectedDataTypeLinked": true,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeDeveloperAdvertising",
"NSPrivacyCollectedDataTypePurposeProductPersonalization",
"NSPrivacyCollectedDataTypePurposeAppFunctionality"
]
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypePhoneNumber",
"NSPrivacyCollectedDataTypeLinked": true,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAppFunctionality"
]
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypePaymentInfo",
"NSPrivacyCollectedDataTypeLinked": true,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAppFunctionality"
]
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeCrashData",
"NSPrivacyCollectedDataTypeLinked": false,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAnalytics"
]
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypePreciseLocation",
"NSPrivacyCollectedDataTypeLinked": false,
"NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAppFunctionality"
]
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeDeviceID",
"NSPrivacyCollectedDataTypeLinked": false,
"NSPrivacyCollectedDataTypeTracking": true,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeDeveloperAdvertising",
"NSPrivacyCollectedDataTypePurposeAnalytics",
"NSPrivacyCollectedDataTypePurposeAppFunctionality",
]
],
[
"NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeProductInteraction",
"NSPrivacyCollectedDataTypeLinked": false,
"NSPrivacyCollectedDataTypeTracking": true,
"NSPrivacyCollectedDataTypePurposes": [
"NSPrivacyCollectedDataTypePurposeAppFunctionality",
"NSPrivacyCollectedDataTypePurposeAnalytics",
]
]
],
accessedApiTypes: [
[
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime",
"NSPrivacyAccessedAPITypeReasons": [
"35F9.1",
],
],
[
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
"NSPrivacyAccessedAPITypeReasons": [
"CA92.1",
],
],
[
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace",
"NSPrivacyAccessedAPITypeReasons": [
"E174.1",
],
],
[
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
"NSPrivacyAccessedAPITypeReasons": [
"3B52.1",
],
],
]
)
let resources: ProjectDescription.ResourceFileElements =
.resources(
[
"demo/Resources/**",
"demo/**/*.storyboard",
"demo/**/*.xib"
],
privacyManifest: privacyManifest
)
let firebaseScript = """
if [ "${CONFIGURATION}" != "Debug" ]; then
"$SRCROOT/Tuist/.build/checkouts/firebase-ios-sdk/Crashlytics/run"
fi
"""
let scripts: [ProjectDescription.TargetScript] = [
.post(script: firebaseScript, name: "firebase", inputPaths: [
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}",
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}",
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist",
"$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist",
"$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)"
], basedOnDependencyAnalysis: false)
]
let project = Project(
name: "demo",
organizationName: "DEMO",
targets: [
.target(
name: "demo",
destinations: [.iPhone],
product: .app,
bundleId: "io.tuist.demo",
deploymentTargets: .iOS("15.0"),
infoPlist: .file(path: "demo/Info.plist"),
sources: ["demo/Sources/**"],
resources: resources,
entitlements: Entitlements(stringLiteral: "demo/demo.entitlements"),
scripts: scripts,
dependencies: [
.external(name: "Kingfisher"),
.external(name: "FirebaseCrashlytics"),
.external(name: "GoogleMaps"),
.external(name: "GoogleMapsUtils"),
.external(name: "Moya"),
.external(name: "RxSwift"),
.external(name: "RxDataSources"),
.external(name: "ObjectMapper"),
.external(name: "SideMenu"),
.external(name: "FloatingPanel"),
.external(name: "SwiftMessages"),
.external(name: "AppMetricaCore"),
.external(name: "AppsFlyerLib-Static"),
.external(name: "SkeletonView"),
.external(name: "Swinject"),
.external(name: "SwinjectStoryboard"),
.external(name: "SnapKit"),
],
settings: .settings(
base: SettingsDictionary()
.codeSignIdentityAppleDevelopment()
.otherLinkerFlags(["-ObjC"])
.debugInformationFormat(.dwarfWithDsym)
.marketingVersion("1.0.0")
.otherSwiftFlags(["-D IS_PRODUCTION"])
.currentProjectVersion("1"),
configurations: [
.debug(name: .debug),
.release(name: .release)
]
)
),
]
)
Bardakan
так в итоге в чем преимущество Tuist?