on
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.
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.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
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
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
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.
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.
rmswift_bridged
library file at the root- Header file
rmswift_bridged.h
inside theHeaders
folder module.module_map
in theModules
folder.Info.plist
file at the root for iOS device and simulator frameworks.
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.
Usage
Using this .xcframework
to access our C library is quite simple.
- Drag the
.xcframework
into your Xcode project import rmswift_bridged
in Swift files that need to access the C library.- 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.