От Cocoapods к Tuist+SPM быстрыми шагами

в 6:15, , рубрики: iOS, swift, swift разработка, Tuis, xcode

Не так давно прогремела новость, что 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, все достаточно просто:

  1. Ставим Mise curl https://mise.run | sh

  2. Ставим 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")),
в итоге получим наши зависимости

От Cocoapods к Tuist+SPM быстрыми шагами - 1
В виде кода
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
    )
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)

scripts: scripts,
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.swift

  • Firebase, он нам не нужен весь, тут нам необходимо указать только нужные части, в нашем случае это FirebaseCrashlytics, но об этом почему-то молчит Get started библиотеки(или я не нашел, но там сказано про флаг -ObjC к нему мы вернемся когда будем разбираться с полем settings)

  • С Appmetrica так же как с FirebaseCrashlytics, необходимо указать только то, что нужно AppMetricaCore

dependencies
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:

От Cocoapods к Tuist+SPM быстрыми шагами - 2

Вот и все, вам останется только удалить ContentView.swift + DemoApp.swift и перенести весь сой код в Sources. Спасибо за внимание.

Project.swift
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)
                ]
            )
        ),
    ]
)

Автор: Sergio_ml

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js