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 via import 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.

Subscribe to this substack
to stay updated with the latest content