How to develop Cross-Platform App SDK with Rust?
FeatureProbe is an open-source feature management service. We provide different kinds of programming languages for users, which include iOS SDK and Android SDK. In that way, how to build a Cross-platform SDK?
Why do we use Cross-Platform?
Lower development costs.
Two platforms share one set of code, easy to maintain in the future.
The popular cross-platform solutions
- C++
Many company's cross-platform mobile frameworks, like WeChat, Tencent Meeting, and earlier versions of Dropbox, leave traces of C++. Well-known open-source libraries like WeChat's Mars also have C++ roots. The benefit of this approach is that a single codebase can be adapted to multiple clients. However, it requires strong tool support for C++ and significant investment in hiring C++ developers. The ongoing cost of product maintenance should not be underestimated, particularly considering team dynamics.
- Rust + FFI
Rust and the corresponding platform FFI wrappers. Common methods like Lark and AppFlow adopt like RPC, exposing a limited number of interfaces for data transfer. The advantage is controllable complexity, but the drawback is the requirement for extensive serialization and deserialization. Moreover, expressing certain code structures, such as callback functions, may be challenging.
- Flutter
This is a solution that is more suitable for cross-platform full app development with UI functionality and may not be good for cross-platform mobile SDKs.
Why do I use Rust?
- Cost of development
If you don't consider the investment costs, native solutions have more advantages in terms of publishing, integration, and user debugging. But we don't have enough people to maintain two sets of SDKs, so we chose to use Rust to write the cross-platform solution.
- Experience
We have implemented a cross-platform networking stack using Rust, Utilizing high-quality crates such as Tokio and Quinn to build a long-lived connection between client and server.
- Security and Stability
As a feature management platform for gradual deployment, FeatureProbe also takes on the responsibility of feature degradation and places higher demands on the stability of SDKs.
Once multiple-thread crashes occur in a native mobile SDK, it becomes challenging to identify and investigate the issues, leading to longer turnaround times for bug fixes.
Rust code is inherently thread-safe, eliminating the need for highly experienced mobile developers to ensure the delivery of high-quality and stable SDKs.
Uniffi-rs
Uniffi-rs is developed by Mozilla and is a Rust component used in the Firefox mobile browser. It has the following advantages:
1、Safety
The first design goal of Uniffi-rs is "safety first." All methods generated by Rust and exposed to the calling language should not trigger undefined behavior.
All Rust object instances exposed to external languages are required to be Send + Sync.
- Simple
Users do not need to learn how to use FFI (Foreign Function Interface).
It only defines an interface abstraction in a DSL, and the framework generates implementations for corresponding platforms. Users don't need to worry about encapsulating cross-language calls.
- Well-documented
Thorough documentation and testing ensure reliability.
Adhering to idiomatic language style enables seamless integration.
Let’s start using Uinffi-rs
First, we clone the Uniffi-rs project to our local machine and open the "arithmetic" project in our IDE.
git clone https://github.com/mozilla/uniffi-rs.git
cd examples/arithmetic/src
Let's take a look at the compilation result of this line of code:
[Error]
enum ArithmeticError {
"IntegerOverflow",
};
namespace arithmetic {
[Throws=ArithmeticError]
u64 add(u64 a, u64 b);
};
In Arithmetic. udl, we can find an Error type and four methods: add, sub, div, and equal. The namespace is a language package name. Next, let's take a look at how the Rust section is implemented in lib.rs:
#[derive(Debug, thiserror::Error)]
pub enum ArithmeticError {
#[error("Integer overflow on an operation with {a} and {b}")]
IntegerOverflow { a: u64, b: u64 },
}
fn add(a: u64, b: u64) -> Result<u64> {
a.checked_add(b)
.ok_or(ArithmeticError::IntegerOverflow { a, b })
}
type Result<T, E = ArithmeticError> = std::result::Result<T, E>;
uniffi_macros::include_scaffolding!("arithmetic");
Let's take a look at the diagram of various files in uniffi-rs.
In the diagram, the 'Interface Definition File' represents the 'Arithmetic.udl' file, and the red-colored 'Rust Business Logic' at the bottom corresponds to the 'lib.rs' file. The platform-specific invocation files under the 'test/bindings/' directory correspond to the green-colored section at the top. However, we noticed that the files inside the blue box are missing. We found a line of code at the bottom of 'lib.rs': 'Uniffi_macros::include_scaffolding!("arithmetic");'. This code will include the generated code as a dependency during compilation. Now, let's proceed with executing the test cases to see the compiled output.":
cargo test
If it goes well, we will see:
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
In the 'uniffi-rs/uniffi_bindgen/src/bindings/kotlin/mod.rs' file, inside the 'run_script' method, add a line 'println!("{:?}", cmd);' before 'Ok(())'. After making this modification, run the tests again.
cargo test -- --nocapture
In the 'run_script' method, we can get the content of the executed command. After, we will be able to see the generated code in the 'uniffi-rs/target/debug' directory.
arithmetic.jar
arithmetic.py
arithmetic.rb
arithmetic.swift
arithmetic.swiftmodule
arithmeticFFI.h
arithmeticFFI.modulemap
The jar file is Kotlin, py is Python, rb is Ruby, and the remaining four are all Swift. These files are the platform-specific binding files located in the upper part of the diagram. Let's take the Swift code as an example and examine the add method inside.
public
func add(a: UInt64, b: UInt64)
throws
->
UInt64
{
return try FfiConverterUInt64.lift(
try rustCallWithError(FfiConverterTypeArithmeticError.self) {
arithmetic_77d6_add(
FfiConverterUInt64.lower(a),
FfiConverterUInt64.lower(b), $0)
}
)
}
We can see that the method being called here is arithmetic_77d6_add in the FFI. Let's remember this strange name. We've found that the Rust scaffolding files are still missing - they're actually hidden in the out folder under the /uniffi-rs/target/debug/build/uniffi-example-arithmetic prefix directory. Note that multiple compilations may result in multiple folders with the same prefix. Taking the add method as an example:
// Top level functions, corresponding to UDL `namespace` functions.
#[doc(hidden)]
#[no_mangle]
pub extern "C" fn r#arithmetic_77d6_add(
r#a: u64,
r#b: u64,
call_status: &mut uniffi::RustCallStatus
) -> u64 {
// If the provided function does not match the signature specified in the UDL
// then this attempt to call it will not compile, and will give guidance as to why.
uniffi::deps::log::debug!("arithmetic_77d6_add");
uniffi::call_with_result(call_status, || {
let _retval = r#add(
match<u64 as uniffi::FfiConverter>::try_lift(r#a) {
Ok(val) => val,
Err(err) => return Err(uniffi::lower_anyhow_error_or_panic::<FfiConverterTypeArithmeticError>(err, "a")),
},
match<u64 as uniffi::FfiConverter>::try_lift(r#b) {
Ok(val) => val,
Err(err) => return Err(uniffi::lower_anyhow_error_or_panic::<FfiConverterTypeArithmeticError>(err, "b")),
}).map_err(Into::into).map_err(<FfiConverterTypeArithmeticError as uniffi::FfiConverter>::lower)?;
Ok(<u64 as uniffi::FfiConverter>::lower(_retval))
})
}
Let me explain it. The extern "C" is a Rust syntax used for generating C language bindings. We finally understand how the peculiar add method name is generated: arithmetic_77d6_add is formed by concatenating the namespace with the code hash and the method name add. Moving on to call_status, it encapsulates the return value of the add method. The call_with_result method is defined in uniffi-rs/uniffi/src/ffi/rustcalls.rs and primarily sets up the panic hook to capture logs when Rust code encounters a crash. The core logic of arithmetic_77d6_add is defined as let _retval = r#add(a, b), with a and b wrapped in a match statement. Inside the match block, the lift and lower functions are primarily responsible for handling the conversion between Rust types and the C types used in the FFI. You can find more details about this process here. Now, we clearly understand all the modules in the diagram and the overall flow of Uniffi-rs.
How do we integrate Uniffi-rs into our project
So far, we have learned how to generate platform-specific code using Uniffi-rs and how to invoke it from the command line. However, we are still unsure about how to integrate it into a real Android or XCode project. The Uniffi-rs documentation includes sections on Gradle and Xcode integration, but even after reading it, we find it challenging to use.
In simple terms, there is a Rust project serving as the sole crate for generating binaries, with other components such as autofill, logins, and sync_manager as dependencies of this project. The goal is to consolidate the udl files into a single location and ultimately generate binding files and binaries in a unified manner. This approach offers the advantage of avoiding overhead from multiple Rust crates' intercommunication, resulting in a single binary file, making compilation, distribution, and integration easier.
Android: an AAR package is generated. The Mozilla team provides a Gradle plugin called org.mozilla.rust-android-gradle.rust-android that can be used for this purpose. The specific usage can be found on Mozilla's website.
IOS: it is an xcframework. The Mozilla team provides a script called build-xcframework.sh that can be used for this purpose. The specific usage can be found on Mozilla's website.
We only need to make appropriate modifications to create our cross-platform project.
We are using Uniffi-rs and Mozilla's project in our project may be more complex. I recommend that you use the mobile SDK to learn how to build your cross-platform components:
Rust-core: is a pure Rust crate.
Rust-uniffi: is the crate that generates bindings based on the udl and rust-core dependency.
Rust-android: is the Android project that generates the AAR package, specifically integrated via the Gradle plugin.
Rust-ios: is the Apple project that generates the cframework, integrated via the build-xcframework.sh script.
By studying this, I hope it can help you know how to create your cross-platform project with Rust.
About FeatureProbe
FeatureProbe is an open-source experimentation and A/B testing platform designed to help businesses and product teams make data-driven decisions to optimize their products and user experiences. It provides a suite of tools that enable organizations to run experiments, analyze results, and iterate on their offerings to drive growth and improve key metrics.
Follow us:
GitHub: https://github.com/FeatureProbe/FeatureProbe
Get started: https://featureprobe.io/
Documentation:https://docs.featureprobe.io
Subscribe to my newsletter
Read articles from Nancy directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by