Another day, another glorious way of printing Hello World. Here is the script.

XCFramework demo

# ignore lines that start with a hash (#)
setopt INTERACTIVE_COMMENTS

# skip commands where the glob pattern does not match any files
setopt null_glob

mkdir -p ~/Desktop/Hello
cd ~/Desktop/Hello
rm -rf *

# Compile the library source
cat << EOF > Greeter.swift
public class Greeter {
    public init() {}
    
    public func hello() {
        print("Hello World!")
    }
}
EOF

# Create directories for each architecture and platform
mkdir -p ios-arm64 ios-x86_64-simulator macos-universal

# Compile Hello.swift for macOS (arm64)
xcrun --sdk macosx swiftc -parse-as-library -emit-library -emit-module -module-name Hello \
    -emit-module-path macos-arm64/Modules/Hello.swiftmodule \
    -emit-module-interface-path macos-arm64/Modules/Hello.swiftinterface \
    -target arm64-apple-macosx10.15 \
    -enable-library-evolution \
    -o macos-arm64/libHello.dylib Greeter.swift

# Compile Hello.swift for macOS (x86_64)
xcrun --sdk macosx swiftc -parse-as-library -emit-library -emit-module -module-name Hello \
    -emit-module-path macos-x86_64/Modules/Hello.swiftmodule \
    -emit-module-interface-path macos-x86_64/Modules/Hello.swiftinterface \
    -target x86_64-apple-macosx10.15 \
    -enable-library-evolution \
    -o macos-x86_64/libHello.dylib Greeter.swift

lipo -create \
    macos-arm64/libHello.dylib \
    macos-x86_64/libHello.dylib \
    -output macos-universal/libHello.dylib
    
# Compile Hello.swift for iOS
xcrun --sdk iphoneos swiftc -parse-as-library -emit-library -emit-module -module-name Hello \
    -emit-module-path ios-arm64/Modules/Hello.swiftmodule \
    -emit-module-interface-path ios-arm64/Modules/Hello.swiftinterface \
    -target arm64-apple-ios14.0 \
    -enable-library-evolution \
    -o ios-arm64/libHello.dylib Greeter.swift

# Compile for iOS Simulator
xcrun --sdk iphonesimulator swiftc -parse-as-library -emit-library -emit-module -module-name Hello \
    -emit-module-path ios-x86_64-simulator/Modules/Hello.swiftmodule \
    -emit-module-interface-path ios-x86_64-simulator/Modules/Hello.swiftinterface \
    -target x86_64-apple-ios14.0-simulator \
    -enable-library-evolution \
    -o ios-x86_64-simulator/libHello.dylib Greeter.swift


## CREATE FRAMEWORKS

# Create framework structure for each platform in separate directories
mkdir -p macos-universal/Hello.framework/Versions/A/{Headers,Resources}
mkdir -p ios-arm64/Hello.framework/Headers
mkdir -p ios-x86_64-simulator/Hello.framework/Headers

# Move dylib
mv macos-universal/libHello.dylib macos-universal/Hello.framework/Versions/A/Hello
mv ios-arm64/libHello.dylib ios-arm64/Hello.framework/Hello
mv ios-x86_64-simulator/libHello.dylib ios-x86_64-simulator/Hello.framework/Hello

# Instead of separate directories, merge modules into one
mkdir -p macos-universal/Hello.framework/Modules/Hello.swiftmodule
cp macos-arm64/Modules/Hello.swiftmodule macos-universal/Hello.framework/Modules/Hello.swiftmodule/arm64.swiftmodule
cp macos-x86_64/Modules/Hello.swiftmodule macos-universal/Hello.framework/Modules/Hello.swiftmodule/x86_64.swiftmodule

# Copy other module files
cp macos-arm64/Modules/Hello.swiftdoc macos-universal/Hello.framework/Modules/
cp macos-arm64/Modules/Hello.abi.json macos-universal/Hello.framework/Modules/
cp macos-arm64/Modules/Hello.swiftsourceinfo macos-universal/Hello.framework/Modules/

