Migrating to Swift Package Manager

Victor Sarda
Just Eat Takeaway-tech
9 min readJan 25, 2023

--

Photo by Susan Holt Simpson on Unsplash

Overview

Until recently, our modularised iOS codebase heavily relied on CocoaPods. Every module was a single pod with its podspecs and the main app integrated modules as development pods.

This year we decided it was time to give SPM a try for a few reasons:

  • It’s the new standard, built by Apple.
  • It’s mature enough. The first versions weren’t as good as CocoaPods. Now it can compete with it. Most importantly, it now supports resources.
  • The third-party dependencies we use vastly adopt it. Only a couple of years ago, third-party dependencies still only had CocoaPods & Carthage support. Now, it’s very common to see SPM support.

The challenge

Even though we estimated the migration was doable, we knew it wouldn’t be possible to complete in a few days. Here at Just Eat Takeaway.com, we have different teams working on different modules from Checkout, and Restaurant to Orders, so we needed to find a way to migrate to SPM without blocking other groups.

So our main challenge was:

How can we support both CocoaPods & SPM until we complete the migration?

First things first: the structure

The first thing we needed to do was to find a structure for our modules that fits both CocoaPods & SPM. It was a challenging task, but it was a necessary step before we could continue the migration. We looked at the current structure in our modules and realised it was inconsistent but, most importantly, CocoaPods-oriented. Most of our modules had the following configuration:

MyModule/
├─ MyModule/
│ ├─ Assets/
│ ├─ Classes/
│ ├─ UnitTests/
├─ Example/
│ ├─ DemoApp/
│ ├─ UITests/
├─ MyModule.podspec

A module has sources & resources along with tests and an example app. The example app is used as a host app to run UI tests. The unit tests can (and should) run without a host app as they test the module’s code itself, but we used to reference them in the example app Podfile, like so:

target 'MyModule_Example' do
# Development pod
pod 'MyModule', :path => '../', :testspecs => ['UnitTests']

# Dependencies
# ...

end

And a module’s podspec would typically look like this:

Pod::Spec.new do |s|
s.name = 'MyModule'
s.version = '1.0.0'
s.source_files = 'MyModule/Classes/**/*'
s.resources = 'MyModule/Assets/**/*'

# Test specs
s.test_spec 'UnitTests' do |test_specs|
test_specs.source_files = 'MyModules/Tests/**/*'
end

# Dependencies
# ...

end

This worked fine, but the recommended structure for a Package is as follows:

MyPackage/
Sources/
├─ MyPackageTarget/
Tests/
├─ MyPackageTargetTests/

Knowing we would eventually remove CocoaPods from our codebase, we wanted to get as close as possible to the recommended SPM structure. After hours of debating & testing different formats, we came up with the following one:

MyModule/
├─ Demo/
│ ├─ DemoApp/
│ │ ├─ Sources/
│ │ ├─ Resources/
│ ├─ UITests/
├─ Framework/
│ ├─ Sources/
│ │ ├─ Resources/
├─ Tests/
│ ├─ Sources/
│ │ ├─ Resources/
Package.swift
MyModule.podspec

The most significant changes are:

  • The “Framework folder. It replaces the previous “MyModule folder. It’ll only contain the module’s source code & tests.
  • The “Example” folder will be renamed to “Demo”. It’ll include the code for the demo app & UI tests.
  • We’ll have our simplified podspec file and the new “Package.swift” one in the root folder.

While thinking about this new design, we always had convention over configuration in mind. By enforcing standard practices, we can easily add new validations on CI to ensure our new common structure is correct in every module. Most importantly, we could apply the same podspec format everywhere.

Creating Packages for each module

After updating our modules to use our new structure, we could finally move on to the fun part: creating Packages!

We decided to try it with one of our smallest modules: DateFormatting. It’s a shared module we use to ensure date formatting is applied the same way everywhere in our codebase. It’s also fully unit-tested.

Here is what the “Package.swift” manifest looks like:

let package = Package(
name: "DateFormatting",
defaultLocalization: "en",
platforms: [.iOS(.v14)],
products: [
.library(
name: "DateFormatting",
targets: ["DateFormatting"]
)
],
dependencies: [],
targets: [
.target(
name: "DateFormatting",
dependencies: [],
path: "Framework/Sources"
),
.testTarget(
name: "UnitTests",
dependencies: [
.byName(name: "DateFormatting"),
],
path: "Tests/Sources"
),
]
)

Pretty simple and has no external dependency. The code built & our tests passed.

Please note that if you have unit tests using WKWebView , those won’t work within a Package as they need a host app. In our case, we had to move those inside our main app tests.

The DateFormatting package

Now what? Should we manually add “Package.swift” files to every other module? Not scalable when you think about it. We felt we could do better, and we did.

Let me introduce you to our homemade CLI tool: PackageGenerator.

The great thing with SPM is that you can create executable Packages. The easiest way to start is to run the following command in your Terminal:

swift package init --type executable

This will create a new package that you can export as an executable. If you want to know more about creating a command-line tool using SPM, I highly recommend reading this article: https://www.swiftbysundell.com/articles/building-a-command-line-tool-using-the-swift-package-manager/.

When we wrote our PackageGenerator tool, we decided the easiest way to generate Package files was to:

  • Loop through folders from a given path (e.g. “MyApp/Modules/”) and look for a JSON specs file similar to podspecs, named after the module.
  • If a specs file exists, parse specs and generate a “Package.swift” file.
  • Lock the generated manifests to make sure developers can’t manually edit them.

Let’s take DateFormatting as an example again. Here’s what the specs file looks like:

