Ferrostar: Building a Cross-Platform Navigation SDK in Rust (Part 1)

This is the first in a series of technical blog posts covering the joys and challenges of building a cross-platform shared library, with a focus on use in mobile applications.

As a motivating example to frame things, we'll follow the journey of building Ferrostar, a free and open-source navigation SDK. We want to offer a modern SDK that is cross-platform, vendor-neutral, and easily extensible both by direct contributors and developers using it in their apps.

Why?

Before we get into the technical weeds too much, it's worth taking a moment to ask why. At Stadia Maps, we have offered highly customizable routing since 2017, and support use cases including safe pedestrian routing through lit areas, golf cart routing that prefers multi-use paths, and more! But developers are on their own when it comes to offering a turn-by-turn navigation UI. Several open-source apps already offer navigation, but the solution is not generalized. Among open-source navigation SDKs, MapLibre Navigation Android being the most mature at the time of this writing. But as the name suggests, it is Android-specific. And since the internals originally came from old open-source Mapbox code, it takes considerable effort to use with other vendors, and extensibility was never a core design consideration.

Starting a new navigation SDK is certainly an ambitious project, but at the moment there is no navigation SDK that is high-quality, cross-platform, and open-source. We're here to change that, and enable developers to bring these routing innovations to their users.

This post series is about the nuts and bolts of how we plan to pull it off. We'll start with an overview of ways to share common logic across platforms, explain the architecture we settled on, and finish with a deep dive into the first technical topic: binding generation with UniFFI.

How to Share Common Logic?

We want to target multiple platforms: iOS and Android for starters. Certain code is the same regardless of where you're running it. Algorithms like detecting if the user has strayed off the route and calculating the distance to the next turn should be written once and shared across platforms. And at an even higher level, the broad "business logic" of a turn-by-turn navigation experience should be common shared code.

Broadly speaking, there are two common approaches for sharing code across platforms in mobile apps today. First, there are cross-platform app development frameworks like Flutter, React Native, or Kotlin Multiplatform. They promise the ability to write your business logic and UI once and run it anywhere. The second approach is to put your business logic in a shared library. You write your code in a reasonably portable language (usually C++) and link this with your (platform-specific) applications.

In general, the cross-platform frameworks are optimized for building applications, not libraries. We want to ensure that we can support additional platforms with relative ease. You can't just pick up React Native, for example, and run it on a bicycle computer. This is situation is improving though. For example, Flutter now runs on certain embedded devices, and Kotlin Multiplatform has a quickly improving support for library targets. However, these are still very early stage developments, and they involve a certain amount of ecosystem lock-in.

Given our requirements, we opted for the shared library approach. Historically, C++ has been a popular choice for the task. It is portable across a wide range of platforms, can be made to interoperate with almost any other language, and usually produces small, well-optimized binaries. We preferred to use something else for a few reasons, the most important being that it is difficult to write safe and correct C++ code, especially as a new contributor.

At Stadia Maps, we have been shipping Rust in production since 2018. Our investment has rewarded us with consistently good performance (Rust is keen on zero-cost abstractions), memory safety, and excellent maintainability. And Rust code tends to have fewer bugs as well! Rust also has excellent interoperability with other languages via a straightforward FFI using the platform's C ABI, in stark contrast to C++ which can be rather complex to interface with. Rust also has a large and growing list of supported platforms, including iOS and Android on several architectures.

We aren't alone in our selection of Rust either. We've seen a lot of exciting development in Rust mobile frameworks like Crux, Dioxus, and Rinf over the past year. But it's not just new experimental projects that are adopting Rust. We know that both Mozilla and Lyft are using Rust shared libraries in their mobile apps. Rust is already being used in production mobile apps today.

Architecture Overview

Zooming out, let's look at the broader architecture we adopted. The core is the place where we want all shared logic to live. In the context of our navigation SDK, this includes parsing API responses, figuring out when to advance navigation to the next step, calculating the distance to the next turn, and so on.

Shared logic in a "functional core" isn't new, but we can go one step further and define our data models too! This lets us keep the navigation logic vendor- and platform-agnostic. For example, we define a common location update type with fields like coordinate, timestamp, and heading. This way the core logic doesn't need to care about the differences between CLLocation and android.location.Location.

We're building a Rust library that's callable via the C ABI. But if we want any mobile devs to even try our framework, we'll need to expose a nicer Swift/Kotlin interface. These are called bindings, and they provide a thin layer that hides the messy details of going back and forth between Rust and the "native" mobile code.