# assembling the macos-universal framework
cp macos-arm64/Modules/Hello.swiftinterface  macos-universal/Hello.framework/Modules/Hello.swiftmodule/arm64.swiftinterface
cp macos-arm64/Modules/Hello.private.swiftinterface  macos-universal/Hello.framework/Modules/Hello.swiftmodule/arm64.private.swiftinterface
cp macos-x86_64/Modules/Hello.swiftinterface  macos-universal/Hello.framework/Modules/Hello.swiftmodule/x86_64.swiftinterface
cp macos-x86_64/Modules/Hello.private.swiftinterface  macos-universal/Hello.framework/Modules/Hello.swiftmodule/x86_64.private.swiftinterface

# copy ios modules
mv ios-arm64/Modules ios-arm64/Hello.framework
mv ios-x86_64-simulator/Modules ios-x86_64-simulator/Hello.framework

# Create Info.plist for each framework
cat << EOF > macos-universal/Hello.framework/Versions/A/Info.plist
<?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>CFBundleDevelopmentRegion</key>
    <string>en</string>
    <key>CFBundleExecutable</key>
    <string>Hello</string>
    <key>CFBundleIdentifier</key>
    <string>com.example.Hello</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>CFBundleName</key>
    <string>Hello</string>
    <key>CFBundlePackageType</key>
    <string>FMWK</string>
    <key>CFBundleShortVersionString</key>
    <string>1.0</string>
    <key>CFBundleVersion</key>
    <string>1</string>
    <key>MinimumOSVersion</key>
    <string>10.15</string>
</dict>
</plist>
EOF
# Create symlinks for macos-arm64
cd macos-universal/Hello.framework/Versions
ln -s A Current
cd ..
ln -s Versions/Current/Hello Hello
ln -s Versions/Current/Info.plist Info.plist
ln -s Versions/Current/Headers Headers
ln -s Versions/Current/Resources Resources
cd ../..

cp macos-universal/Hello.framework/Info.plist ios-arm64/Hello.framework/Info.plist
cp macos-universal/Hello.framework/Info.plist ios-x86_64-simulator/Hello.framework/Info.plist

/usr/libexec/PlistBuddy -c "Set :MinimumOSVersion 14.0" ios-arm64/Hello.framework/Info.plist
/usr/libexec/PlistBuddy -c "Set :MinimumOSVersion 14.0" ios-x86_64-simulator/Hello.framework/Info.plist

install_name_tool -id "@rpath/Hello.framework/Versions/A/Hello" macos-universal/Hello.framework/Versions/A/Hello
install_name_tool -id "@rpath/Hello.framework/Versions/A/Hello" ios-arm64/Hello.framework/Hello
install_name_tool -id "@rpath/Hello.framework/Versions/A/Hello" ios-x86_64-simulator/Hello.framework/Hello

# cleanup
rm -rf macos-x86_64
rm -rf macos-arm64


## CREATE THE XCFRAMEWORK
xcodebuild BUILD_LIBRARY_FOR_DISTRIBUTION=YES -create-xcframework \
    -framework macos-universal/Hello.framework \
    -framework ios-arm64/Hello.framework \
    -framework ios-x86_64-simulator/Hello.framework \
    -output Hello.xcframework

# cleanup
rm -rf ios-arm64
rm -rf ios-x86_64-simulator
rm -rf macos-universal


# COMPILE THE CLIENT

# Create UseHello.swift
cat << EOF > UseHello.swift
import Hello
@main
struct UseHello {
    static func main() {
        let greeter = Greeter()
        greeter.hello()
    }
}
EOF

install_name_tool -id "@rpath/Hello.xcframework/macos-arm64_x86_64/Hello.framework/Versions/A/Hello" Hello.xcframework/macos-arm64_x86_64/Hello.framework/Versions/A/Hello

xcrun swiftc -parse-as-library -target arm64-apple-macos15.0 -framework Hello -F Hello.xcframework/macos-arm64_x86_64 UseHello.swift -o UseHello -Xlinker -rpath -Xlinker @executable_path

./UseHello

An XCFramework is a distributable binary package created by Apple that contains variants of a framework or library for multiple platforms (iOS, macOS, tvOS, etc.) and architectures. It simplifies working with libraries that need to support multiple platforms and architectures. For instance, you could switch from SPM dependencies to binary dependencies, and skip compiling dependencies during development.

