TL;DR

Currently, when using Swift Package Manager packages in Xcode, SPM compiles the package with reference to the name of the Build Configuration and automatically selects whether to compile with debug or release, which determines the compilation flag like DEBUG and This determines the architecture of the final binary. This automatic selection may cause problems when using a custom Build Configuration in Xcode other than the default “Debug” and “Release”.

Right now (October 2022) there is no particularly good way to map the Build Configuration in Xcode to the SPM build environment. Hopefully, future versions of Xcode and SPM will be improved.

For some examples in the text, you can find the source code here.

Compile conditions in Xcode and SPM

Default DEBUG build conditions

In Xcode, when creating a project we are automatically given two Build Configuration: Debug and Release.

Build Configuration

In SWIFT_ACTIVE_COMPILATION_CONDITIONS, Debug Configuration predefines the DEBUG condition.

Debug Configuration predefines the DEBUG condition

This allows us to use code like the following to compile different content at Debug and Release time.

1
2
3
4
5
6
// In app
#if DEBUG
public let appContainsDebugFlag = true
#else
public let appContainsDebugFlag = false
#endif

Starting with Xcode 11, we can add frameworks directly in Xcode using SPM. In the package, we can also use the same code to differentiate.

1
2
3
4
5
6
7
8
// In package
public struct MyLibrary {
    #if DEBUG
    public static let libContainsDebugFlag = true
    #else
    public static let libContainsDebugFlag = false
    #endif
}

For observation purposes, these results can be placed on the UI.

1
2
3
4
5
6
Form {
  Section("DEBUG flag") {
    Text("App: \(appContainsDebugFlag ? "YES" : "NO")")
    Text("Package: \(MyLibrary.libContainsDebugFlag ? "YES" : "NO")")
  }
}.monospaced()

Running it with Xcode’s default Debug Configuration gives you the following result, and everything looks great.

Xcode’s default Debug Configuration

Custom compilation conditions

However, this DEBUG condition in Package is not implemented by passing SWIFT_ACTIVE_COMPILATION_CONDITIONS from the Xcode project to the SPM. To verify this, we can add a new condition to Xcode, such as CUSTOM.

add a new condition to Xcode

Similar to #if DEBUG, add an attribute to CUSTOM as well.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// In package
#if CUSTOM
public static let libContainsCustomFlag = true
#else
public static let libContainsCustomFlag = false
#endif
    
// In app
#if CUSTOM
public let appContainsCustomFlag = true
#else
public let appContainsCustomFlag = false
#endif

Unfortunately, this CUSTOM condition does not work in the package.

CUSTOM condition does not work in the package

If you do some confirmation on the build log, you can see that DEBUG and CUSTOM are correctly passed to the build command for the app target. However, when compiling the package, the condition given in is:

1
2
3
SwiftCompile normal arm64 Compiling\ MyLibrary.swift
...
builtin-swiftTaskExecution .. -D SWIFT_PACKAGE -D DEBUG -D Xcode ...

In Xcode 14.0, the conditions passed in are SWIFT_PACKAGE, DEBUG and Xcode; CUSTOM is not listed here.

At the time of writing, SPM only provides two Build Configuration, .debug and . release.

1
2
3
4
5
6
7
8
public struct BuildConfiguration : Encodable {

    /// The debug build configuration.
    public static let debug: PackageDescription.BuildConfiguration

    /// The release build configuration.
    public static let release: PackageDescription.BuildConfiguration
}

SPM itself supports custom conditions for a Configuration, and for packages that you have control over package, we can pass this condition by adding swiftSettings to Package.swift.