{
"name": "DateFormatting",
"localDependencies": [ ],
"remoteDependencies": [ ],
"targets": [
{
"name": "DateFormatting",
"dependencies": [ ],
"path": "Framework/Sources",
"hasResources": false
}
],
"testTargets": [
{
"name": "UnitTests",
"dependencies": [
{
"name": "DateFormatting",
"isTarget": true
}
],
"path": "Tests/Sources",
"hasResources": false
}
]
}

It’s pretty straightforward. We specify the module name, its targets & test targets, the source path for each target, and a hasResources flag.

localDependencies is where we reference other local modules. For instance, our Checkout module uses DateFormatting, and its localDependencies look like this:

"localDependencies": [
{
"name": "DateFormatting"
},
{
"name": "..."
}
]

After parsing specs from a module, PackageGenerator creates its Package file from a Stencil template we created. If you want to know more about Stencil templates, take a look at their documentation: https://stencil.fuller.li/en/latest/.

Dealing with third-party dependencies

Some of our modules use third-party dependencies. We declared them in podspecs files. Now, we also need to use SPM to reference dependencies in packages. One thing we wanted to ensure was that the versions were pinned. With CocoaPods, we used a PodVersions.rb file in the root folder of our project like so:

MyDependencyVersion = '1.0.0'

Then, we would import that file in our podspecs & Podfiles and do the following:

pod 'MyDependency', MyDependencyVersion

It was easy for us to pin versions & make sure every module uses the same ones. So, how do we keep the same behaviour with SPM?

Say hello to “RemoteDependencies.json” 👋

We created this new file and decided it would be the only source of truth for our external dependencies when the migration is over. It contains a simple array of dependencies:

{
"dependencies": [
{
"name": "DependencyA",
"url": "https://github.com/DependencyA",
"version": "1.0.0"
},
{
"name": "DependencyB",
"url": "https://github.com/DependencyB",
"version": "2.0.0"
},
]
}

Easy right? Now we can give the path to this file to our PackageGenerator and reference dependencies in our modules’ specs.
Say we need DependencyA in DateFormatting. Here is how it can be declared in a module specs file:

{
"name": "DateFormatting",
"localDependencies": [ ],
"remoteDependencies": [
{
"name": "DependencyA",
"url": "DEPENDENCYA_URL",
"version": "DEPENDENCYA_VERSION"
}
],
"targets": [
{
"name": "DateFormatting",
"dependencies": [
{
"name": "DependencyA"
},
],
"path": "Framework/Sources",
"hasResources": false
}
],
"testTargets": [...]
}

Note that we don’t manually add the version & the URL for it. PackageGenerator is smart enough to replace the DEPENDENCYA_URL & DEPENDENCYA_VERSION placeholders with the correct version & URL from our “RemoteDependencies.json” file.

If we want to update DependencyA to the version 2.0.0 we only need to update it in “RemoteDependencies.json” and rerun our generate-package command.

Migrating demo apps

After generating “Package.swift” files for all our modules, it was finally time to stop using CocoaPods…in our demo apps!

At Just Eat Takeaway.com, we have a demo app for almost every module. Teams can test their changes on those apps before implementing them in the main app. We also use them as host apps for UI tests.

The demo app for our Account module

Our goal was to start using modules as Packages, as we would eventually do in the Just Eat app. It was a great starting point since the main app still used CocoaPods. This means we could start experimenting with the integration in demo apps in a safe manner.

Since we use Tuist to generate workspace & project files for our apps, we only needed to update our configuration to use SPM.

Using the new packages in our demo apps allowed us to test them individually and fix any issues. The main one we encountered was accessing resources because CocoaPods & SPM generate different bundles. SPM creates a module bundle, while CocoaPods generates a custom one with a specific bundle identifier.

Because we still needed to use CocoaPods in the main app, we came up with this workaround until we finished the migration:

public extension Bundle {
class var dateFormatting: Bundle {
#if SWIFT_PACKAGE // this pre-processor macro is automatically created by SPM
Bundle.module
#else
Bundle(identifier: "org.cocoapods.DateFormatting")!
#endif
}
}

In the example above with DateFormatting, the class var will return Bundle.module when the module is being used as a package. Otherwise, it’ll return the custom bundle created by Cocoapods. Note that SPM automatically adds the SWIFT_PACKAGE pre-processor macro at compile time.

We made sure to use Bundle.<module-name> in every place where the code is accessing resources from a module like so:

UIImage(named: "my-image", in: Bundle.dateFormatting, compatibleWith: nil)

We also had to ensure “Inherit Module From Target” was unticked for views and storyboards accessing resources, with the correct module selected:

Finally, the main app

Once we migrated all our demo apps to use SPM, we were ready for the last part: the main app. We could proceed with the very satisfying removal of all the CocoaPods references in the remaining parts of the code.

Like demo apps, the most important change was in our Tuist configuration code to use modules and third-party dependencies with SPM.

Unfortunately, some of them were yet to add support for SPM, so we manually linked them as frameworks or xcframeworks in the project. This is temporary until those remaining libraries add SPM support (looking at you, GoogleMaps 👀).

Overall, migrating the app was relatively easy since every module was ready to be linked as a package. We managed to finish the whole migration in about a week. Our next and final step was to update our CI and Fastlane configurations to use SPM over CocoaPods.

Conclusion

With a modularised iOS codebase, there are different ways to handle local & external dependencies. Until recently, CocoaPods was our obvious choice, thanks to its maturity. However, SPM eventually caught up over the years.

We now use Apple’s native technology to manage external dependencies and local modules. It integrates seamlessly with Xcode, is open source and is written in Swift.

Upgrading Xcode to a new major version requires less work than before. When doing so, we no longer need to worry about our CocoaPods configuration.

Finally, the feedback from the rest of the team has been very positive. Setting up the project is now easier than ever, so new joiners can quickly get on board.

Just Eat Takeaway.com is hiring! Want to come work with us? Apply today.

--

--