How to use Swift Package Manager products from Cocoapods

Oswaldo Rubio
New Work Development
6 min readMay 19, 2023

--

Roughly one year ago, New Work SE’s iOS Platform Team started a proof of concept about how we could move from our existing XING iOS App, based in over one hundred Cocoapods internal libraries and other 15 external dependencies, into a clean and modern project based only in SwiftPM packages.

If you’re interested, I explained how we simplified the previous graph dependency in a previous article here in order to tackle this project in an easier way.

Now, I want to share with you, how the process of migration to SwiftPM can be easier than expected if you are an iOS developer who works in a large iOS project with:

  • a Cocoapods setup with large number of Development Pods
  • a local Swift Package where you want to move these Pods as a targets
Photo by Michał Parzuchowski on Unsplash

Which was the problem we wanted to solve?

We started defining our migration plan, which was created by using our jungle tool in a Xcode Playground, to explore the dependency graph of the project and build the required steps for that migration in a way that only modules that were dependant of already migrated modules can be also migrated. Easy.

But then, only a few days after we started the migration process, we arrived to this kind of situation you see in the image below where only one or a reduced number of modules were used in our still not migrated Pods.

Can we use our already migrated modules in the SPM Package in our still active Pods?

We asked ourselves, “Is there a way to start consuming these new migrated modules into our legacy Cocoapods Modules setup without having duplicated modules in both package managers?”

Also, we wanted to start having results as soon as possible in the final iOS App. Integrating some of these migrated libraries in the final binary would allow us to start monitoring the behaviour in our APM solution and enjoying the benefits without waiting for the complete migration and (maybe) having a hard switch to SPM process.

Our first approach was to use this pod_install hook that configures a Swift Package Manager dependency into an existing Xcode Project (the ones that Cocoapods creates for us). But then, this error was happening when we built the app:

How we solved this problem?

We didn’t want to move to SwiftPM and create a new dependency with the Xcodeproj library that is used by Cocoapods. So, why not using the new Xcodeproj (lowercased P) Swift version by Tuist to configure our Pod Xcode Project and have an easier way to properly configure these Xcode Projects.

We defined 2 differents commands for local and remote Swift packages:

import XcodeProj
import PathKit
import ArgumentParser

struct AddLocalPackageCommand: ParsableCommand {
static var configuration = CommandConfiguration(
commandName: "addLocal",
abstract: "Injects a local SPM Package"
)

@Option(help: "The Pod project directory.")
var projectPath: String

@Option(help: "The SwiftPM package directory.")
var spmPath: String

@Option(help: "The product from that package to be injected.")
var product: String

@Option(help: "The target to be configured with that dependency")
var targetName: String

func run() throws {
let projectPath = Path(projectPath)
let spmPath = Path(spmPath)
let xcodeproject = try XcodeProj(path: projectPath)
let pbxproj = xcodeproject.pbxproj
let project = pbxproj.projects.first
_ = try project?.addLocalSwiftPackage(path: spmPath, productName: product, targetName: targetName)
try xcodeproject.write(path: projectPath)}
}

struct AddRemotePackageCommand: ParsableCommand {
static var configuration = CommandConfiguration(
commandName: "addRemote",
abstract: "Injects a remote SPM Package"
)

@Option(help: "The Pod project directory.")
var projectPath: String

@Option(help: "The SwiftPM package URL.")
var spmURL: String

@Option(help: "The product from that package to be injected.")
var product: String

@Option(help: "The exact version to be used.")
var version: String

@Option(help: "The target to be configured with that dependency")
var targetName: String

func run() throws {
let projectPath = Path(projectPath)
let xcodeproject = try XcodeProj(path: projectPath)
let pbxproj = xcodeproject.pbxproj
let project = pbxproj.projects.first
_ = try project?.addSwiftPackage(repositoryURL: spmURL, productName: product, versionRequirement: .exact(version), targetName: targetName)
try xcodeproject.write(path: projectPath)}
}

This way, we could include this Swift CLI tool (we called XcodeSPMI by obvious reasons) in our Package and use it after the Pod installation.

