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.

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:

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:

To link dynamic libraries (ex. Bar) with a 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:

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!