Finally, we have the native mobile library. This is the only thing that most app developers will see, and it handles communications with the "outside world" (ex: internet and GPS), and provides a higher level API that can be a bit more "opinionated." We won't say much more about this layer in the rest of our discussions, as many volumes have been written on good API and library design.

Ferrostar Architecture Diagram

Bindings: Bridging Rust and Platform Code

If you're familiar with the concept of foreign function interfaces (FFIs), you may remember extern declarations, calling convention specifications, and having to write a lot of boilerplate. But you know what is really good at generating boilerplate? Computers! Enter: binding generators.

Mozilla has developed a fantastic tool for binding generation: UniFFI. In their case, the motivation was to build pieces of Firefox which could be shared across platforms. UniFFI is designed to generate safe bindings that feel idiomatic to users of the target language. The design principles of UniFFI aligned well with our requirements for Ferrostar, and we think they tend to be a good fit for most similar mobile use cases.

Cargo workspace setup

Let's dive in with discussing the cargo workspace setup for Ferrostar. This should be a helpful template for anyone wanting to create their own cross-platform library.

We chose to structure the Rust portion of our library as a workspace with two member projects: uniffi-bindgen and ferrostar. At the time of this writing, the ability to run the binary from the uniffi crate is only available in nightly, so we opted for a second uniffi-bindgen crate in our workspace for the CLI binary. Here's what it looks like on disk:

├── Cargo.lock
├── Cargo.toml
├── ferrostar
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
└── uniffi-bindgen
    ├── Cargo.toml
    └── src
        └── main.rs

Scaffolding in place, we need to add UniFFI to our workspace's Cargo.toml. This way we can keep the binding generator and library versions in sync. At the time this article was originally published, proc macro features were still under active development. Since then, they have stabilized somewhat, and it might make sense to start using the crates.io releases. If you want the newest features and fixes though, it's best to use git dependencies. Tracking the latest commits can cause pain, but it is pretty rewarding if you want access to the latest improvements, which do come quite fast.

[workspace]

members = [
    "uniffi-bindgen",
    "ferrostar",
]
resolver = "2"

[workspace.dependencies]
uniffi = "0.26.1"

Then, we can add it to the uniffi-bindgen and ferrostar crates respectively. In the uniffi-bindgen crate, we need the cli feature.

[dependencies]
uniffi = { workspace = true, features = ["cli"] }

The main function in uniffi-bindgen/src/main.rs is a single line. Here's the entire file:

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

In the ferrostar crate, we also need uniffi as a normal dependency and build dependency. We also need to configure the library target with a few different crate_types.

  • cdylib - A dynamic system library. Used on most platforms.
  • staticlib - A system library with all upstream dependencies included. Required to target iOS.
  • lib - A Rust library. This isn't mentioned in the UniFFI documentation as of this writing, but if you want to add integration tests (runnable via cargo test) and you don't include a Rust library target, you'll get strange errors.
[dependencies]
uniffi.workspace = true

[build-dependencies]
uniffi = { workspace = true, features = ["build"] }

[lib]
crate-type = ["cdylib", "staticlib", "lib"]

Defining what to export

Internally, UniFFI uses an IDL (Interface Definition Language) to describe the public interface of your crate to foreign code (ex: Swift and Kotlin). This is similar to the process of writing a header file in C in that it is a set of definitions, but the definition file is used to generate the foreign language bindings.

This used to require writing a bunch of boilerplate by hand. Fortunately, UniFFI (starting from v0.25.0) automates this using procedural macros. For our use case, we were able to replace all handwritten UDL with macros, and expect this will be the best path for most library authors going forward.

Functions

Let's get started by exporting a function from Rust which generates a parser for routing data coming from our APIs. A single macro, uniffi:export is all we need to export a top-level function.

#[uniffi::export]
fn create_osrm_response_parser(polyline_precision: u32) -> Arc<dyn RouteResponseParser> {
    Arc::new(OsrmResponseParser::new(polyline_precision))
}

UniFFI macros generate the definitions for us automatically based on the function's type signature. Many built-in types like numbers, String, and bool "just work" as you expect. Collections like Vec<T>, Option<T> and HashMap<K, V> are also implemented for all types that UniFFI knows how to represent over the FFI. They even have special cases for types like Vec<u8> which idiomatically map to a language-specific byte sequence type (ex: Data in Swift)!

