Give your simulator superpowers

Give your Xcode
Simulator extra features

Binary Targets in Swift Package Manager

Binary Targets in Swift Package Manager (SPM) allow packages to declare xcframework bundles as available targets. The technique is often used to provide access to closed-source libraries and can improve CI performance by reducing time spent on fetching SPM repositories.

Both downs and upsides are essential to consider when adding binary targets to your project. It’s essential to understand the problem they solve and how xcframeworks play a role in their supported platforms.

What is a binary target?

A binary target is explained as a target referencing a specific binary. Binary frameworks are pre-compiled source code joined by a defined interface that allows it to be used in your apps. A binary framework can be defined as static or dynamic.

Swift Package Manager allows defining binary targets by referencing so-called xcframeworks.

XCFramework explained

An XCFramework allows the distribution of multiple frameworks and libraries required to build for multiple platforms. The included frameworks can be static or dynamic and define the supported platforms for the xcframework.

Inside an XCFramework, you’ll find a framework for each supported architecture. For example, Firebase’s Crashlytics xcframework looks as follows:

An example of an xcframework containing multiple binary targets.
An example of an xcframework containing multiple binary targets.

You can tell from this overview that there’s support for tvOS, macOS, and iOS.

The Info.plist file defines all available frameworks:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>AvailableLibraries</key>
	<array>
		<dict>
			<key>LibraryIdentifier</key>
			<string>ios-arm64_x86_64-maccatalyst</string>
			<key>LibraryPath</key>
			<string>FirebaseCrashlytics.framework</string>
			<key>SupportedArchitectures</key>
			<array>
				<string>arm64</string>
				<string>x86_64</string>
			</array>
			<key>SupportedPlatform</key>
			<string>ios</string>
			<key>SupportedPlatformVariant</key>
			<string>maccatalyst</string>
		</dict>
		
		// .. Other frameworks
		
	</array>
	<key>CFBundlePackageType</key>
	<string>XFWK</string>
	<key>XCFrameworkFormatVersion</key>
	<string>1.0</string>
</dict>
</plist>

Your apps will only build for the platforms supported by the referenced xcframeworks. In other words, you’ll need to ensure that any binary target added matches the supported platforms of your application.

The downsides of using binary targets

Before considering adding binary targets to your project, knowing the downsides is essential. Binary targets are great for distributing closed-source frameworks, but that also means debugging becomes harder. Since you can only reference header files, you won’t be able to debug through the lines of code as you would otherwise be able to do when referencing packages as regular SPM targets.

Secondly, an xcframework only supports the platforms for which it contains frameworks or libraries. Platforms move forward by adopting new architectures, requiring you to update and rebuild the XCFramework libraries to add support for new architectures. You can take the adoption of Apple silicon as a recent example.

The upsides of binary targets

At this point, you might wonder what the benefits of using a binary target are. A commonly mentioned benefit is that you can distribute frameworks as closed source. Yet, I want to focus on a more major upside that I experienced lately after trying to speed up our CI.

A downside of Swift Package Manager is that the whole repository has to be downloaded before your project can start building. I’m sure many of you have experienced long loading times in Xcode while waiting for all packages to be fetched. I’ve experienced examples of repositories containing snapshots for testing with a total size of more than 100MB. While I was only interested in building the actual framework, I also had to wait to fetch all those snapshots.

Swift Package Manager caches its fetched frameworks to optimize performance in a folder structure that looks as follows:

An example of the folder structure Swift Package Manager uses for caching.
An example of the folder structure Swift Package Manager uses for caching.

The repositories directory contains files for the Git tag matching the latest release you’ve fetched. Files are optimized using so-called Packfiles to save space and be more efficient. Yet, it still means SPM has to fetch all the large files defined inside the repository.

As an example, I downloaded the Swift Package Manager cache directory of our CI system and analyzed the size of the repositories folder:

[email protected] repositories % du -sh */ | sort -hr   
357M	lottie-ios-0271592b/
173M	firebase-ios-sdk-a1569f98/
117M	grpc-ios-9a5f03c1/
 44M	boringssl-SwiftPM-c3eba25b/
 40M	ContentGallery-20ca4562/
 34M	Auth0.swift-483a50e2/
 33M	PanModal-f760eb02/
 32M	Alamofire-e8f130fe/
 30M	dd-sdk-ios-ecc3edec/
 29M	swift-protobuf-9f1e4ec4/
 26M	Gifu-05fd1ae0/
 24M	plcrashreporter-606c3f9d/
 23M	UINotifications-98c8d6f9/
 18M	snowplow-objc-tracker-08ee2f30/
 18M	purchases-ios-92046cd5/
 17M	CryptoSwift-72c2bbc7/
 12M	swifter-2897f0d5/
 12M	Nuke-6e25c069/