Dynamic frameworks

First I created dynamic frameworks for iOS, iOS Simulator, macOS ARM64 and x86_64. I made the macOS version a fat framework. The iOS plists are actually a copy with a different minimum version.

Dynamic framework structure.

Dynamic framework structure

% tree -F
./
├── Greeter.swift
├── ios-arm64/
│   └── Hello.framework/
│       ├── Headers/
│       ├── Hello*
│       ├── Info.plist
│       └── Modules/
│           ├── Hello.abi.json
│           ├── Hello.private.swiftinterface
│           ├── Hello.swiftdoc
│           ├── Hello.swiftinterface
│           ├── Hello.swiftmodule
│           └── Hello.swiftsourceinfo
├── ios-x86_64-simulator/
│   └── Hello.framework/
│       ├── Headers/
│       ├── Hello*
│       ├── Info.plist
│       └── Modules/
│           ├── Hello.abi.json
│           ├── Hello.private.swiftinterface
│           ├── Hello.swiftdoc
│           ├── Hello.swiftinterface
│           ├── Hello.swiftmodule
│           └── Hello.swiftsourceinfo
└── macos-universal/
    └── Hello.framework/
        ├── Headers -> Versions/Current/Headers/
        ├── Hello -> Versions/Current/Hello*
        ├── Info.plist -> Versions/Current/Info.plist
        ├── Modules/
        │   ├── Hello.abi.json
        │   ├── Hello.swiftdoc
        │   ├── Hello.swiftmodule/
        │   │   ├── arm64.private.swiftinterface
        │   │   ├── arm64.swiftinterface
        │   │   ├── arm64.swiftmodule
        │   │   ├── x86_64.private.swiftinterface
        │   │   ├── x86_64.swiftinterface
        │   │   └── x86_64.swiftmodule
        │   └── Hello.swiftsourceinfo
        ├── Resources -> Versions/Current/Resources/
        └── Versions/
            ├── A/
            │   ├── Headers/
            │   ├── Hello*
            │   ├── Info.plist
            │   └── Resources/
            └── Current -> A/