1
2
3
4
5
6
7
8
...
targets: [
  .target(
    name: "MyLibrary",
    dependencies: [],
+   swiftSettings: [.define("CUSTOM", .when(configuration: .debug))]
  ),
  ...

For external packages that are added directly from the git repository, the contents are locked by default. If you just need to temporarily pass in a compile condition, you can convert it to a local package by convert it to a local package and then add swiftSettings to it by doing something similar to the above. If a long-term solution is needed, consider wrapping the required external packages again yourself: create a new Swift package that depends on these external packages, and then add the appropriate swiftSettings when exposing them.

As package maintainers, if we use compile conditions other than DEBUG in our packages, it is best to add them in Package.swift accordingly. Xcode will respect these settings when the user compiles your package using Xcode.

Determination based on Build Configuration

When Xcode chooses to use .debug to build SPM packages, it “automatically” passes in DEBUG according to Xcode’s general build conditions. But when does Xcode choose to use .debug and when does it choose to use .release?

The answer may be a surprise. In the Xcode environment, Xcode selects the build configuration used for SPM packages based on the name of the Build Configuration. Specifically, the rules found for now are.

  • If the name contains Debug or Development (case-insensitive), then Xcode will use .debug to build the SPM package. For example, the default Debug, as well as Development, Debug_Testing, _development_, Not_DEBUG, and hello development are listed here.
  • Otherwise, use .release for compilation. For example, the default Release, as well as things like Dev, Testing, Staging, Prod, Beta, QA, CI, etc., all use .release as the compilation configuration.

Build Configuration

Xcode has chosen to be “empirical” and presumptuous here, and when SPM is used in Xcode, the name Custom Build Configuration becomes a joke. When you painstakingly configure a Testing build configuration for your project with the intention of running tests exclusively, you find that the Swift packages compiled under this configuration are optimized and stripped of testable support. To get SPM to work as intended, you must change the name of the Build Configuration in Xcode back to, say, Debug_Testing.

These rules are written in the Xcode build toolchain, they are not open source, and there is no documentation on the matter, so they are subject to change in the future. It is safer to just use the default Debug and Release Build Configuration, and when more environments are needed (e.g. to set different bundle ids or app names for different environments), it may be an option to use multiple schemes and configure them with the appropriate environment variables to to differentiate between them.

Compilation architecture and Apple Silicon

In addition to the DEBUG flag, Xcode automatically selects the architecture to be compiled for a package based on .debug and .release after selecting the build configuration for the SPM package. For the .release configuration, the situation is relatively simple: ONLY_ACTIVE_ARCH is set to false, compiling binaries for multiple architectures according to the Standard Architecture defined by the current Xcode version; for .debug, ONLY_ACTIVE_ARCH is set to true for .debug, which determines a build architecture based on the mac device and the target device (emulator or real machine).

Troubleshooting arm64-induced problems on the emulator

In the days of Apple Silicon, by default Xcode would run with the arm64 architecture. At this point, the included iOS simulator will also run under arm64. If you use some old libraries in your project that are released in binary, such as a framework made by fat binary, or .a files that do not contain the emulator arm64, then it is likely that you will see an error like this when linking to the emulator as a target on an Apple Silicon mac.

building for iOS Simulator, but linking in object file built for iOS, for architecture arm64

This is because although arm64 is included in the library, it is marked as being for use on real devices and not the simulator. The common approach on the web will teach you to add arm64 for the simulator in EXCLUDED_ARCHS, which is used to exclude this architecture.

1
EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64

This is a “quick fix” that will get you up and running. But you need to be aware of the downside of doing this: because arm64 is excluded, the only architecture option on the iOS simulator is x86_64. This means that your entire app will compile in x86_64 and then run on the x86_64 emulator. On Apple Silicon’s mac, this emulator is actually run using Rosetta 2, which means a significant performance drop.

Even more damaging is the fact that this method is even more problematic when used with SPM.

Because Xcode will not pass the EXCLUDED_ARCHS you set to SPM, you will have problems compiling against the simulator like this.

Could not find module ‘MyLibrary’ for target ‘x86_64-apple-ios-simulator’; found: arm64-apple-ios-simulator

For .debug, ONLY_ACTIVE_ARCH is true and the compilation target is the arm64 iOS simulator, so SPM will only give the compilation result for the arm64-apple-ios-simulator version. But the project itself has EXCLUDED_ARCHS arm64 set, and it’s actually the x86_64 simulator version of the package it needs when linking packages. Boom!

For older binary dependencies, the best thing to do is to urge the maintainer to adapt the xcframework quickly.

Another possible option is to hack the binary and modify the target field of the arm64 slice to “trick” Xcode into thinking that the binary is for the simulator. This approach is explained in detail here, and the author has also published a related arm64-to-sim tool, which can be used temporarily at your discretion if needed.

Accidents and accidental overlays

Understanding how SPM picks Build Configuration in Xcode and how the build architecture relates, we can “solve” the above problem by “fighting fire with fire”.

The easiest way to do this is to change the name of the Build Configuration in Xcode, e.g. change Debug to Dev. This way, SPM will pick .release to compile the Swift package, and it will then compile all supported architectures. In the app target, even if we exclude arm64, the link will still be there because the x86_64 Swift package is compiled, so we can find the required architecture and link it properly.

This practice of using one “accident” to correct another “accident” is silly, but it is also effective.

The biggest side effects are two.

  1. because the package is compiled using .release, it requires not only compiling unnecessary architecture, but also additional compilation optimizations, which will slow down the package compilation speed.
  2. because the package is optimized by release, debugging becomes difficult: for example, breakpoints set in the package may not work, the output of po may have problems, etc.

Summary

To get to the root of these problems, the SPM in Xcode needs to provide some means by which we can map Xcode’s Build Configuration (including the various build flag settings) to the SPM’s Build Configuration. The community envisioned Package Flavors was envisioned by the community to solve this problem, but the topic requires an official Apple modification as it involves the Xcode implementation. Unfortunately, however, we have not seen a public and positive response from Apple on this yet.

Until a mature solution is available, we are quite limited in what we can do, to summarize.

  • Try not to customize the name of the Build Configuration. If you do need to change it, understand how the name of the build configuration may affect the SPM compilation.
  • If you need to use binary libraries, try to use the xcframework format that includes all architectures. If not provided, consider using arm64-to-sim to convert the arm64 compiled for the device to arm64 for the emulator.
  • If you can’t get around it the usual way, you can create your own wrapper package and pass the required compilation parameters in Package.swift.
  • If that’s not possible on Apple Silicon, you can try running Xcode with Rosetta as a temporary solution.

Ref

  • https://onevcat.com/2022/10/spm-in-xcode/