Developing Mixed-Source Swift Packages
Apple originally introduced the Swift Package Manager in 2015 as a command-line tool for managing the distribution of Swift code. Starting with the release of Xcode 11, the Swift Package Manager has been integrated into the Xcode user interface as a convenient, first-party solution for distributing open and closed source libraries. Over the past couple of years, many of the Swift Package Manager’s initial limitations have been addressed, and it has become a rich tool for supporting binary framework distribution, embedding resources and localization, and more. Today, it’s usually the most convenient way to integrate third-party code into Xcode projects. Almost all prominent open source iOS and macOS projects now support the Swift Package Manager, and you should consider doing the same if you intend to distribute a library.
Adding support for the Swift Package Manager to your existing Swift library project is a straightforward process, but doing the same with a mixed-language codebase — in our case, Objective-C and Swift — can be challenging. In this blog post, we’ll look at two options for delivering a Swift package with Objective-C and Swift source code, along with the pros and cons of each option.
Use Cases for Mixed-Source Swift Packages
For simple open source libraries, using only Swift code is a no-brainer: It’s a powerful language, and most Apple frameworks have been updated to fully support the Swift coding style and naming idioms. However, there are a few cases where being able to include Objective-C source code is a must:
-
You have a sizeable existing codebase written in Objective-C and rewriting it in Swift isn’t an option.
-
You’re using older Apple frameworks — like the low-level Core Audio framework — that were designed to be used primarily from Objective-C and are inconvenient to use from Swift.
-
You need interoperability with C++, which is only possible using Objective-C++.
Delivering Mixed-Source Swift Packages
There are two ways to deliver a mixed-source package:
-
Mixed-source multi-target Swift package — Source code is organized into library targets based on the source language. The library is built on the user’s machine based on the package manifest file configuration.
-
Binary framework distribution using a Swift package — A prebuilt binary framework distributed in a Swift package.
In the next sections, we’ll first look into the details of setting up a mixed-source multi-target Swift package, and then we’ll later compare it to the binary framework distribution of the same codebase.
Mixed-Source Multi-Target Swift Package
For our example, we’ll create a package that contains Objective-C and Swift code. Unlike Xcode frameworks where the build instructions are stored in the project file, Swift packages rely only on the Package.swift
manifest file for build instructions. While most of the defaults can be overridden in the manifest, they offer a good starting point:
-
Source files should be placed in the
Sources
folder. -
The default location for the source files of a target is the folder name matching the target name.
-
The default location for public headers is the
include
folder inside the target folder.
The Swift Package Manager requires that a target contains code in only one programming language, so our manifest file will contain two targets — one for our Swift source code, and another for our Objective-C source code:
// swift-tools-version:5.5 import PackageDescription let package = Package( name: "MixedSourceModule", products: [ .library( name: "MixedSourceModule", // 1 targets: ["MixedSourceModule"]), ], targets: [ .target( name: "MixedSourceModuleObjC", // 2 cSettings: [ .headerSearchPath("Private") // 3 ] ), .target( name: "MixedSourceModule", // 4 dependencies: ["MixedSourceModuleObjC"] // 5 ), ] )
Let’s take a look at the details:
-
Our package contains only one product — a library called
MixedSourceModule
. -
We define a target for the Objective-C code in
MixedSourceModuleObjC
. -
By default, public headers can be found in the
include
directory, but we can further specify other header search paths to make private headers available inside the target. -
We define a target for the Swift code in
MixedSourceModule
. -
We add
MixedSourceModuleObjC
as a dependency to ourMixedSourceModule
target.
Defining the Public API of the Library
We have to consider which application programming interface (API) we want to publicly expose to the users of our library:
-
Some of the definitions used by the library are considered implementation details, and it’s best to keep them hidden.
-
It’s a good idea to only expose the minimum that’s necessary for the library to function. You can always open up more APIs later if further customization points are needed, but taking away APIs is much harder.
-
Exposing unnecessary APIs can easily lead to breaking changes in the future if you want to change the inner workings of the library. Breaking changes require a major version increment and possibly a migration guide to help the transition.
To specify API visibility in the Objective-C target:
-
Public header files go in the
include
folder. -
Private header files can be placed anywhere, but they have to be covered by the search path specified in the package manifest.
To specify API visibility in the Swift target:
-
Use the regular access control keywords to define your public API.
-
Use
public
andopen
to make your definitions available in your public interface. -
The default
internal
access level will hide your definitions.
Limitations
Swift packages can be used in both Swift and Objective-C projects. Our goal is to have a common module name for importing without exposing any internal details of the package:
import MixedSourceModule // Swift
@import MixedSourceModule; // Objective-C
However, since our definitions are spread between two targets and implementation languages, we might end up having to import both MixedSourceModule
and MixedSourceModuleObjC
to access all definitions when using the package in our project. This is a problem because it limits our ability to move classes in our framework from Objective-C to Swift, as it’ll become a breaking change for the library users.
One consequence of having your public header files separate from the implementation files is Xcode’s inability to easily switch between the two using keyboard shortcuts. This is an inconvenience we hope will be fixed in future versions of Xcode.
Binary Framework Distribution Using a Swift Package
Now let’s look at distributing your library using a binary framework package. With this option, the framework is prebuilt, and the build process follows the same principles we discussed in an earlier blog post, Supporting XCFrameworks.
Creating a package manifest becomes much simpler, as it only needs to reference the binary framework:
// swift-tools-version: 5.5 import PackageDescription let package = Package( name: "MixedSourceModule", products: [ .library( name: "MixedSourceModule", targets: ["MixedSourceModule"]) ], targets: [ .binaryTarget( name: "MixedSourceModule", url: "<url for the pre-built binary framework zip file>", checksum: "<checksum for the zip file>"), ] )
You can learn more about binary framework distribution in the official documentation.
Pros and Cons
The two options above both have advantages and disadvantages.
Binary framework distribution is the recommended option if you already distribute your library as a framework for manual integration or using CocoaPods, or if your library is strictly closed source. You can rely on a rich set of options in Xcode when building the binary framework, and adding a manifest for the Swift Package Manager is a trivial next step in providing a convenient way to integrate your library into Xcode projects. You also have full control over the inner organization of your framework, and you can move implementation from Objective-C to Swift without any breaking changes or updates needed by library users.
The drawback is that binary frameworks can be large in size, and you have to decide how to best host them. Binary frameworks also only support Apple platforms, so if you want to support Linux, you have to find complementary solutions.
Multi-target mixed-source package distribution is recommended for open source projects where maintaining a pipeline to build the binary framework is undesirable. Downloading the source code can be much faster, and a Git repository can serve both as the version control system for development and the host for distribution. As the build is happening on the user’s machine, the supported platforms aren’t limited to those supported by binary frameworks and could include Linux as well. Providing the source code is great way to provide visibility into the inner workings of the library and improve the developer experience.
However, there are some drawbacks: Implementation details are visible due to different import statements needed for Objective-C and Swift classes, and setting up and maintaining a package manifest might be challenging compared to the well-understood and documented practices used with binary frameworks.
Conclusion
In this blog post, we went over the basics required to distribute a mixed-source module written in Swift and Objective-C using the Swift Package Manager. We also compared this to delivering the same codebase using binary framework distribution. Each option has its own benefits, and it’s up to the library developer to make a choice. We hope with the considerations outlined in this article, we could help you understand the tradeoffs and make your decision easier.
At PSPDFKit, we use binary framework distribution for our core library and multi-target source distribution for our open source helper library, PDFXKit. PDFXKit is a drop-in replacement for Apple’s PDFKit, and it’s an easy way to evaluate our PDF framework when your needs exceed the functionality of the built-in framework.