LY Corporation Tech Blog

We are promoting the technology and development culture that supports the services of LY Corporation and LY Corporation Group (LINE Plus, LINE Taiwan and LINE Vietnam).

This post is also available in the following languages. Japanese

Diamond in the Rust: Sharing code across mobile platforms

Hi there. I'm Zhihao Zang, an iOS engineer on the LINE App OA Dev team. We are the team behind the Official Account functionalities in the LINE app, as well as the LINE Official Account app. Today, I want to talk about sharing code across platforms.

Sharing code across platforms has been a hot topic in the software development community for some time. But recently, it has gained even more traction in the mobile development realm. Though older technologies like Xamarin have not been in recent news, there have consistently been talks accepted by iOSDC and DroidKaigi that are discussing Kotlin Multiplatform. Talks about technologies like Flutter and React Native are also not uncommon. However, today I want to talk about something else: today, I want to talk about Rust.

Rust isn't an obvious choice for mobile development. You might expect to see engineers wearing a rusty bike chainrings logo shirt working on browser engines, back-end software, or everyone's favorite desktop operating system, Linux. In fact, Rust is often being compared to (and benchmarked against) languages like C and Go. However, if you think about it, there is no real reason why Rust cannot be used for mobile development. It's performant, efficient, and safe—traits for a technology to run on a handheld, resource-constrained mobile device. Also, Android and iOS both play well with C libraries, with which Rust can easily interface. So, why aren't there more people talking about and adopting Rust in cross-platform mobile development? Today I will try to answer this question.

The discussion will be in four parts:

  • The pipeline to integrate Rust into your mobile project
  • How performant Rust is on mobile platforms
  • What kind of features Rust can handle
  • Developer experience

I will be writing from an iOS developer's perspective, but I'll cover Android details when I can.

Pipeline

To call Rust code from Kotlin and Swift, we need to expose the interface of the Rust code. It can be done by manual bridging, or with UniFFI.

Manual bridging

Rust can interoperate with C code through a Foreign Function Interface (FFI). And Swift code can call C functions using bridging headers. So to make our Rust code play with our Swift project, we can expose the Rust functions as C functions, and then create a bridging header in our Swift project to call the C functions. The same thing can be achieved in Kotlin by using Java Native Interface (JNI) and Native Development Kit (NDK).

Manual bridging flow

This works in theory. But in practice, there are probably enough reasons for you to try to avoid this path. First, this is a very involved process. You need to write a lot of boilerplate code to bridge the gaps, including the C code to wrap Rust functions, the bridging headers for Swift, and the JNI interfaces for Kotlin. You can, of course, write scripts to automate this process, but to that end, let's meet the next option first.

UniFFI - A multi-language bindings generator for Rust

UniFFI is Mozilla's effort to make Rust more portable and interoperable. In its GitHub repository it says "you can use UniFFI to help you:

  • Compile your Rust code into a shared library for use on different target platforms.
  • Generate bindings to load and use the library from different target languages."

UniFFI bridging flow

The way UniFFI works is both simple and fascinating. I recommend reading its manual, which will certainly provide more value than what I'm trying to write here. But the following is a brief overview on the technique they call "Lifting, Lowering, and Serialization".

…UniFFI interoperates between different languages by defining a C-style FFI layer which operates in terms of primitive data types and plain functions. To transfer data from one side of this layer to the other, the sending side "lowers" the data from a language-specific data type into one of the primitive types supported by the FFI-layer functions, and the receiving side "lifts" that primitive type into its own language-specific data type.

