Bridging Rust to Swift, pt. 3: Binary Frameworks

In the first and second parts to this series, we wrote code in rust to build a C library. This was coupled with a header file that described its public API. While we could link to these directly from our Xcode projects, it is typical to set up some kind of wrapper library or package in Swift. It isn’t apparent with the simple library we currently have at hand, but, this could ease some of the pain that is associated with manual memory management in C. Generally, I do this in a two-step process.

  1. Build a binary .xcframework that contains the C library.

    macOS and iOS devices run on multiple processor architectures. macOS currently runs on arm and Intel architectures. On the other hand, iOS devices run on arm architecture, while the iOS simulator could be either arm or Intel.

    We need to build a version of the C library for each of our target architectures. While we could build individual versions and link to them manually in Xcode, it’s simpler to wrap them all in a binary xcframework. Our native Swift projects would then pick the appropriate version of the library to use for each architecture it is built against.

  2. Build a helper Swift package.

    This Swift package would have the xcframework built above as a binary target. It would also provide a native Swift API to the functionality provided by the C library. Essentially, this means writing wrapper structures/functions that make usage of the C library more Swift like. Ideally, the existence of the C library and binary framework would be transparent to the users of our package.

Build Targets

When we run cargo build, the rust compiler will generate a product that is compatible with the host platform. For example, building on a M1 mac, will produce a library that is compatible with apple arm64. Different targets can be specified with the --target flag. We can pick from a number of inbuilt targets or provide a specification of our own.

If we need the std parts of rust to compile our code, we would have to install it for any additional platforms. On an arm mac, we would need to run rustup target add x86_64-apple-darwin to target Apple Intel platforms for macOS. An x86_64 compatible version of the library for macOS can then be built with

cargo build --target x86_64-apple-darwin

We should see the relevant files under the target/x86_64-apple-darwin folder.

Targeting the x86_64 iOS simulator is a bit more involved. As there isn’t an inbuilt platform specification, we need to provide a custom spec. Additionally, we need to install rust-src for our host platform on the rust nightly channel. We can then build using

cargo +nightly build --release -Z build-std=core,std,alloc --target custom_target_spec/x86_64-apple-ios9.0.json 

Single Platform Multiple Architectures

Say, what…? macOS currently runs on arm64 and x86_64 architectures, which necessitates a separately compiled library for each of them. However, both the libraries are targeted at a single platform - macOS. Apple requires us to combine the separately compiled libraries into a universal library - before placing them in a framework. We are going to use a command line tool lipo to create these universal libraries. An example run for the macOS platform would be

lipo -create macOS_arm64/libswift_bridged.a macOS_x86/libswift_bridged.a -output macOS_universal/libswift_bridged.a

iOS Simulator which runs on arm64 and x86_64 required similar treatment.

Binary frameworks, modules and module maps

Now, that we have built the libraries for our various platforms, we still need a way to distribute them. Binary frameworks provide an easy way to do so, by putting them all in a .xcframework structure. This can be easily assembled using Xcode’s command line utilities which we will get to in a minute. Let’s first take a look at our need for modules.

Traditionally, the C pre-processor has been used to access software libraries via #include statements. Modules provide a convenient (and, necessary) alternative to this. After we build (or download a pre-built version of) our C library, we should have the header and library files that are needed to access it. To package this into a module, we need to provide a module.modulemap file. This file describes the module, and maps the library’s header files to the module.

framework module rmswift_bridged {
    header "rmswift_bridged.h"
    export *
}

Before creating the .xcframework, we need to manually assemble all the files we have into frameworks - one for each target platform. Create subfolders like macOS/rmswift_bridged.framework for each target platform. Make sure the module name and the framework name match, else, you could be faced with cryptic Xcode errors around importing the framework. Move the compiled .a library file for each platform into the root of its .framework folder - remove any file extension and rename it to match the framework name. Copy the header files and module map into the Headers and Modules folders respectively.

iOS framework’s additionally require a Info.plist file at it’s root. The easiest way I’ve found to get this is to create a dummy iOS project in Xcode and grab the plist from there. Make sure to edit the file to reflect the actual framework names etc.

Now, each rmswift_bridged.framework should have the following structure.

We can now get xcodebuild to assemble our binary .xcframework by using the -create-xcframework command and supplying it with frameworks of each architecture as an argument.

xcodebuild -create-xcframework \
-framework path/to/macOS_universal/rmswift_bridged.framework \
-framework path/to/iOS_device/rmswift_bridged.framework \
-framework path/to/iOS_simulator_universal/rmswift_bridged.framework \
-output RmSwiftBridgedRust.xcframework

Usage

Using this .xcframework to access our C library is quite simple.

  1. Drag the .xcframework into your Xcode project
  2. import rmswift_bridged in Swift files that need to access the C library.
  3. Directly access any of the C functions/structures in your Swift file. Here, we can call bodevAddIntegers(UInt32, UInt32) from our swift code.

While this works, it is generally better to abstract away from our C library. This would entail writing Swift functions and structures that wrap the components published by the C library. To simplify distribution, we could put it all in a Swift package - in the next installment to this series.