If anything you export references a type that UniFFI doesn't know how to convert, you will get a compilation error. So, how do we tell UniFFI about new types?

Data models

Let's look at two data models: user locations and route requests. We'll export these using derive macros. If you've used the serde crate, this should feel familiar.

User location is just a struct with some properties like coordinates and course over ground. The uniffi::Record macro exposes our Rust types idiomatically. They will show up as structs in Swift, and data classes in Kotlin.

#[derive(uniffi::Record)]
pub struct UserLocation {
    pub coordinates: GeographicCoordinates,
    pub horizontal_accuracy: f64,
    pub course_over_ground: Option<CourseOverGround>,
    pub timestamp: SystemTime,
}

Route requests are a little different. They don't really map to a record type. Since we want to build an extensible framework, we need to account for multiple ways of getting a route. For example, making an HTTP request, or computing a route on-device.

This maps well to an enumeration. The uniffi::Enum derive macro exports these like you would expect.

#[derive(uniffi::Enum)]
pub enum RouteRequest {
    HttpPost {
        url: String,
        headers: HashMap<String, String>,
        body: Vec<u8>,
    },
    // ...
}

Objects

You can also export more complex objects with local state, methods, and constructors!

One example of this in Ferrostar is our route adapter. It stores a request generator and response parser and exposes a generic interface so the caller doesn't need to know about the details.

#[derive(uniffi::Object)]
pub struct RouteAdapter {
    request_generator: Arc<dyn RouteRequestGenerator>,
    response_parser: Arc<dyn RouteResponseParser>,
}

#[uniffi::export]
impl RouteAdapter {
    #[uniffi::constructor]
    pub fn new(
        request_generator: Arc<dyn RouteRequestGenerator>,
        response_parser: Arc<dyn RouteResponseParser>,
    ) -> Self {
        Self {
            request_generator,
            response_parser,
        }
    }

    #[uniffi::constructor]
    pub fn new_valhalla_http(endpoint_url: String, profile: String) -> Arc<Self> {
        let request_generator = create_valhalla_request_generator(endpoint_url, profile);
        let response_parser = create_osrm_response_parser(6);
        Self::new(request_generator, response_parser)
    }

    pub fn generate_request(
        &self,
        user_location: UserLocation,
        waypoints: Vec<GeographicCoordinates>,
    ) -> Result<RouteRequest, RoutingRequestGenerationError> {
        self.request_generator
            .generate_request(user_location, waypoints)
    }

    pub fn parse_response(
        &self,
        response: Vec<u8>,
    ) -> Result<Vec<Route>, RoutingResponseParseError> {
        self.response_parser.parse_response(response)
    }
}

This example is a bit more complicated than the others we've looked at so far. First, we use the uniffi::Object derive macro on the struct. In contrast to uniffi::Record, this signals that the type will be passed by reference and may have methods. This will be exposed in Swift or Kotlin as a class.

You must annotate your constructors with the uniffi::constructor macro. At the time of this writing, constructors must return an Arc<Self>. The new constructor is special, and is exposed most naturally, but other named constructors are also supported if you mark them as such. They are not currently as idiomatic as the default constructor, but I expect things like exposing a convenience init to Swift will be possible in the future.

Finally, types exported with uniffi::Object tell the binding generator to emit a protocol (Swift) or interface (Kotlin) definition. Pro tip: This makes it easier for you to write mock implementations for unit testing! Our rule of thumb is to pass the protocol/interface to your native methods, and define your instance variables with protocol/interface types. When the time comes to construct one, your business logic will dictate which concrete implementation to construct. This makes your code significantly more extensible, and allows you to either use implementations from the Rust core or define your own in Swift/Kotlin.

Errors

Result types in Rust signal errors, and UniFFI can expose these idiomatically in the generated bindings. We use this pattern in the above example to signal response parsing errors.

#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum RoutingResponseParseError {
    #[error("Failed to parse route response: {error}.")]
    ParseError { error: String },
    // ...
    #[error("An unknown error parsing a response was raised in foreign code.")]
    UnknownError,
}

The uniffi::Error derive macro will work with any type conforming to std::Error. We make extensive use of thiserror for simplicity. In Swift and Kotlin code, this definition will be bridged into an enum conforming to Error and a sealed subclass of Exception respectively.

