Swift Packages: Packaging as an XCFramework (1)
Background
Modularizing code using Swift packages has become more and more common in iOS development. With the introduction of XCFrameworks, Apple has provided a robust mechanism to ship prebuilt products that support multiple platforms and architectures.
Binary distribution of SDKs and third-party libraries is not new, especially in closed-source projects where protecting intellectual property is crucial. Another major benefit is reduced build times. Many build caching tools rely on this aspect, ie. prebuilding code into binaries, to speed up development workflows.
With Swift packages, you can declare a binary target, backed by either an XCFramework, or a zip of an XCFramework, in the manifest using .binaryTarget(name:path:)
.
However, building an XCFramework from a Swift package target isnβt as straightforward as it seems. The process generally breaks down into two main steps:
- (1) Creating a framework bundle for each slice (arm64-ios-simulator, arm64-ios, arm64-macos, etc.)
- (2) Merging slices into an XCFramework bundle
While the second is relatively easy with xcodebuild -create-xcframework
, the first step - building the individual frameworks, is where the challenge lies. Letβs explore why.
Why swift build
Isnβt Enough
When building a Swift package using swift build
, you donβt get a .framework
bundle, or even a static .a
file like you would with an Xcode project. Instead, it produces object files (.o
) along with module-related auxiliaries.
Historically, Swift provided a generate-xcodeproj
subcommand that made it easy to create an .xcodeproj
file for a package. This allowed developers to build a proper framework via xcodebuild
, with support for features like library evolution.
However, this command was removed in Swift 5.8, leaving developers difficulties distributing their prebuilt frameworks. Many projects maintains their own .xcodeproj
for this purpose. Kingfisher, for example, obtains the XCFramework using a bunch of Fastlane code which requires an Xcode project.
Some tools like swift-create-xcframework recreate this process by generating an Xcode project and then archiving frameworks via xcodebuild
.
But what if we want to avoid Xcode projects entirely? How do we produce a .framework
bundle directly from swift build
outputs? Letβs find out.
Creating a Framework on Your Own
We can formulate the process of creating a framework from sources. But first, letβs take a look at what a standard framework bundle looks like.
Understanding Framework Structure
Below is the structure of Kingfisher.framework
, taken from its release page.
Kingfisher.framework
βββ Headers
βΒ Β βββ Kingfisher-Swift.h
βββ Info.plist
βββ Kingfisher
βββ Modules
βΒ Β βββ Kingfisher.swiftmodule
βΒ Β βΒ Β βββ arm64-apple-ios.abi.json
βΒ Β βΒ Β βββ arm64-apple-ios.private.swiftinterface
βΒ Β βΒ Β βββ arm64-apple-ios.swiftdoc
βΒ Β βΒ Β βββ arm64-apple-ios.swiftinterface
βΒ Β βββ module.modulemap
βββ PrivacyInfo.xcprivacy
Key components:
- Binary (
Kingfisher
): contains the compiled code - Modules
Kingfisher.swiftmodule
defines the Swift modules, making it visible to Swift code viaimport X
.
module.modulemap
defines the Clang module, making it visible to Objective-C code via@import X;
.
For more details on how module imports work, check out my previous article: How Xcode Recognizes Module Imports. - Headers: includes public headers, declared in the modulemap.
- Info.plist: metadata such as name, bundle ID, version, etc.
Examining Build Products
After running swift build --target Kingfisher
, the build directory looks like as follows.
.build/debug/
βββ Kingfisher_Kingfisher.bundle
βΒ Β βββ PrivacyInfo.xcprivacy
βββ Kingfisher.build
βΒ Β βββ Kingfisher-swift.h
βΒ Β βββ *.swift.o (object files)
βΒ Β ...
βββ Modules
βββ Kingfisher.swiftdoc
βββ Kingfisher.swiftmodule
βββ Kingfisher.swiftsourceinfo
Creating Framework Binary
What is missing in the build products compared to the standard framework structure? There is no consolidated binary of the target. Instead, we have scattered object files (.o
) under Kingfisher.build
.
We can use libtool to produce a static library/archive out of them.
$ libtool -static -o Kingfisher.framework/Kingfisher .build/debug/Kingfisher.build/**/*.o
Creating Modules
There are some module-related files among build outputs of a Swift target under the Modules directory. We just need to copy them to the .swiftmodule
bundle inside the framework (Modules/Kingfisher.swiftmodule
).
Remember to rename those files according to the slice, eg. arm64-ios-simulator
.
Kingfisher.framework
βββ Modules
βββ Kingfisher.swiftmodule
βββ arm64-ios-simulator.swiftdoc
βββ arm64-ios-simulator.swiftmodule
βββ arm64-ios-simulator.swiftsourceinfo
To make this module visible to Objective-C, we need to create a modulemap, under Kingfisher.framework/Modules/module.modulemap
.
framework module Kingfisher {
umbrella header "Kingfisher-umbrella.h"
export *
module * { export * }
}
In this case, Kingfisher-umbrella.h
resides under Headers/
. Weβll talk about the headers in the subsequent section.
Creating Headers
Copy the generated Swift header (eg. in Kingfisher.build/Kingfisher-Swift.h
) to Headers/
and create the umbrella header:
// File: Kingfisher-umbrella.h
#include <Kingfisher/Kingfisher-Swift.h>
If the target includes Objective-C code, we need to copy its public headers as well.
For example, with FirebaseCrashlytics, youβll find its public headers defined in Package.swift#L462. Those under Crashlytics/Public should be copied to the frameworkβs Headers, and included in the umbrella header as follows.
// File: FirebaseCrashlytics-umbrella.h
#include <FirebaseCrashlytics/FIRCrashlytics.h>
#include <FirebaseCrashlytics/FIRCrashlyticsReport.h>
#include <FirebaseCrashlytics/FirebaseCrashlytics.h>
#include <FirebaseCrashlytics/FIRExceptionModel.h>
#include <FirebaseCrashlytics/FIRStackFrame.h>
Now, the framework bundle looks like this.
FirebaseCrashlytics.framework
βββ FirebaseCrashlytics
βββ Headers
βΒ Β βββ FirebaseCrashlytics-umbrella.h
βΒ Β β
βΒ Β βββ FIRCrashlytics.h
βΒ Β βββ FIRCrashlyticsReport.h
βΒ Β βββ FirebaseCrashlytics.h
βΒ Β βββ FIRExceptionModel.h
βΒ Β βββ FIRStackFrame.h
βββ Info.plist
βββ Modules
βββ module.modulemap
Dealing with headers in Swift packages is sometimes tricky. Header imports using flat angle-bracket style #include <foo.h>
may break when headers are moved into framework bundles.
With SPM, this import is valid as long as the header is visible to the header search paths, configured by the publicHeadersPath
argument of a target declaration.
let package = Package(
...
targets: [
.target(
name: "FirebaseCrashlytics",
...
publicHeadersPath: "Crashlytics/Public", <-- HERE
...
)
]
)
However, inside a framework, headers are isolated from SPMβs header search paths, resulting in “header not found” errors. Imports like #include <foo.h>
no longer works. Meanwhile, cross-referencing headers of another framework is still okay. Therefore, we may need to change the import from flat angle-bracket style to the nested angle-bracket style: #include <foo/foo.h>
. This way, the compile can resolve header imports as long as the framework and its dependency frameworks are shipped together.
Enabling Library Evolution
With the xcodeproj-based approach, this can be done simply by setting the build setting BUILD_LIBRARY_FOR_DISTRIBUTION=YES
.
Once enabled, youβll notice that the .swiftmodule
bundle differs from the standard case. Instead of a binary .swiftmodule
file, it now includes a textual .swiftinterface
file. We need it for the packaging process.
Kingfisher.framework
βββ Modules
βββ Kingfisher.swiftmodule
βββ arm64-ios-simulator.swiftinterface <-- HERE
To achieve the same result using swiftc
, you need to pass the following flags: enable-library-evolution
and emit-module-interface
.
You might encounter an error like 'X' is not a member type of class 'X.Xβ
if a struct, class, or enum shares the same name as the module. This can be resolved by adding the swiftc
flag -alias-module-names-in-module-interface
.
These flags are passed to swift build
via the -Xswiftc
option. Your command should look like this:
swift build ... \
-Xswiftc -enable-library-evolution \
-Xswiftc -emit-module-interface -Xswiftc \
-Xswiftc -alias-module-names-in-module-interface
Keep in mind that not all packages are compatible with library evolution. For example, in swift-syntax, many switch
statements do not use @unknown default
(see: here), causing failures when generating Swift interfaces.
In such cases, you may prefer to ship the framework without enabling library evolution. When creating an XCFramework from slices using xcodebuild
, you need to add the -allow-internal-distribution
flag to skip interface validation.
xcodebuild -create-xcframework -allow-internal-distribution ...
Introducing xccache
Too much hassle just to produce an XCFramework? Meet xccache, a caching tool for Xcode projects, with SPM support.
In addition to powerful build caching features, xccache makes it easy to build a Swift package target into an XCFramework. No need to worry about headers, Swift modules, module maps, or other low-level details. Just one command, and you’re done.
xccache pkg build Kingfisher
The CLI also supports multi-platform builds, along with other features.
xccache pkg build Kingfisher --sdk=iphoneos,iphonesimulator,macos,tvos
Check it out at: xccache.
Wrapping Up
Distributing prebuilt Swift packages as XCFrameworks is powerful but nuanced without Xcode projects. By understanding how modules, headers, Swift interfaces fit together, we can construct framework bundles from swift build
outputs.
Tools like xccache help simplify the process, making binary distribution more efficient in a modern SPM-driven workflow.
Like what youβre reading? Buy me a coffee and keep me going!
Subscribe to this substack
to stay updated with the latest content