LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

ラストソリューション: Rustでのモバイルクロスプラットフォーム開発方法(Android/iOS間のコード共有)

こんにちは。私はLINEアプリのiOSエンジニア、Zang Zhihaoです。私たちのチームはLINEアプリの公式アカウント機能やLINE公式アカウントアプリを担当しています。今日は、iOS/Androidプラットフォーム間でのコード共有についてお話しします。

プラットフォーム間でのコード共有は、ソフトウェア開発コミュニティで長い間ホットな話題となっています。しかし最近では、モバイル開発の分野でさらに注目を集めています。古い技術であるXamarinは最近のニュースにはあまり登場しませんが、Kotlin Multiplatformについての話がiOSDCやDroidKaigiでは、少なくとも最近3年間は常に取り上げられています。FlutterやReact Nativeの技術についてもよく話題になります。しかし、今日は別の、Rustの話をしたいと思います。

Rustはモバイル開発のための明白な選択肢ではありません。錆びたバイクのチェーンリングのロゴのシャツを着ているエンジニアたちが、ブラウザエンジンやバックエンドソフトウェア、あるいはみんなが大好きなデスクトップオペレーティングシステムであるLinuxに取り組んでいるのをよく見かけるかもしれません。実際、RustはしばしばCやGoのような言語と比較され、ベンチマークされています。しかし、よく考えてみると、Rustがモバイル開発に使用できない理由は特にありません。Rustは高性能で、効率的で、安全です。これらは、リソースが限られたモバイルデバイスで動作する技術に必要な特性です。また、AndroidとiOSの両方がCライブラリと互換性があり、Rustはそれに簡単にインターフェースできます。では、なぜRustをクロスプラットフォームのモバイル開発で採用する人がもっといないのでしょうか? 今日はこの質問に答えてみたいと思います。この議論は4つの部分に分かれます。

  • Rustをモバイルプロジェクトに統合するためのパイプライン
  • モバイルプラットフォーム上でのRustの性能
  • Rustが対応できる機能の種類
  • 開発者の体験

私はiOS開発者の視点から書きますが、可能な限りAndroidの方もカバーします。

パイプライン

KotlinやSwiftからRustコードを呼び出すためには、Rustコードのインターフェースを公開する必要があります。これは手動のブリッジングやUniFFIを使用して行うことができます。

手動ブリッジング

RustはCコードとFFI(Foreign Function Interface)を通じて相互運用できます。そして、Swiftコードはブリッジングヘッダーを使用してC関数を呼び出すことができます。したがって、RustコードをSwiftプロジェクトで使用するために、Rust関数をC関数として公開し、Swiftプロジェクトにブリッジングヘッダーを作成してC関数を呼び出すことができます。同じことがJNI(Java Native Interface)とNDK(Native Development Kit)を使用してKotlinでも実現できます。

手動ブリッジングフロー

理論的にはこれで動作しますが、しかし、実際にはこの方法を避ける理由がいくつかあります。まず、これは非常に手間のかかるプロセスです。Rust関数をラップするCコード、Swift用のブリッジングヘッダー、Kotlin用のJNIインターフェースなど、多くのボイラープレートコードを書く必要があります。このプロセスを自動化するスクリプトを書くこともできますが、その前に次のオプションを見てみましょう。

UniFFI - Rustのための多言語バインディングジェネレーター

UniFFIは、Rustをよりポータブルで相互運用可能にするためのMozillaの取り組みです。そのGitHubリポジトリには次のように書かれています。

  • Rustコードを異なるターゲットプラットフォームで使用するための共有ライブラリにコンパイルします。
  • ライブラリを異なるターゲット言語からロードして使用するためのバインディングを生成します。

UniFFIでのブリッジングフロー

UniFFIの設計はシンプルでありながら、興味深いです。詳細については公式マニュアルを読むことをお勧めしますが、以下は「リフティング、ロワリング、シリアライゼーション」と呼ばれる技術の概要です。

…UniFFIは異なる言語間で相互運用するために、プリミティブデータ型とプレーン関数を使用するCスタイルのFFIレイヤーを定義します。このレイヤーを通じてデータを転送するために、送信側はデータを言語固有のデータ型からFFIレイヤー関数がサポートするプリミティブ型に「ロワリング」し、受信側はそのプリミティブ型を自分の言語固有のデータ型に「リフティング」します。

UniFFIを使用すると、Rustで公開したい関数にマクロ(#[uniffi::export])を付けるだけで、UniFFIが残りの作業を行い、バインディングを生成します。Rustコードを共有ライブラリにコンパイルした後、Swiftプロジェクト用にXCFrameworkを作成することもできます。

パフォーマンス

Rustはその性能で知られていますが、モバイルプラットフォーム上ではどうでしょうか? 実際のパフォーマンスを見るために、Swiftから呼び出されるRustコードの性能を純粋なネイティブSwiftコードの性能と比較するための簡単なサンプルを作成しました。以下のRustコードは、指定された文字列を繰り返し暗号化するものです。

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);
    }
}

こちらは CryptoKit を利用したSwiftの実装です:

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))
}