```

Note that iOS frameworks don’t have a Versions folder. In macOS there may be the case that the makers of a library offer a new version while supporting previous ones. But in iOS applications are distributed as standalone without being allow to share frameworks due to sandboxing restrictions.

There is no header or modulemap because this is a Swift-only project, no Objective-C, C, or C++.

Create XCFramework

And here is the command to join the frameworks.

xcodebuild BUILD_LIBRARY_FOR_DISTRIBUTION=YES -create-xcframework \
    -framework macos-universal/Hello.framework \
    -framework ios-arm64/Hello.framework \
    -framework ios-x86_64-simulator/Hello.framework \
    -output Hello.xcframework

I used BUILD_LIBRARY_FOR_DISTRIBUTION=YES to enable “library evolution support”. This generates a textual .swiftinterface.

XCFramework structure.

XCFramework structure

% tree -F Hello.xcframework 
Hello.xcframework/
├── Info.plist
├── ios-arm64/
│   └── Hello.framework/
│       ├── Headers/
│       ├── Hello*
│       ├── Info.plist
│       └── Modules/
│           ├── Hello.abi.json
│           ├── Hello.swiftdoc
│           └── Hello.swiftsourceinfo
├── ios-x86_64-simulator/
│   └── Hello.framework/
│       ├── Headers/
│       ├── Hello*
│       ├── Info.plist
│       └── Modules/
│           ├── Hello.abi.json
│           ├── Hello.swiftdoc
│           └── Hello.swiftsourceinfo
└── macos-arm64_x86_64/
    └── Hello.framework/
        ├── Headers -> Versions/Current/Headers/
        ├── Hello -> Versions/Current/Hello*
        ├── Info.plist -> Versions/Current/Info.plist
        ├── Modules/
        │   ├── Hello.abi.json
        │   ├── Hello.swiftdoc
        │   ├── Hello.swiftmodule/
        │   │   ├── arm64.private.swiftinterface
        │   │   └── arm64.swiftinterface
        │   └── Hello.swiftsourceinfo
        ├── Resources -> Versions/Current/Resources/
        └── Versions/
            ├── A/
            │   ├── Headers/
            │   ├── Hello*
            │   ├── Info.plist
            │   └── Resources/
            └── Current -> A/

Compiling the client

install_name_tool -id "@rpath/Hello.xcframework/macos-arm64_x86_64/Hello.framework/Versions/A/Hello" Hello.xcframework/macos-arm64_x86_64/Hello.framework/Versions/A/Hello

xcrun swiftc -parse-as-library \
    -target arm64-apple-macos15.0 \
    -framework Hello -F Hello.xcframework/macos-arm64_x86_64 \
    UseHello.swift \
    -o UseHello \
    -Xlinker -rpath -Xlinker @executable_path

What I did here:

  • I set the install name of the executable to the relative path of the binary.
  • I generated a client for the current architecture.
  • I told the linker to add the executable path (.) to the rpath, which is the list of folders where the dynamic loader looks for frameworks. This client is only linked to the macOS fat framework inside the XCFramework.

And finally,

% ./UseHello
Hello World!

Mergeable edition

Mergeable libraries are a special kind of dylib that can be linked statically or dynamically. This means we can use the same library to speed up launch time in release (linking statically), or to speed up iterations during development (linking dynamically).

To create a merged executable

  • pass -make_mergeable when compiling the library
  • pass -merge_framework (or -merge_library) when compiling the code that depends on the framework or library

The script is already passing -make_mergeable when creating the dylibs so we just have to pass it to the client. Let’s create ‘merged’ debug and release versions.

# compile merged executable for debug
xcrun swiftc \
    -F Hello.xcframework/macos-arm64_x86_64 \
    -framework Hello \
    -o UseHelloMergedDebug \
    -parse-as-library \
    -target arm64-apple-macos15.0 \
    -Xlinker -rpath -Xlinker @executable_path \
    -Xlinker -merge_framework -Xlinker Hello \
    UseHello.swift

# compile merged executable for release
xcrun swiftc \
    -F Hello.xcframework/macos-arm64_x86_64 \
    -framework Hello \
    -o UseHelloMergedRelease \
    -parse-as-library \
    -target arm64-apple-macos15.0 \
    -swift-version 6 \
    -O -whole-module-optimization \
    -Xlinker -rpath -Xlinker @executable_path \
    -Xlinker -merge_framework -Xlinker Hello \
    -Xlinker -dead_strip \
    UseHello.swift

Rejoice, we have three executables now

  • UseHello
  • UseHelloMergedDebug
  • UseHelloMergedRelease

All three from the same libraries but here is something funny, only one links to the framework: the one where we didn’t use -merge_framework.

% otool -L UseHello | grep Hello
UseHello:
	@rpath/Hello.xcframework/macos-arm64_x86_64/Hello.framework/Versions/A/Hello (compatibility version 0.0.0, current version 0.0.0)

% otool -L UseHelloMergedDebug | grep Hello
UseHelloMergedDebug:

% otool -L UseHelloMergedRelease | grep Hello
UseHelloMergedRelease:

Those where we merged have the definition of the Greeter class. Therefore they can be distributed independently of the framework.

% nm UseHello | swift demangle | grep DATA_Hello.Greeter

% nm UseHelloMergedDebug | swift demangle | grep DATA_Hello.Greeter
000000010000c048 s __DATA_Hello.Greeter
000000010000c000 s __METACLASS_DATA_Hello.Greeter

% nm UseHelloMergedRelease | swift demangle | grep DATA_Hello.Greeter
0000000100008048 s __DATA_Hello.Greeter
0000000100008000 s __METACLASS_DATA_Hello.Greeter

Merging not only applies to executables. Two dependencies can become one if you merge them to a third framework. The benefit is that symbols are reexported and you only need to import that third framework.

Conclusion

Mergeable XCFrameworks are the final destination of a journey through static libraries, frameworks, and universal binaries. By consolidating multiple architectures and platforms into a single binary dependency, we bypass the need for package resolution and compilation.

References