DevelopmentCross-platform RustWritten by Emil Sjölander on Sun May 05 2019

Stretch is a cross-platform Flexbox engine written in Rust. At Visly we are building a design tool for front-end engineers and we needed to ensure components looked the same across many platforms. To ensure that a component has the same layout across all platforms we needed to build a cross-platform layout engine.

In this post, I’ll cover why we chose Rust as our cross-platform language, how we structure the project with multiple language bindings, and how we deal with testing those bindings (for more on testing see our previous post). You can also find details in previous posts on how to use rust on Android and iOS.

Why Rust?

Before building this layout engine we had to investigate the various alternatives for building cross-platform libraries. This mainly comes does to language choice and their support for Android and iOS. While there are probably hundreds of languages that make this possible we narrowed our choices down to the following.

  • C
  • C++
  • Rust
  • Go
  • Kotlin
  • JavaScript

I’d previously written a similar engine in C/C++ and didn’t feel like going through that again. They are both fine languages but lack of abstractions in C is tedious and C++ error messages tend to be longer than the codebase itself. JavaScript, Go, and Kotlin requires a large runtime which doesn’t make them ideal for a library that is supposed to be embedded anywhere. This really only leaves Rust and I had always wanted a good excuse to learn it. Rust also has fantastic support for cross-compilation so I was pretty confident it would meet all our demands.

Language bindings

For Stretch, we have built language bindings to Swift, Kotlin, and JavaScript. We also expect more binding to be developed over time, both by us and the community.

Like most languages, Rust has the ability to interop with other languages through a C based API. However, it’s sadly not as easy as building a single language binding for C and then use that to hook up every other language. The reason for this is that every language has its own patterns for binding to C code, often to emulate an object-oriented system or some other language-specific features not present in C.

For Kotlin/Java we have to write C bindings that match the JNIspecification for example. We use the fantastic jni crate for this. But as you can see, the code would never be reusable on any other platform.

#[no_mangle]
#[allow(non_snake_case)]
pub unsafe extern "C" fn Java_app_visly_stretch_Node_nIsDirty(
    _: JNIEnv,
    _: JObject,
    node: jlong,
) -> jboolean { ... }

The same goes for JavaScript where we use the incredible wasm-bindgen crate. This is a library that makes it trivial to build JavaScript bindings via WebAssembly for Rust. Due to restrictions on communication between JavaScript and WebAssembly we still can’t use all of Rust’s powerful language features, however, thanks to the power of wasm-bindgen we can actually use a lot more features than typical C bindings. This again makes the code unsuitable to reuse for other platforms.

#[wasm_bindgen(js_name = computeLayout)]
pub fn compute_layout(&self, size: &JsValue) -> Layout { ... }

The same goes for JavaScript where we use the incredible wasm-bindgen crate. This is a library that makes it trivial to build JavaScript bindings via WebAssembly for Rust. Due to restrictions on communication between JavaScript and WebAssembly we still can’t use all of Rust’s powerful language features, however, thanks to the power of wasm-bindgen we can actually use a lot more features than typical C bindings. This again makes the code unsuitable to reuse for other platforms.

#[no_mangle]
pub extern "C" fn stretch_node_compute_layout(
    node: *mut c_void, 
    width: f32, 
    height: f32, 
    create_layout: fn(*const f32) -> *mut c_void
) -> *mut c_void { ... }
private func create_layout(_ floats: UnsafePointer<Float>?) -> UnsafeMutableRawPointer? {
    let layout = Layout.fromFloats(floats!).1
    return Unmanaged.passRetained(layout).toOpaque()
}

class Node {
    public func computeLayout(thatFits size: Size<Float?>) -> Layout {
        let layoutPtr = stretch_node_compute_layout(
            rustptr, size.width ?? Float.nan,
            size.height ?? Float.nan,
            create_layout)
        let layout: Layout = Unmanaged.fromOpaque(layoutPtr!).takeUnretainedValue()
        return layout
    }
}

Project structure

There are various ways to structure a cross-platform project in Rust and I can’t tell you which way to go, but this is the way we chose to do its worked out pretty well. We chose to have a main Rust project with no cross-platform library references and then separate binding libraries which statically link the main Stretch library and provide the language interop layer.

[package]
name = "ios-stretch"
version = "0.1.0"
edition = "2018"

[dependencies]
stretch = "0.2.2"

[lib]
name = "stretch"
crate-type = ["staticlib"]

[workspace]
members = []

The other way to do it would be to include these interop layers as part of the main library and conditionally compile them using #![cfg(…)] markers. We had a couple of reasons for implementing each interop layer as a separate project was.

  1. We wanted anyone to be able to build a third party Stretch language binding. If the bindings were part of the core package they would make first-party bindings privileged to APIs third-party developers would not have access to.
  2. It enables us to easily configure projects differently with a different 

Cargo.toml for every binding. It’s possible to do this in a single project as well but much easier to accidentally add a configuration or dependency that applies to every platform when it shouldn’t. 3. It provides a very clear separation of concerns. We don’t expect that any contributors to Stretch be familiar with every platform we support so the more we can separate them the better for contributors.

Testing

One thing you may have noticed through all of this is that language bindings drop a lot (most) of the type and memory safety that comes with Rust. This means that even though the bindings themselves contain almost no logic we need to write extensive unit tests to make sure changes to the core of Stretch are still compatible with all the language bindings, sadly compiling isn’t enough as they involve a lot of unsafe runtime mapping between types.

We make use of CircleCI to ensure every commit to Stretch passes not only the main stretch tests but also make sure every language binding is still fully functioning. These tests don’t do much more than exercise the language bindings and check that it didn’t crash.

This setup ensures that we ourselves as well as the community of contributors can be certain we aren’t breaking any of these unsafe bindings when making changes to the core of Stretch. It also makes it much easier to contribute to Stretch as for most commits you don’t need to think about any of the various different language bindings that exists. You can just write your contribution and trust that the tests will let you know if there is something that needs changing in a specific language binding (usually there isn’t).

In conclusion

At Visly we are super happy with Stretch running across all the platforms we support using Rust with language bindings. However, as you can see the choice isn’t an obvious one as it requires quite a bit of work to get up and running. If you want to share code between iOS and Android I would definitely suggest having a look at Kotlin, however, if you are facing the same constraints as we were then I think Rust is a great choice!

Make sure to sign up to the Visly early access list to receive more information as we get closer to launch. Also follow Stretch on github as we get closer to a first stable release. All contributions are welcome!