ベンチマークは以下のパラメータで設定しました:「Benchmarking AES encryption in Rust」という文字列を128ビットのキーと128ビットのIVで暗号化し、暗号化を一千万回繰り返します。

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

iPhone 11 Pro(iOS 17)での結果は次の通りです: 

ベンチマーク結果

Rustコードは暗号化を完了するのに約15秒かかり、Swiftコードは約22秒かかりました。ベンチマーク中、両方の実装において、CPU使用率は常に100%であり、メモリ使用量は少なめでした。

このデモはもちろん完璧でもありません。たとえば、Rustの実装でプラットフォーム固有のハードウェアアクセラレーションを利用したり、Swiftの実装でCryptoKitの代わりにCommonCryptoを使用したりすると、結果は大きく異なる可能性があります。しかし、RustとSwiftの性能差の大まかなアイデアを得ることができます。

バイナリサイズ

テストのために、cargo build --release --target=aarch64-apple-iosを使用してRustコードをコンパイルし、結果として25 MBの静的ライブラリが生成されました。Rustライブラリを含まない場合、リリース用にコンパイルされたデモアプリ全体はわずか130 KBでした。

機能セット

このセクションでは、Rustがアプリを書くために必要な機能に対応できるかどうかを検討します。たとえば、UIを書く場合、ネイティブフレームワークを直接使用するのではなく、Rustで書くことを意味します。

UI

RustでUIを実装するには、基本的に次の3つの選択肢があります。

  1. ウェブ技術
  2. ローレベルAPI(Vulkan / Metal)で描画
  3. ネイティブUI APIのブリッジ

1や2は、ネイティブのAndroid/iOSの体験に慣れているユーザーに一貫した体験を提供することはできません。ユーザーはネイティブであると感じたり、なじみ深いと感じたりしないでしょう。なので、選択肢3が残ります。

しかし、RustのネイティブUI APIブリッジの状態は、控えめに言っても「実験的」です。以下はAppleプラットフォーム向けの最も有名なソリューションであるCacaoの状況です。

Cacaoサポート機能一覧

ご覧の通り、AppKit APIの良好なカバレッジがあるかもしれませんが、iOS上では使えるものではないでしょう。

ネットワーキング

ここに問題があります:現代のモバイルネットワークスタックは複雑です。リクエストを行うだけでなく、Wi-Fi/セルラーの切り替え、マルチパス、VPN、省データモード(および関連するバックグラウンドフェッチ管理)などを処理する必要があります。それだけでなく、無線ハードウェアの電力管理も考慮する必要があります。Appleの(やや古い)ドキュメントに記載されているように、「可能な限りiOSでPOSIXソケットやCFSocketを避ける」ことが推奨されます。

同じことがI/Oやその他のプラットフォーム固有の機能にも当てはまります。

開発者体験

Rustは、SwiftやKotlinのような他のモダンな言語と極端に異なるわけではありませんが、学習曲線があります。所有権システム、ライフタイム、借用チェッカーなどはすべて独特です。しかし、Rustを学ぶためのリソースが豊富にあります。一度慣れると、よくメンテナンスされたライブラリやツールの世界に力を与えられることに気づくでしょう。

実用上、UniFFIを使用すれば、ネイティブモバイルプロジェクトにRustを統合するのもそれほど難しくありません。しかし、バイナリサイズは問題になるかもしれません。Rustを既存のモバイルプロジェクトに統合する場合、総バイナリサイズが大幅に増加することはほぼ確実です。

機能サポートの対応時間

サードパーティの技術に対してコードを書く場合、ネイティブプラットフォームに新しい機能が導入されるたびに、ブリッジライブラリのメンテナに依存するか、(場合によっては厳しい期限内で)自分でブリッジコードを書く必要があります。Rustの場合、最終的にはCインターフェースとやりとりするため、プラットフォームに依存しない部分のコードはアップデート間で比較的安定しているはずです。しかし、新しい機能をサポートするためにUniFFIの更新が必要になる場合もあります。以下はその例です。

  1. 良い例:Swift 6で必要とされる@Sendableサポート。Swift 6は2024年9月にリリース予定です。関連プルリクエストは2024年3月23日に作成され、2024年3月26日にマージされました。リリース日前(WWDC 2024よりも前!)にPRがマージされ、3日以内にレビューおよび承認されました!

  2. 悪い例:Swift Concurrency。2021年9月20日にSwift 5.5で発表されたSwift Concurrencyは、2023年までUniFFIでサポートされませんでした。さらに、プルリクエストは2022年11月23日に作成され、最終的に3カ月以上経過した2023年2月28日にマージされました。

結論

Write once, run anywhere」は、Java言語のクロスプラットフォームの利点を示すために、Sun Microsystemsが1995年に作成したスローガンです。まだ終わりが見えないものの、その追求は続いています。しかし、プラットフォーム固有の機能とほとんど関係のないビジネスロジックなど、共有できるコードの部分を慎重に選択すれば、ここで今その夢の一部を実現できるかもしれません。Rustは、良い点も悪い点もありながら、そのための適切なツールかもしれません。