Moving a large project from Cocoapods to Swift Package Manager

August 31, 2022

Packages

Video version

A video version of this post from Cocoaheads Berlin September 2022 is available on Youtube, here.

Key points

I've moved a large project (ca. 300K SLOC, 16 external dependencies) to Swift Package Manager (SPM, SwiftPM) and would like to share my learnings with others who may want to do the same.

I want to start by sharing some key points with you, and then go further in-depth by the end of the article.

So, what happened when I moved a large project from Cocoapods to Swift Package Manager?

What stayed the same?

  • Compilation time: I haven't noticed any measurable difference in compilation time between Cocoapods and SPM
  • App size stays the same, but SPM might be optimizing it in the future
  • Startup time: a bit faster, since it's using the static frameworks

What I find good

  • Swift as a first-class citizen: packages are defined in Swift (Package.swift manifest)
  • Local packages work really well. It is possible to add and remove files on the fly, Xcode can figure out the updates. As well as that, Xcode understands the changes that are happening and recalculates the dependencies if required.
  • No need for xcworkspace anymore (unless you have multiple projects)
  • Integration in Xcode: to change or add the dependency, select 'project'. In the project on the right, select the project -> 'Package Dependencies'
SPM Xcode Integration
  • It is possible to use an automatically created Bundle.module reference when looking for the bundle resources inside of a module

What did I have to come to terms with?

  • Frequent issues Missing package product for ...
  • Package resolution takes a lot of time
  • It is necessary to import Foundation and UIKit when they are used in your source files

What could be improved?

  • SPM is doing full checkouts of dependencies repositories. This is necessary, since SPM needs to know the version history of the repository. This must not be an issue in the future, once the SE-0292 Swift Package Registry would be implemented.
  • Dependencies are defined inside of the project.pbxproj
  • A lot of warnings from the frameworks that are not possible to suppress

Was it worth it?

Personally, I think it was worth it. SPM allows you to create a better modular project structure. Adding new packages is a fast process, and the package format is intuitive for engineers.

SPM cheat sheet

I have summarized the practical points that I have learned in a short cheat sheet.

General information

Dependencies are defined in the project or workspace file -- this is your old Podfile. Pinned versions are saved in Project.xcodeproj|Workspace.xcworkspace/xcshareddata/swiftpm/Package.resolved. This is your Podfile.lock

Xcode error: Missing build product for xxx

Try closing Xcode, then executing in the project folder in the Terminal: xcodebuild -resolvePackageDependencies -project <Project>.xcodeproj -scheme <Scheme>, or xcodebuild -resolvePackageDependencies -workspace <Workspace>.xcworkspace -scheme <Scheme> if you are using a workspace.

Xcode: Server SSH Fingerprint Failed to Verify

Double-click the error message in Xcode and confirm the GitHub's fingerprint.

SPM is failing to fetch private packages

  • Sign in to GitHub in Xcode -> Preferences -> Accounts
  • Check if you have a single and correct SSH key installed on your system
  • Check if your SSH token is authorized with the SSO on GitHub (if your company is using SSO)
  • Check if Xcode is using the default SSH configuration (You can execute defaults write com.apple.dt.Xcode IDEPackageSupportUseBuiltinSCM YES to change this)
  • Ensure ssh agent trust GitHub host (execute ssh-keyscan github.com >> ~/.ssh/known_hosts)

General Troubleshooting

  • Update Xcode to the latest release version
  • Try File -> Packages -> Reset Package Cache
  • You can try wiping the caches directory with rm -rf ~/Library/Caches/org.swift.swiftpm/

An in-depth view

Once the basics are out of the way, we can take a more in-depth look at the SPM and migration.

Why was SPM introduced?

SPM was introduced to fill the gap in the Swift ecosystem. Swift is intended as a general-purpose programming language, and the fact that there was no package manager on Linux meant that there was a need for SPM to be introduced.

On Mac, the most prominent SPM predecessors are Cocoapods and Carthage. I recall working on iOS projects before Cocoapods, and it was a total nightmare. However, Cocoapods are great, as they modify the project file and adjust build settings. Essentially, Cocoapods are pulling the dependency sources, and configuring a separate Xcode project to build those sources in a way that a product of this build can be used in the main project. Following that, those two projects are connected with a Workspace, so Xcode can understand where the dependency library should be coming from.

Carthage works in a very different way, building dependencies to frameworks so that those binary frameworks can be used by Xcode in the linking process.