8.4M	Zip-06769e8d/
6.9M	Amplitude-iOS-8df29baf/
6.7M	KeychainAccess-d7d19352/
4.4M	nanopb-208d9073/
4.1M	Diagnostics-9c08c655/
3.5M	abseil-cpp-SwiftPM-cc296c64/
2.8M	fmdb-edf51281/
2.8M	JWTDecode.swift-88cf1fc9/
2.1M	gtm-session-fetcher-66fe3e42/
1.7M	dd-sdk-swift-testing-6bc33eba/
1.6M	leveldb-12d5b35c/
1.4M	GoogleDataTransport-2feee0f8/
1.3M	SimpleKeychain-ad0e4670/
1.3M	Mocker-ce713560/
1.0M	GoogleUtilities-34a5fa84/
696K	promises-effc8acf/
644K	BlueRSA-df3fcd6a/
568K	combine-schedulers-6797c0bf/
344K	experiment-ios-client-edba1876/
204K	Trekker-cbf63343/
196K	xctest-dynamic-overlay-856e3cf8/
168K	SwiftUIKitView-3a178e3e/
144K	GoogleAppMeasurement-0ed9e027/
104K	analytics-connector-ios-280b19df/
 96K	ExceptionCatcher-8424f3ff/

Frameworks like Lottie and Firebase represent a significant portion of the cache size. Even worse, we only use a portion of the frameworks Firebase provides while we still have to fetch all the files defined in the repository.

While the Swift Package Registry should be an actual solution to this problem, we currently have to deal with how SPM works.

Binary targets to the rescue

When dealing with large repositories you can start considering binary targets to help you speed up CI and SPM performance. Carefully consider whether you’ll need to often debug those targets as you won’t have access to the actual code anymore. In our case, we never really debug Firebase, so we decided to switch over to directly referencing xcframeworks.

For us, this resulted in only having to fetch 117MB directly from Git compared to ~360MB+. A significant improvement that can vary based on the frameworks your project needs.

There are several ways of including XCFrameworks, for which I’d like to refer you to this thread specifically. The thread covers the same problem I faced, resulting in a specific solution for Firebase. Yet, you might be looking to add binary targets for any framework.

Defining a binary target

You can define a binary target using a local path or a remote URL. The latter will let SPM fetch the binary up on fetching all other packages. The local path reference can be helpful for local packages or when you want to provide binary targets for frameworks that don’t offer xcframeworks themselves. Note that this is only required if you define dependencies inside a Package.swift file. You can add xcframework files directly into Xcode otherwise.

Remote Binary Targets

An example binary target referencing a remote URL can look as follows:

.binaryTarget(
    name: "SwiftLintBinary",
    url: "https://github.com/realm/SwiftLint/releases/download/0.49.1/SwiftLintBinary-macos.artifactbundle.zip",
    checksum: "227258fdb2f920f8ce90d4f08d019e1b0db5a4ad2090afa012fd7c2c91716df3"
)

This is an example of SwiftLint that provides a binary with each release. Remote binary targets require you to provide a checksum to verify that the hosted archive file matches the archive you declare in the manifest file. Not all packages provide such checksum, so let me explain how you can define them yourself.

In case your package comes with a Package.swift file you can execute the following command in your terminal:

swift package compute-checksum FirebaseAnalytics.xcframework
Prints: 547258fdb2f920f8ce90d4fkli9873mb0db5a4ad2090afa012fd7c2c91716ds1

However, you might be running into an error:

error: Could not find Package.swift in this directory or any of its parent directories.

In this case, you can run the following command:

shasum -a 256 SwiftLintBinary-macos.artifactbundle.zip | sed 's/ .*//'                 
Prints: 227258fdb2f920f8ce90d4f08d019e1b0db5a4ad2090afa012fd7c2c91716df3

Local Binary Targets

You can define local binary targets using a path inside your Package.swift file:

.binaryTarget(
    name: "FirebaseAnalytics",
    path: "FirebaseAnalytics.xcframework"
)

Since you’re referencing a framework found locally, there’s no need to compare a checksum for security reasons.

Creating an xcframework

Referencing an xcframework only works if the framework you’re trying to adopt provides an actual xcframework variant. In my case, Lottie didn’t yet provide such a framework, so I decided to create one myself.

Creating multiplatform binaries can be complicated for each library you work with. Therefore, I’d like to reference the detailed instructions provided by Apple here.

Following Apple’s instructions I downloaded the Lottie repository and executed the following command from the terminal:

$ xcodebuild archive -workspace Lottie.xcworkspace -scheme "Lottie (iOS)" -destination generic/platform=iOS -archivePath "archives/Lottie_iOS" SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES

And another command for adding support for the Simulator:

$ xcodebuild archive -workspace Lottie.xcworkspace -scheme "Lottie (iOS)" -destination "generic/platform=iOS Simulator" -archivePath "archives/Lottie_iOS_Simulator" SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES

Lastly, I executed the following command to generate an xcframework:

$ xcodebuild -create-xcframework -framework archives/Lottie_iOS.xcarchive/Products/Library/Frameworks/Lottie.framework -framework archives/Lottie_iOS_Simulator.xcarchive/Products/Library/Frameworks/Lottie.framework -output xcframeworks/Lottie.xcframework

xcframework successfully written out to: /Users/avanderlee/Downloads/lottie-ios-master 2/xcframeworks/Lottie.xcframework

The result of these commands is a new xcframework:

The folder structure of the xcframework we've generated for Lottie.
The folder structure of the xcframework we’ve generated for Lottie.

We can start referencing this xcframework locally inside our package manifest file:

.binaryTarget(
    name: "Lottie",
    path: "Lottie.xcframework"
)

Conclusion

Binary targets in Swift Package Manager allow you to optimize the performance of SPM and CI by no longer having to download all repository files. While there are downsides like no longer being able to debug through the code, there are upsides like a reduced size of packages cache. When there’s no xcframework available, you can create one yourself.

If you like to improve your Swift knowledge, even more, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.

Thanks!