import PackageDescription

let package = Package(
name: "libraries",
products: [
.library(name: "FeatureB", type: .dynamic, targets: ["FeatureB"]),
.executable(name: "XcodeSPMI", targets: ["XcodeSPMI"]),
],
dependencies: [
.package(url: "https://github.com/tuist/XcodeProj.git", .upToNextMajor(from: "8.9.0")),
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.2"),
],
targets: [
.target(name: "FeatureB", path: "FeatureB"),
.executableTarget(
name: "XcodeSPMI",
dependencies: [
.product(name: "XcodeProj", package: "XcodeProj"),
.product(name: "ArgumentParser", package: "swift-argument-parser")
])

]
)

Once we removed the dependency of FeatureB in the FeatureA’s .podspec file, this is how we inject (as a post_integrate step in the Podfile) the SPM dependency in the Cocoapods project using the previous .executable product from our Swift Package:

post_integrate do |installer|

# FeatureB
featureB_dependant = ["FeatureA"]

puts "Injecting FeatureB SPM framework into ..."
featureB_dependant.each do |project|
puts " #{project}"
`swift run --package-path libraries XcodeSPMI addLocal --project-path Pods/#{project}.xcodeproj --spm-path ../libraries/ --product FeatureB --target-name #{project}`
end
end

Is this enough? It’s for .binaryTargets but not for regular .targets as the one you can see in the example (FeatureB).

For .binaryTargets, which is the current solution we’re using for these shared modules, we are creating the .xcframework artifacts by using swift-create-xcframework.

In order to been able to remove the No such module ‘FeatureB’ error from your build log for plain .targets, there is an extra step needed during the module step. Looking at the logs we found something was not completely provided to the swift-frontend tool. The missing part was this variable you can find in the Build Setting documentation from Apple (SWIFT_INCLUDE_PATHS) which should also contain the directory where other modules can be found during that building stage.

Import Paths

Setting name: SWIFT_INCLUDE_PATHS

A list of paths to be searched by the Swift compiler for additional Swift modules.

As we can change the build settings for our development pods, that’s what we need to include in our .podspec file:

Pod::Spec.new do |s|
s.name = 'FeatureA'
s.version = '1.0.0'
s.author = 'Oswaldo Rubio'
s.license = 'commercial'
s.homepage = "https://github.com/osrufung/UsingSPMFromCPDemo"
s.source = { git: 'https://github.com/osrufung/UsingSPMFromCPDemo' }
s.summary = "#{s.name} – Version #{s.version}"
s.ios.deployment_target = '15.0'
s.swift_version = '5.7'
s.source_files = "Sources/**/*.swift"
# This resolves the missing SWIFT_INCLUDE_PATHS variable
s.pod_target_xcconfig = { 'SWIFT_INCLUDE_PATHS' => '$(inherited) ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)'}
end

Conclusion

Having this SPM injection into Cocoapods projects allow us to increase the number of Integrated modules (modules that are being linked with the final app) before finishing migrate the pending modules.

We’re having multiple benefits of this “hack”:

  • being able to remove from Cocoapods modules that are already migrated in SPM.
  • also injecting the dependency as a .binaryTarget in .xcframework format reduces the total build time spent locally and time resources and credits in the CI side.
  • Foundational modules shared in both package managers without any kind of library duplication.

We expect to finish this migration process during this year. This is the current migration state right now and I hope to share my thoughts about the whole migration project once we finished with you.

I mentioned before we had over one hundred internal modules in our Podfile, and that’s how the migration process looks today (mid of May 2023). Some of the modules have been deleted (because some features were removed), and other ones have been migrated but still integration in the app is still not possible because some of the modules that are dependant on them are still in the pending to be migrated list.

Visual representation with percentage of migrated, integrated and pending to migrate modules
current SPM migration status in our XING iOS project

If you want to play yourself, the issue can be reproduced and is already solved in this demo project along with a really tiny SwiftPM injector .executableTarget based on XcodeProj. I hope this can be useful for your projects and please share your thoughts or problems in the comments or in the github repository.

--

--