SPM is integrated in a much better way with Xcode and other build tools (as you might expect since it's an Apple product). It is pulling the dependency sources and creating an invisible project, similar to what Cocoapods would do.

How to approach migration to SPM

I went all-in with the migration. Our project involved using Cocoapods to pull in the dependencies, but we also used local development pods to define logical modules of the applications. Some local pods were also dependent on the third-party libraries fetched by Cocoapods. This made step-by-step migration infeasible.

Step 1: Review current dependencies

In order to do this, I followed the following steps:

  • List all external and internal dependencies
  • Find out which versions of those dependencies are providing SPM support.
    • If a library version already used supports SPM, all is well.
    • If a library needs to be updated, the release notes of the versions we need to go over have to be checked. This can be done as a separate task, i.e. you can update to the required version with your existing package manager and allow it to be tested for compatibility with your app.
    • If a library does not support SPM at all, fork it and introduce SPM to it. Then, when you are confident it is working, create a pull request from your fork to the upstream repo. Let other engineers enjoy the SPM support you’ve developed!

By the end of this process, it is important to have a list of dependencies and versions you would like to use.

Step 2: Remove Cocoapods (this can also be done later)

Cocoapods are usually deeply integrated in the project through the xcconfigs and build steps. If you would like to have a clean slate, it is necessary to remove cocoapods before you start integrating SPM packages. This can be easily achieved via pod deintegrate.

sudo gem install cocoapods-deintegrate cocoapods-clean
pod deintegrate
pod cache clean --all

You can also remove Podfile, Podfile.resolved and the workspace if you are not using them for anything other than Cocoapods.

If you would like to go step by step, you can always remove one dependency and add it via SPM.

Step 3: In Xcode, add the dependencies from step 1

  • In Xcode, select File -> Add Packages... menu item
  • Use the top right search field to enter the git repo path for your dependency
  • Let Xcode find the repository
  • Select dependency rule. I recommend using exact versions to make sure your build is reproducible
  • Click on "Add Package" and let Xcode do its magic

Step 4: Migrating the local and in-house packages (if any)

Moving from Cocoapods podspec manifest to an SPM Package.swift is a breeze. I I find it quite self-explanatory. Take a look at the migration example I've taken from an internal package:

Cocoapods podspec
Pod::Spec.new do |s|
  s.name         = "MyChatModule"
  s.version      = "1.0.0"
  s.summary      = "Module for MyChatModule"
  s.description  = ""
  s.homepage     = "https://www.deliveryhero.com"
  s.author       = { "Package Author" => "someone@deliveryhero.com" }
  s.platform     = :ios, "11"
  s.source       = { :git => "git@github.com:deliveryhero/MyChatModule.git", :tag => "#{s.version}" }
  s.swift_version = '5.0'

  s.static_framework = true

  s.dependency 'DependencyInjection', '1.0.0'
  s.dependency 'Networking', '1.0.0'
  s.dependency 'ExternalSDK', '5.2.1'

  s.source_files = "Sources/**/*.{swift}"
  s.resources = "Resources/**/*.{storyboard,xib,xcassets,png,pdf,ttf}"

  s.test_spec 'UnitTests' do |test_spec|
    test_spec.source_files = 'Tests/**/*.{swift}'
  end
end
SPM package manifest
// swift-tools-version:5.3
import PackageDescription

let package = Package(
    name: "MyChatModule",
    platforms: [
        .iOS(.v11)
    ],
    products: [
        .library(
            name: "MyChatModule",
            targets: ["MyChatModule"])
    ],
    dependencies: [
        .package(path: "../DependencyInjection"),
        .package(path: "../Networking"),
        .package(url: "https://github.com/ExternalSDKCompany/ExternalSDK-iOS.git", from: "5.2.1")
    ],
    targets: [
        .target(
            name: "MyChatModule",
            dependencies: ["Networking",
                           "DependencyInjection",
                           .product(name: "ExternalSDK", package: "ExternalSDK-ios")],
            path: "Sources",
            resources: [.process("Resources/Assets.xcassets"),
                        .process("Resources/Illustrations.xcassets"),
                        .process("Resources/Icons.xcassets"),
                        .copy("Resources/Fonts/font.ttf")]
        ),
        .testTarget(
            name: "MyChatModule-UnitTests",
            dependencies: ["MyChatModule"],
            path: "Tests"),
    ]
)

Wishlist

I am going to take you through what else I would like to see in the SPM.

Shallow clones of the dependencies

Truly low-hanging fruit that would speed up the CI. This is currently not possible, because SPM needs to know the version history of a package. This must not be an issue in the future, once the SE-0292 Swift Package Registry would be implemented.

Build artifacts caching

Wouldn't it be great to not have to rebuild changes every time on your computer or the CI?

There are multiple ways to achieve this. The easiest way would be to add an option for SPM to store the artifacts in a certain folder on the file system and try to read them from there. Certainly, it is much more complex than that, but at least for a given dependency version and a given Xcode version, it should be entirely possible to cache all the build products.

In hermetic build systems like Bazel, it is possible to collect all the arguments the build step needs and avoid other side effects. Using this feature, it is possible to cache the build artifacts for the given input and reuse them in case the inputs (source files, configurations, libraries) were not changed.

I believe work on this topic is already happening inside of the SPM team, so we would be able to benefit from it in the future.

The solution we ended up with

In my team, we ended up using a local copy of foreign dependencies xcframeworks gathered under a single Package.swift manifest. Carthage can create such a copy. This allows us to speed up build times and remove the warnings displayed from them.

Nota Bene: SPM Slack

If you have ideas about SPM or would like to participate in a discussion on the topic, you can register for SPM Unofficial Slack. Some of the best Apple engineers are also present there.