Skip to content

Instantly share code, notes, and snippets.

@boehs
Last active December 20, 2023 07:48
Show Gist options
  • Save boehs/0966e875592aacbb75b9859a8e5372f4 to your computer and use it in GitHub Desktop.
Save boehs/0966e875592aacbb75b9859a8e5372f4 to your computer and use it in GitHub Desktop.
A little guide to UniFFI for iOS (using Proc macros)

Hi all! My app, Love Your Music, needs a cross platform backend shared between a swift and kotlin codebase. UniFFI has been an absolute joy to use, but unfortunately the setup process is a little bit poorly documented. No problem! I got you <3. This tutorial assumes you have a file structure like

Note

Why Rust × Swift? A common use for rust is FFI, which we are actually doing right here anyway! But if you can FFI to Swift, you can FFI to anywhere. Cross platform apps writen in native code (e.g. apps with a Kotlin counterpart) benefit greatly from shared libraries and UniFFI. In addition, the Rust community is significantly larger, and so it's very likely you'll find crates that are more battletested, more featureful, higher performance, more documented, or even nonexistant in Swift.

app/
app.xcodeproj/
app.xcworkspace/
myCrate/
  cargo.toml
  src/
    lib.rs

Of course, you can diverge from this if so needed.

it also assumes you are on Mac. I don't know why you'd be here if you aren't

Converting a rust codebase to UniFFI

Add UniFFI to your crate

[dependencies]
uniffi = { version = "0.25.3", features = [ "cli" ] }

[build-dependencies]
uniffi = { version = "0.25.3", features = [ "build" ] }

A rust codebase like

enum Fruits {
  Watermelon,
  Cranberry,
  Cherry
}

struct Person {
  name: String,
  age: u8
}

fn add(a: u32, b: u32) -> u32 {
    a + b
}

Can be converted to

#[derive(uniffi::Enum)]
enum Fruits {
  Watermelon,
  Cranberry,
  Cherry
}

#[derive(uniffi::Record)]
struct Person {
  name: String,
  age: u8
}

#[uniffi::export]
fn add(a: u32, b: u32) -> u32 {
    a + b
}

Also, somewhere in lib.rs at the top level (not inside a function) you need to write

uniffi::setup_scaffolding!();

Building & Adding UniFFI to your Swift codebase

This configures UniFFI to generate libraries it understands. Unfortunately, as part of this build the Swift and header code is not generated. You might be inclined to do this in build.rs, but I don't reccomend this because as of writing Cargo does not support post-build code, and to generate correct swift code we need correct libraries. Instead, create a binary for a small CLI by making a new file named uniffi-bindgen.rs (I put this outside of the src directory, and inserting the following:

fn main() {
    uniffi::uniffi_bindgen_main()
}

Don't forget to add it to your cargo.toml!

[[bin]]
name = "uniffi-bindgen"
path = "uniffi-bindgen.rs"

Great, we now have a binary that basically proxies UniFFI. How do we call it?

Create build.sh in the top level of the project's fs (on the same level as your app and crate folders), and write the following:

cd <myCrate>

NAME="<myCrate>"
HEADERPATH="out/${NAME}FFI.h"
TARGETDIR="target"
OUTDIR="../<myApp>"
RELDIR="release"
STATIC_LIB_NAME="lib${NAME}.a"
NEW_HEADER_DIR="out/include"

cargo build --target aarch64-apple-ios --release
cargo run --bin uniffi-bindgen generate --library target/aarch64-apple-ios/release/lib${NAME}.a --language swift --out-dir out

What this code does is build the crate for iOS and generate the code we need. As a result of this, you should get a

  • Static library
  • Module map
  • Header file
  • Swift file with pregenerated call bindings

But how do we include this in XCode? We should use a framework, which removes the ambiguity about how code is bundled in the app and allows you to add multiple targets (eg mac) in the future.

To do this, let's append to the build script the following

mkdir -p "${NEW_HEADER_DIR}"
cp "${HEADERPATH}" "${NEW_HEADER_DIR}/"
cp "out/${NAME}FFI.modulemap" "${NEW_HEADER_DIR}/module.modulemap"

rm -rf "${OUTDIR}/${NAME}_framework.xcframework"

xcodebuild -create-xcframework \
    -library "${TARGETDIR}/aarch64-apple-ios/${RELDIR}/${STATIC_LIB_NAME}" \
    -headers "${NEW_HEADER_DIR}" \
    -output "${OUTDIR}/${NAME}_framework.xcframework"
    
rm -rf "${NEW_HEADER_DIR}"

What this does is generate a folder with all the components we just generated, except for the swift binding code, and generate a framework from that folder, insert it into our iOS project, and then remove that folder. Clean!

Run the script...

Warning

If you don't have a folder called out with 3 files, please run the command again. The double run is only needed this one time.

Got all that? Switch to XCode, and... Nothing yet? We need to tell XCode what we want to include! Add the framework by finding said framework within finder and clicking "open"

image

Also, we need to add the Swift file we just generated to the project.

image

XCode should automatically add it to the global namespace, and you can call all the functions, use all the types, and just generally have a swell time as if you wrote native swift code. Build, and watch the magic! Every time you update the rust code, just rerun build.sh

image

PS: Reducing size

[profile.release]
# 31mb -> 7.1mb
lto = "fat"
# these two strip off another 0.3mb
panic = "abort"
strip = true

PS: Additional Targets

To run in the simulator, you need another build, which is

cargo build --target <targetName> --release
cargo run --bin uniffi-bindgen generate --library target/<targetName>/release/lib${NAME}.a --language swift --out-dir out

# Add the following arguments:

-library "${TARGETDIR}/<targetName>/${RELDIR}/${STATIC_LIB_NAME}" \
-headers "${NEW_HEADER_DIR}" \

xcodebuild -create-xcframework \
    -library "${TARGETDIR}/aarch64-apple-ios/${RELDIR}/${STATIC_LIB_NAME}" \
    -headers "${NEW_HEADER_DIR}" \
    # Here
    -output "${OUTDIR}/${NAME}_framework.xcframework"

Targets particularaly relevant to me is:

  • iOS Sim on M1 Macs: aarch64-apple-darwin
  • M1 Mac: aarch64-apple-darwin
  • Mac Catalyst M1: aarch64-apple-ios-macabi Note: This does not work as of writing because Rust support for this is bad

If you don't have a mac I believe you s/aarch64/x86_64/ (with the exception of the native iOS target because that's native iOS)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment