Oscar Franco

Rust modules in React Native

May 2024

This is a tutorial on how I integrate Rust modules, but in the video form I go over the concepts that actually make this work, so you can adjust and understand the tooling behind and you can maintain your integration.

Basic Setup

  • Set up Rust compiler on your computer, just follow the instructions on the Rust website (use rustup, brew will give you headaches).
  • In order to compile the Android version you are going to use the ndk create which simplifies the command to compile the rust library for Android. Install it via cargo install ndk
  • Create a crate where we will put all of our Rust lib code and infra scripts. In my case I will call it my_sdk

    cargo new my_sdk
    
  • Create a rust-toolchain.toml in the project folder you just created. This will add all the necessary architectures to compile your project:

    [toolchain]
    channel = "stable"
    targets = ["x86_64-apple-ios", "aarch64-apple-ios", "aarch64-apple-ios-sim", "aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android", "i686-linux-android"]
    
  • Change name of main.rs to lib.rs
  • Add your API code on lib.rs

    #[no_mangle]
    extern "C" fn sum(a: i32, b: i32) {
      a + b
    }
    
  • We will use a crate called cbindgen that will help us generate a C header for our Rust functions. We will automate the header creation by creating a build.rs that runs every time our project is compiled/checked. First we are going to add the dependency as a [build-dependencies], the project Cargo.toml:

    [build-dependencies]
    cbindgen = "0.26.0"
    
  • Then on the root of the project create a build.rs file:

    extern crate cbindgen;
    
    use std::env;
    
    fn generate_c_headers() {
        let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
    
        cbindgen::Builder::new()
            .with_crate(crate_dir)
            .with_language(cbindgen::Language::C)
            .with_include_guard("my_sdk_h")
            .with_autogen_warning(
                "/* Warning, this file is autogenerated by cbindgen. Don't modify this manually. */",
            )
            .with_namespace("my_sdk")
            .with_cpp_compat(true)
            .generate()
            .expect("Unable to generate bindings")
            .write_to_file("generated/include/my_sdk.h");
    }
    
    fn main() {
        // Tell Cargo that if the given file changes, to rerun this build script.
        println!("cargo:rerun-if-changed=src/lib.rs");
        generate_c_headers();
    }
    
  • Modify the cargo.toml to compile as static library. You can also create a dynamic library that can be loaded on runtime on Android, but both should work. The jni dependency is only necessary if you are planing to call your code from Java/Kotlin.

    [package]
    name = "SDK"
    version = "0.1.0"
    edition = "2021"
    
    [lib]
    name = "SDK"
    crate-type = ["staticlib"]
    
    [dependencies]
    libc = "0.2.80" # Allows to use c types CString, c_char, etc.
    jni = "0.17.0" # OPTIONAL Allows to write JNI bindings directly from Rust
    
    [build-dependencies]
    cbindgen = "0.26.0"
    
  • (Optional) In my experience static binaries on iOS are OK, but on Android they can be huge. Ideally you would specify crate-type = ['staticlib', 'dylib'] and just be on your merry way, however, it seems this bloats the static lib massively. In order to get a static binary for iOS and a dynamic one for Android you can set crate-type = ['dylib'] and change the compilation command for iOS to cargo rustc --crate-type=staticlib ...