Besides exposing the error types themselves, UniFFI also makes the error handling idiomatic. Let's return to the response parsing function signature.

pub fn parse_response(
    &self,
    response: Vec<u8>,
) -> Result<Vec<Route>, RoutingResponseParseError>

Semantically speaking, this function will either return an ordered list of routes, or it will generate an error of type RoutingResponseParseError. As such, this is how the generated Swift bindings will look:

public func parseResponse(response: Data) throws -> [Route]

Swift does not explicitly list error types, but uses the throws keyword. If you inspect the generated code, you can see that UniFFI does indeed handle error variants, mapping them to your error types.

Traits

Carrying on the motivating use case of vendor-agnostic backends, we define traits in our crate for response parsing and similar things that we want to ensure remain generic.

#[uniffi::export(with_foreign)]
pub trait RouteResponseParser: Send + Sync {
    /// Parses a raw response from the routing backend into a route.
    fn parse_response(&self, response: Vec<u8>) -> Result<Vec<Route>, RoutingResponseParseError>;
}

Traits can be exported with the uniffi::export macro, and will show up as protocols/interfaces in your foreign code. Even better, if you use with_foreign, you can supply foreign code to Rust! It requires a bit of a dance Rust-side (you must use types of the form Arc<dyn Trait> at the time of this writing), but that's a small price to pay for such flexibility.

By making things like RouteRequestGenerator and RouteResponseParser traits, we make it possible for anyone to do the following:

  1. Connect to a custom routing server (for example, one they are self-hosting).
  2. Parse routes in a different format.
  3. Implement device-local route generation in Swift, Kotlin, or Rust.

We're really excited about how this will enable more innovation by developers, since Ferrostar will be easily adaptable to custom routing backends, both for research and commercial use.

What works well?

The APIs UniFFI generates truly feel idiomatic! It's hard to overstate how nice this is. It also goes out of its way to generate protocols/interfaces, which make it easy to mock parts for unit testing in native code, and even has pleasant surprises like Vec<u8> mapping to idiomatic byte sequence types.

UniFFI encourages good hygiene all the way from Rust to your application code via type safety. Any type that appears in your signature must be exported, and Result error variants are translated into appropriate errors/exceptions.

Finally, UniFFI makes it easy to use native code implementations of a protocol/interface with your Rust core! This lets us do things like dependency injection in a type-safe manner across an FFI boundary.

What are the rough edges?

When I gave a talk about this three months ago at the Seoul Rust meetup, and I had an entire slide full of challenges. Almost all of them were related to UDL, and almost are non-issues when you replace UDL generation with proc macros! A few remain though.

First, you'll need to change your habits around struct mutation. Since we are introducing foreign code, UniFFI must assume that objects may be mutated from other threads. So, you can't have mutable references to self in UniFFI interfaces and must rely on interior mutability patterns instead. Importantly, your storage needs to be both Send and Sync, which means you'll need to turn to things like atomics and mutexes.

UniFFI is also evolving quite rapidly. While some high-profile crates are fairly stable pre-1.0, you can definitely expect some turbulence with UniFFI. The changelogs are normally quite good, but if you're tracking main you can expect breaking changes. Fortunately, the team are super responsive on GitHub. They're working on it every day, and are extremely responsive as maintainers (we submitted a PR to improve some docs around proc macros, and it was merged after a round of discussions in less than 24 hours).

The process of generating bindings for Swift and Kotlin, integrating this into your build tooling, and packaging everything into a usable SPM / Maven package is also quite complex. In fact, we would even suggest that the best practices around this are still being explored. This is a complex topic, and this post is already quite long, so we'll save it for the next article.

Wrap-up

In summary, while it is still a developing field, we couldn't be more excited about the future of Rust on mobile. As we've shown in this post, a shared core is not just possible; it's actually pretty straightforward and the developer experience is great thanks to UniFFI. We hope the best practices and gotchas we discovered during our journey so far are helpful to others building Rust libraries for mobile.

Stay tuned for the next post, where we'll dive deeper into build processes and packaging. You can follow us on social media, join our Slack or Discord communities, or subscribe to our mailing list (no spam) to get the news first.

Finally, if you're excited to bring the latest innovations in routing to more users, check out Ferrostar on GitHub. We have an issue tracker and have even marked some good first issues. (We'd especially love to have someone who is excited about Jetpack Compose.) Let's build the future of mobile navigation together!