With UniFFI, you can simplify the pipeline by marking the functions you want to expose in Rust with a macro (#[uniffi::export]), and UniFFI will take care of the rest and generate the bindings. After compiling the Rust code into a shared library, you can even make a XCFramework for your Swift project.

Performance

Rust is known for its performance, but how would it fare on a mobile platform? To evaluate real-world performance, I created a simple sample to benchmark the performance of Rust code called from Swift against the performance of pure native Swift code. The following code encrypts a given string repeatedly:

uniffi::setup_scaffolding!();

extern crate aes;
extern crate block_modes;
extern crate block_padding;
extern crate hex;

use aes::Aes128;
use block_modes::{BlockMode, Cbc};
use block_modes::block_padding::Pkcs7;
use std::time::Instant;

type Aes128Cbc = Cbc<Aes128, Pkcs7>;

#[uniffi::export]
fn encrypt_multiple_iterations(data: String, key: Vec<u8>, iv: Vec<u8>, iterations: u32) -> String {
    let data_bytes = data.as_bytes();
    let mut buffer = vec![0u8; data_bytes.len() + 16]; // AES block size is 16 bytes

    let start_time = Instant::now();
    for _ in 0..iterations {
        let cipher = Aes128Cbc::new_from_slices(&key, &iv).unwrap();
        let _ = cipher.encrypt(&mut buffer, data_bytes.len()).unwrap();
    }
    let duration = start_time.elapsed().as_nanos();
    duration.to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use hex::decode;

    #[test]
    fn test_encryption_iterations() {
        let key = decode("000102030405060708090a0b0c0d0e0f").unwrap();
        let iv = decode("0f0e0d0c0b0a09080706050403020100").unwrap();
        let data = "Benchmarking AES encryption in Rust".to_string();
        let iterations = 1000;

        let total_time = encrypt_multiple_iterations(data, key, iv, iterations);
        println!("Total time for {} iterations: {} ns", iterations, total_time);
    }
}

Here is the Swift counterpart utilizing CryptoKit:

import CryptoKit
import Foundation

enum EncryptionError: Error {
    case invalidKeyLength
    case invalidIVLength
    case encryptionFailed
}

// Swift implementation of the encryption function using CryptoKit
func timeToEncrypt(
    string: String,
    key: Data,
    iv: Data,
    for iterations: UInt32,
    countUpdateHandler: @escaping (UInt32) -> Void
) -> Result<String, EncryptionError>  {
    let symmetricKey = SymmetricKey(data: key)
    guard symmetricKey.bitCount == 128 else {
        return .failure(.invalidKeyLength)
    }
    guard iv.count == 16 else {
        return .failure(.invalidIVLength)
    }

    let dataToEncrypt = Data(string.utf8)
    var encryptedData = Data()

    let startTime = CFAbsoluteTimeGetCurrent()
    for iteration in 0..<iterations {
        countUpdateHandler(iteration)
        do {
            let sealedBox = try AES.GCM.seal(dataToEncrypt, using: symmetricKey, nonce: AES.GCM.Nonce(data: iv))
            encryptedData = sealedBox.ciphertext + sealedBox.tag
        } catch {
            return .failure(.encryptionFailed)
        }
    }
    let duration = CFAbsoluteTimeGetCurrent() - startTime
    assert(hexString(from: encryptedData) == expectedResult, "Encryption failed")
    return .success(String(format: "%.6f", duration))
}

I set up the benchmark with the following parameters: encrypting the string "Benchmarking AES encryption in Rust" with a 128-bit key and a 128-bit IV, and repeating the encryption 10,000,000 times.

let key = Data([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f])
let iv = Data([0x0f, 0x0e, 0x0d, 0x0c, 0x0b, 0x0a, 0x09, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, 0x00])
let data = "Benchmarking AES encryption in Rust"
let iterations: UInt32 = 10_000_000

Here is the result on an iPhone 11 Pro running iOS 17:

Benchmark result

The Rust code took around 15 seconds to complete the encryption, while the Swift code took about 22 seconds. During the benchmark, for both implementations, the CPU usage was consistently at 100% until the end, and the memory usage was not significant.

Note that the demo here isn't comprehensive or perfect. For example, if we try to utilize platform-specific hardware acceleration in the Rust implementation, to use CommonCrypto instead of CryptoKit in the Swift implementation, the result can be dramatically different. But it should give you a rough idea of the performance difference between Rust and Swift.

Binary size

For the test, I compiled the Rust code using cargo build --release --target=aarch64-apple-ios, and the resulting library was a whopping 25 MB static library. Without the Rust library, the whole demo app, compiled for release, was only 130 KB.

Feature set

In this section we will discuss whether Rust can handle the features we need to write an app. Note that, for example, when we talk about writing UI, we mean write it in Rust, instead of writing in the native framework directly.

UI

To implement UI in Rust, we basically have three options:

  1. Web technology
  2. Draw directly using lower-level API (Vulkan / Metal)
  3. Bridge native UI API

Neither options 1 nor 2 can provide a consistent experience for the users who are used to the native Android/iOS experience. They won't feel native, or familiar. So that leaves us option 3.

However, the state of Rust native UI API bridge is, to put it mildly, experimental. The following is the situation of the most prominent solution for Apple platform: Cacao

Cacao: supported feature

As you can see, a good coverage of the AppKit API as it might have, it isn't nearly as complete on iOS.

Networking

Here is the problem: modern mobile network stack is complicated. Beyond making requests, it has to handle Wi-Fi/cellular switching, multipath, VPN, low-data mode, expensive network (and related background fetch management), just to name a few. And like that's not enough, it also needs to take radio power management into consideration. It's better to, as stated in the (albeit outdated) Apple documentation, "Avoid POSIX Sockets and CFSocket on iOS Where Possible".

The same goes for I/O and any other platform-specific features.

Developer experience

Rust, though not extremely dissimilar to other modern languages like Swift or Kotlin, definitely has a learning curve. The borrow checker, lifetimes, and the ownership system are all quite unique. But the good news is that there are plenty of resources available to help you learn Rust. And once you get the hang of it, you will find yourself being empowered by a world of well-maintained libraries and tools.

In practice, integrating Rust into a native mobile project is also not too bad with UniFFI. But I do want to draw your attention to binary size again. To integrate Rust in your existing mobile project, it will almost certainly significantly increase the total binary size. This overhead might not be easy to overlook.

Feature support turnaround time

One thing to worry about is that to write code against a third-party technology instead of directly against the native platforms, whenever a new feature is introduced in the native platform, you are basically at the mercy of the maintainers of the bridge library, or you will have to get ready to write the bridge code (possibly under a tight deadline) yourself. In the case of Rust, since we are ultimately interfacing with C interfaces, the platform-agnostic part of the code should be pretty stable across updates. But there will definitely be cases where UniFFI will need to be updated to support new features. Here are some cases:

  1. The good: @Sendable support is required by Swift 6, which is planned to be released in September 2024. The related pull request was created on 2024/03/23 and was merged on 2024/03/26. Not only was the PR merged before the release date (even before WWDC 2024), it was reviewed and approved in less than three days!

  2. The bad: Swift Concurrency support. Announced on 2021/09/20 with Swift 5.5, Swift Concurrency wasn't supported by UniFFI until the year 2023. What's more, the PR was actually created on 2022/11/23, and was finally merged after more than three months on 2023/02/28.

Conclusion

"Write once, run anywhere" was a 1995 slogan created by Sun Microsystems to illustrate the cross-platform benefits of the Java language. Though there is still no end in sight, the pursuit continues. But if we carefully choose the part of the code that can be shared cross-platform—for example, the business logic that has little to do with the platform-specific features, we might just achieve part of that dream here and now. And Rust, with all its good and bad, might just be the right tool for it.