iOS

  • We are going to use Make to compile and package the library. No specific reason for it you can create your script on JS too.

    ARCHS_IOS = x86_64-apple-ios aarch64-apple-ios aarch64-apple-ios-sim
    ARCHS_ANDROID = aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
    LIB = libmy_sdk.a
    DYLIB = libmy_sdk.so
    XCFRAMEWORK = my_sdk.xcframework
    
    all: ios android
    
    ios: $(XCFRAMEWORK)
    
    android: $(ARCHS_ANDROID)
      # After build is done copy files into the android folder
      mkdir -p ../android/app/src/main/jniLibs
      mkdir -p ../android/app/src/main/jniLibs/x86
      mkdir -p ../android/app/src/main/jniLibs/arm64-v8a
      mkdir -p ../android/app/src/main/jniLibs/armeabi-v7a
      mkdir -p ../android/app/src/main/jniLibs/x86_64
    
    
      cp ./target/i686-linux-android/release/$(DYLIB) ../android/app/src/main/jniLibs/x86/$(DYLIB)
      cp ./target/aarch64-linux-android/release/$(DYLIB) ../android/app/src/main/jniLibs/arm64-v8a/$(DYLIB)
      cp ./target/arm-linux-androideabi/release/$(DYLIB) ../android/app/src/main/jniLibs/armeabi-v7a/$(DYLIB)
      cp ./target/x86_64-linux-android/release/$(DYLIB) ../android/app/src/main/jniLibs/x86_64/$(DYLIB)
    
    .PHONY: $(ARCHS_IOS)
    $(ARCHS_IOS): %:
      cargo build --target $@ --release
    
    .PHONY: $(ARCHS_ANDROID)
    $(ARCHS_ANDROID): %:
      cargo ndk --target $@ --platform 31 --release
    
    $(XCFRAMEWORK): $(ARCHS_IOS)
      mkdir -p simulator_fat
      lipo -create target/x86_64-apple-ios/release/$(LIB) target/aarch64-apple-ios-sim/release/$(LIB) -output simulator_fat/$(LIB)
      xcodebuild -create-xcframework -library target/aarch64-apple-ios/release/$(LIB) -headers include -library simulator_fat/$(LIB) -headers include -output $@
      cp -r $@ ../ios/$@
    

    You see on iOS we are creating a xcframework, that is because the architectures conflict (iOS and iOS sim m1), so we use a xcframework to package it nicely for Xcode to build our app.

  • The copy-ios.sh is just a simple scripts that copies the generated xcframework to a more convenient location. You can leave it out if you modify the locations manually.
  • Add generated .xcframework to Xcode
    • If you are doing this on a single project then dragging and dropping is the easiest, just make sure in the project properties mark the xcframework as embed and sign.
    • If you are doing this on React Native, as part of a library, then you need to modify your podspec. Just drop the xcframework somewhere and then on your podspec add s.vendored_frameworks = "my_sdk.xcframework"
  • You should now be able to simply import the header file (#include "my_sdk.h") and call any Rust function from any Obj-C++ file

Binary size

As mentioned in a previous point, the sizes of compiled Rust binaries can be quite large. Which is a problem when targeting mobile platforms. You need to turn on optimizations to get the binary size down, check out the size optimization guide.

Android

  • The ndk crate simplifies the generation of Android Rust modules massively. You need to have the variables set up properly though. Make sure you have the Android NDK properly installed in your system. Then set the following environment variables in your system. Change the NDK version to whatever you have installed or you need:

    export ANDROID_SDK_ROOT=$HOME/Library/Android/sdk
    export ANDROID_HOME=$HOME/Library/Android/sdk
    export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.1.8937393
    
  • We need to tell cmake to link the library when compiling our native module, on the CMakeLists.txt file add the following:

    make_path(SET MY_SDK_LIB ${CMAKE_CURRENT_SOURCE_DIR}/jniLibs/${ANDROID_ABI}/libmy_sdk.a NORMALIZE)
    add_library(my_sdk STATIC IMPORTED)
    set_target_properties(my_sdk PROPERTIES IMPORTED_LOCATION ${MY_SDK_LIB})
    
    target_link_libraries(tm
            jsi
            my_sdk
            react_nativemodule_core
            react_codegen_AppSpecs
    )
    
  • We will still not be able to call our Rust code from Java, because we need to go through the JNI and the JNI is very picky regarding names, we need to create specific binding for Android, on the lib.rs and the following block
  • We can finally call make android and the library will be created for us
  • Optional If you want to call the functions from Java/Kotlin (and not from C++) you need to create another binding using Android’s JNI:

    // On Android function names need to follow the JNI convention
    pub mod android {
      extern crate jni;
    
      use self::jni::JNIEnv;
      use self::jni::objects::JClass;
      use self::jni::sys::jstring;
    
      #[no_mangle]
      pub unsafe extern fn Java_com_samplesdk_BindingsModule_helloWorld(env: JNIEnv, _: JClass) -> jstring {
        let output = env.new_string("Hello from Rust!").expect("Couldn't create java string!");
        output.into_inner()
      }
    }
    
  • We can now create a RN Module (or JSI module) and simply load the library and call it (via JNI of course)

    package com.samplesdk;
    
    import com.facebook.react.bridge.NativeModule;
    import com.facebook.react.bridge.ReactApplicationContext;
    import com.facebook.react.bridge.ReactContext;
    import com.facebook.react.bridge.ReactContextBaseJavaModule;
    import com.facebook.react.bridge.ReactMethod;
    import com.facebook.react.util.RNLog;
    import java.util.Map;
    import java.util.HashMap;
    
    public class BindingsModule extends ReactContextBaseJavaModule {
    
    
        BindingsModule(ReactApplicationContext context) {
            super(context);
            // If you are using a Android dylib, you will have to load it now!
        }
    
        @Override
        public String getName() {
            return "Bindings";
        }
    
        @ReactMethod
        public void init(String apiKey) {
            RNLog.w(this.getReactApplicationContext(), "BindingsModule.init() called with apiKey: " + apiKey + "calling rust");
            String result = helloWorld();
            RNLog.w(this.getReactApplicationContext(), "Rust says: " + result);
        }
    
        private static native String helloWorld();
    }