Using Swift Packages in CocoaPods-Based Projects
Introduction
Swift Package Manager (SwiftPM, or SPM) is a tool for managing the distribution of Swift code. Initially launched in 2015, it primarily catered to server-side or command-like projects. Its adoption has been limited over the years due to lack of support for the mainstream iOS/MacOS development. During this time, CocoaPods has gained its power and emerged as a dominant tool in iOS development.
Not until recently has SPM been adopted widely. While long-lived open-source projects (ex. RxSwift, SnapKit, Moya, etc) provide the support for both CocoaPods and SPM, some recently launched projects decided to stick to the SPM support only. A typical case is Point-Free’s OSS projects such as The Composable Architecture, SnapshotTesting, and so forth, embracing SPM exclusively.
In mid 2023, Apple introduced Swift Macros in the WWDC 2023 (Swift 5.9 release). This initiative was a leap in enhancing the language features and capabilities. However, working with Swift macros requires access to apple/swift-syntax, which is not available for CocoaPods (the same happens to most of Apple’s open-source projects). This again prompts the consideration of adopting SPM for projects that seek to leverage these advanced Swift features.
This blog delves into integrating SPM into CocoaPods-based projects, addressing some challenges and presenting a solution for a seamless transition.
Using SPM in CocoaPods-based Projects
The Needs for a Hybrid Approach
While SPM has gained popularity, CocoaPods remains prevalent in iOS development. Many projects, especially those with a long history, rely heavily on CocoaPods. Transitioning entirely to SPM poses risks, especially when not all dependencies are SPM-compatible, such as vendor libraries or frameworks. Another challenge is when the project spans multiple teams. Coordinating such a migration can be difficult due to varying work priorities and timelines. Therefore, a hybrid approach is crucial to ensure a smooth transition without disrupting the existing project structure.
Current Status
As of Dec 2023, CocoaPods still lacks native support for SPM. While there are pending pull requests addressing this issue, the acceptance is anticipated in the next major release of Xcode (see: here). The core members just echoed the general consensus in this comment.
The workaround to add SPM packages to the project is modifying Pods.xcodeproj
in the post_install
hook, mentioned in this comment, for example. However, there are still more to cover, for instance, how to link binaries or frameworks properly.
Writing a CocoaPods Plugin
Now let’s create a CocoaPods plugin that eases the burden of managing Swift packages in the project.
High-level sketch
In a hybrid model, a Swift package behaves similarly to a pod. It can be declared in both Podfile and podspecs.
-
In Podfile, the package is declared under the context of a specific target and will be accessible to that target.
spm_pkg "Package", :url => "path/to/package", :version => "0.0.1" target "App" do spm_pkg "PackageA", :git => "path/to/package", :tag => "0.0.1" end target "Test" do spm_pkg "TestPackageX", :git => "path/to/package", :branch => "main" end
-
In the podspec of a pod, the package is declared as one of the pod’s dependencies.
Pod::Spec.new do |s| s.name = "Foo" ... s.dependency "AnotherPod" s.spm_dependency "PackageA" # <-- HERE end
A Swift package may have multiple products some of which a podspec depends on. This can be done with the following format: spm_dependency "PackageName/ProductName"
. The idea is conceptually similar to dependencies declaration when having subspecs.
Pod::Spec.new do |s|
...
s.spm_dependency "PackageA/ProductA1"
s.spm_dependency "PackageA/ProductA2"
end
Integrating Swift packages to the Pods project
Integrating a Swift package involves creating references and adding them to the Pods project.
pkgs.each |pkg|
pods_project.root_object.package_references << create_pkg_ref(pkg)
end
For each pod depending on a package, the package products are added to the pod’s Target Dependencies under the “Build Phases” tab.
pods_project.targets.each do |target|
products_for(target).each do |product|
target.dependencies << create_target_dependency_ref(product)
end
end
Linking libraries and frameworks
While some recommendations, such as those found in this comment, suggest adding the package products to the “Link Binary With Libraries” section of a pod target, this approach may not align with specific use cases. Moreover, this linking approach deviates from how CocoaPods handles the linking setup.
Rather, a more fitting strategy is linking frameworks or libraries based on the linker flags in the target’s build settings.
Static vs. Dynamic
A Swift package library can be either static or dynamic, depending on the library type declared in the package product. If unspecified, it defaults to static.
let package = Package(
...
products: [
.library(name: "Foo", targets: ["Foo"]), // <-- static
.library(name: "Bar", type: .dynamic, targets: ["Bar"]) // <-- dynamic
]
)
When compiling such a package:
- A dynamic library is compiled and packaged into a framework under the
PackageFrameworks
dir (residing in the per-configuration build dir). - A static library is compiled into a
.o
binary along with its.swiftmodule
in the per-configuration build dir.
The following image illustrates the products dir structure when compiling a package containing a static library Foo
and a dynamic library Bar
.
As we can see, the Swift modules of Foo
and Bar
are located in the per-configuration build dir. Despite the existence of Bar.framework
, its Swift module is not packaged into that bundle. Thus, we need to add ${PODS_CONFIGURATION_BUILD_DIR}
to SWIFT_INCLUDE_PATHS
so that Xcode can search for additional Swift modules.
To link static libraries (ex. Foo
) with a target:
- Use linker flags:
-l"Foo.o"
- Add
${PODS_CONFIGURATION_BUILD_DIR}
to the library search paths (LIBRARY_SEARCH_PATHS
)
To link dynamic libraries (ex. Bar
) with a target:
- Use linker flags:
-framework "Bar.framework"
- Add
${PODS_CONFIGURATION_BUILD_DIR}/PackageFrameworks
to the framework search paths (FRAMEWORK_SEARCH_PATHS
). Each framework under this dir must be embedded to the app bundle’s Frameworks. This can be accomplished by appendinginstall_framework "${PODS_CONFIGURATION_BUILD_DIR}/PackageFrameworks/Bar.framework"
to the embed frameworks script of an aggregate target.
The following build settings reflect the outlined linking strategy:
SWIFT_INCLUDE_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/PackageFrameworks"
LIBRARY_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"
OTHER_LDFLAGS = $(inherited) -l"Foo.o" -framework "Bar.framework"
Integrating Swift macros as prebuilt binaries
As highlighted in the introduction, Swift macros is an important driving force for SPM adoption. However, a notable challenge preventing Swift macros from being adopted, especially in large-scale projects, is their overhead build time. Specifically, a macro package depends on swift-syntax which alone takes up 10-15s in the compilation process. This means the compilation time for the macro package could extend to 20s or more. This becomes even more problematic when such a macro is used by many targets, leading to delays in compiling those dependent targets.
Fortunately, with the proposal SE-0394 being implemented (in Swift 5.9), we can integrate Swift macros in the form of prebuilt binaries. Refer to this blog post for more details about this approach. Basically, we just need the prebuilt binary of the macro implementation, along with the source files of the macro interfaces. This gives way to the idea of automating the process of prebuilding a macro and integrating it to the project. There is no additional effort required from the package author.
To illustrate, let’s consider the use case of integrating the [Orcam](https://github.com/trinhngocthuyen/orcam)
macro into a project. The following steps outline the process:
- Step (1): Prepare a dedicated pod dir:
.spm.pods/Orcam
- Step (2): Download the source of that package into:
.spm.pods/.download/Orcam
- Step (3): Prebuild the macro implementation from the downloaded source.
Place the binary in
.spm.pods/Orcam/.prebuilt/debug/OrcamImpl
- Step (4): Copy the source files of the macro interfaces,
from
.spm.pods/.download/Orcam/Sources/Orcam
to.spm.pods/Orcam/Sources/Orcam
The pod dir .spm.pods/Orcam
can now be used as a development pod in the project.
.spm.pods/Orcam/
|-- Orcam.podspec
|-- Sources/
|-- Orcam/
|-- Orcam.swift
|-- .prebuilt/
|-- debug/
|-- OrcamImpl (*)
|-- release/
|-- OrcamImpl (*)
To simplify the usage, we can patch the pod
method to introduce an additional option, :macro
, to specify its source. Under the hood, it’s just like specifying pod "Orcam", :path => ".spm.pods/Orcam"
.
pod "Orcam", :macro => {
:git => "https://github.com/trinhngocthuyen/orcam.git",
:branch => "main",
}
For a deeper understanding of this technique, refer to this documentation.
Introducing cocoapods-spm
Struggling to integrate SPM packages seamlessly into your CocoaPods-based projects?
Meet cocoapods-spm, a CocoaPods plugin designed to simplify and enhance the integration process. This plugin offers an intuitive and pod-like syntax for declaring and managing SPM dependencies. The linking strategies discussed above are well effectively handled by the plugin. Additionally, the plugin provides a set of CLI usages to work with Swift binary macros.
Visit the GitHub repo for documentation, issue reporting, and contributions.
Let’s make SPM integration in CocoaPods-based projects a breeze!