Rust 移動端開發體驗

在過去的幾周裏,我根據 Xlog 和 Loagan 的設計思路,使用 Rust 寫了一個移動端的跨平臺日誌庫 EZLog。在我實現這個庫的過程中,查閱了大量的問答和博客。因爲這些開發者的分享,節省了我大量的時間。所以我把我的經歷也分享出來。

如果以下任何一個點你感興趣,不要划走。

起因

22 年初,我有一個遊戲需求,要在 android 上實現勻速的貝塞爾曲線路徑移動。依賴 kurbo 寫了一個生成貝塞爾曲線 LUT 的命令行工具,體驗很好。於是想嘗試一下,發揮 Rust 的優勢,在移動端寫一個對性能有要求的開源庫,第一個想到的就是日誌。

可行性調查

大型公司在移動端使用 Rust

個人開發者在移動端的嘗試 Rust 的案例

更多的案例收錄,可以參看這篇 Rust 移動開發與跨平臺模式探究。從以上的例子來看,大概率是可行的,還要對具體的需求進行驗證。

項目簡述

主要功能

可行性驗證

先查找主要功能是否有對應的 Rust 開源實現。

開源社區早已實現好了:) 複製開源庫中的示例,藉助 android-ndk-rs。在 android 真機運行,查看日誌輸出,符合預期。項目中使用android ndk rs的例子,可以查看examples/android_preview

cargo apk run -p ezlog_android_preview
   Compiling ezlog v0.1.2 (ezlog/ezlog-core)
   Compiling ezlog_android_preview v0.1.0 (ezlog/examples/android_preview)
    Finished dev [unoptimized + debuginfo] target(s) in 2.39s
 'lib/arm64-v8a/libezlog_android_preview.so'...
Verifying alignment of ezlog/target/debug/apk/ezlog_android_preview.apk (4)...
      49 AndroidManifest.xml (OK - compressed)
    1020 lib/arm64-v8a/libezlog_android_preview.so (OK)
Verification succesful
Performing Incremental InstallServing...
All files should be loaded. Notifying the device.SuccessInstall command complete in 949 msStarting: Intent { act=android.intent.action.MAIN cmp=rust.ezlog_android_preview/android.app.NativeActivity }

項目結構

├── android
│   ├── app # android 示例工程│   └── lib-ezlog # EZLog android 庫├── examples # rust 示例├── ezlog-cli # 命令行工具├── ezlog-core # 核心庫├── ios
│   ├── EZLog # EZLog iOS 庫│   ├── demo # iOS 示例工程│   └── framework # EZLog XCFramework

開發中碰到的問題及解決

iOS

iOS 端的開發流程爲

  1. Rust 編碼

  2. 通過 cbindgen 生成頭文件

  3. 編譯多平臺靜態庫

  4. 把靜態庫和頭文件打包成 XCFramework,並依賴

  5. 實現 Swift 綁定

  6. 測試,發佈

在對比了多種依賴靜態庫的方式之後,發現 XCFramework 對多平臺的支持,更適合這個項目。更多 XCFramework 的相關資料可以看這幾篇文章 distributing universal ios frameworks as xcframeworks using cocoapods, Static libraries into XCFramework,From Rust To Swift。在項目中的構建使用,可以參看ios/b_ios.sh腳本。

Swift 與 C 的互相調用,很多概念需要了解。在被Unmanaged@escaping,@convention,UnsafePointer,UnsafeBufferPointerUnsafeMutableRawPointer 折磨許久之後,終於可以在 Swift 中拿到 Rust 的回調了。

Cocoapods 支持 XCFramework,嘗試了 SPM,找不到符號的問題沒有解決。暫時放一放。在花費了以天計的時間成本之後,終於在 Cocoapods 成功發佈。

對比一下三個包管理工具的從註冊到發佈的時間成本,從簡單到繁瑣的排序是 Cargo < Cocoapods < Maven。

隨着蘋果在 XCode14 中廢棄了 bitcode,Rust 在 iOS/MacOS 中最大的痛點也就消失了。

android FFI

android 上 Rust 與 JNI 的互調和 C/C++ 的區別不大。同樣要考慮變量的生命週期,全局的 JavaVM 引用,類加載到 JVM,native 線程在 JVM 的 attach 和 detach,詳細的示例代碼可以查看 jni-rs。

得益於 Rust 的生命週期管理,一些內存清理操作 Rust 已經處理了,不需要我們再手動的處理。

impl<'a: 'b, 'b> Drop for JavaStr<'a, 'b> {
    fn drop(&mut self) {
        match self.env.release_string_utf_chars(self.obj, self.internal) {
            Ok(()) => {}
            Err(e) => warn!("error dropping java str: {}", e),
        }
    }
}

android 動態庫大小

因爲用戶對 RAM,流量的關心和 Android 版本向前兼容原因,android 開發中對包體積大小是敏感的。對於 Rust 編譯產物體積較大的問題,在查閱了 Minimizing Rust Binary Size 文章後,在 release 模式開啓優化。

我們將 XLog,Logan 都加入到 Demo 的依賴中,對比 release apk 中的 64 位動態庫大小,如圖所示

Y7Ewv1

Rust 編譯的動態庫大小是最大的,不過也沒有太過於誇張,隨着手機硬件的進一步提升,應該不會是制約 Rust 在 android 中應用的原因。

崩潰?

不同的情況下,需要不同的方式

Rust 的錯誤分爲可恢復和不可恢復的錯誤。Rust 初始化線程的 panic 會導致進程的退出。一些解決方法:

  1. 只在需要崩潰時使用 panic 宏

  2. 在 Clippy 中加入使用 unwrap 和 except 的警告

  3. 替換 [start..end] 爲 get(start..end)

  4. FFI 中 catch_unwind

即使自己的代碼中沒有 panic 調用,依賴庫中也可能會調用。所以需要提供在生產環境中崩潰排查的能力。

崩潰排查

初始化時設置 panic hook。

#[cfg(not(feature = "backtrace"))]
fn hook_panic() {
    std::panic::set_hook(Box::new(|p| {
        event!(panic & format!("ezlog: \n {p:?}"));
    }));
}

在崩潰時會回調拿到 PanicInfo

PanicInfo { payload: Any { .. }, message: Some(asdf), location: Location { file: "ezlog-core/src/lib.rs", line: 119, col: 5 },

PanicInfo 中有錯誤信息,panic 的文件路徑和代碼位置。這樣能粗略的排查 bug。如果想拿到具體的堆棧信息,我們還需要依賴 backtrace,這樣最後動態庫的大小會增加 80KB 左右

#[cfg(feature = "backtrace")]
fn hook_panic() {
    std::panic::set_hook(Box::new(|p| {
        let bt = Backtrace::new();
        event!(panic & format!("ezlog: \n {p:?} \n{bt:?} \n"));
    }));
}
PanicInfo { payload: Any { .. }, message: Some(asdf), location: Location { file: "ezlog-core/src/lib.rs", line: 119, col: 5 }, can_unwind: true }   0: backtrace::backtrace::trace_unsynchronized   1: backtrace::backtrace::trace   2: backtrace::capture::Backtrace::create   3: backtrace::capture::Backtrace::new   4: ezlog::init::{{closure}}   5: std::panicking::rust_panic_with_hook   6: std::panicking::begin_panic_handler::{{closure}}   7: std::sys_common::backtrace::__rust_end_short_backtrace   8: _rust_begin_unwind   9: core::panicking::panic_fmt  10: ezlog::init  11: _ezlog_init  12: _$s5EZLog18ezlogInitWithTraceyyF  13: _$s4demo7DemoAppVACycfC  14: _$s4demo7DemoAppV7SwiftUI0C0AadEPxycfCTW  15: <unknown>  16: _$s4demo7DemoAppV5$mainyyFZ  17: _main
PanicInfo { payload: Any { .. }, message: Some(asdf), location: Location { file: "ezlog-core/src/lib.rs", line: 119, col: 5 }, can_unwind: true }
  0: <unknown>
  1: <unknown>
  2: <unknown>
  3: <unknown>
  4: <unknown>
  5: <unknown>
  6: <unknown>
  7: Java_wtf_s1_ezlog_EZLog_init
  8: art_quick_generic_jni_trampoline
  9: art_quick_invoke_static_stub10: _ZN3art11interpreter34ArtInterpreterToCompiledCodeBridgeEPNS_6ThreadEPNS_9ArtMethodEPNS_11ShadowFrameEtPNS_6JValueE11: _ZN3art11interpreter6DoCallILb0ELb0EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtPNS_6JValueE12: MterpInvokeStatic13: mterp_op_invoke_static14: _ZN3art11interpreterL7ExecuteEPNS_6ThreadERKNS_20CodeItemDataAccessorERNS_11ShadowFrameENS_6JValueEbb.llvm.335106805463763666415: _ZN3art11interpreter33ArtInterpreterToInterpreterBridgeEPNS_6ThreadERKNS_20CodeItemDataAccessorEPNS_11ShadowFrameEPNS_6JValueE16: _ZN3art11interpreter6DoCallILb0ELb0EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtPNS_6JValueE17: MterpInvokeStatic18: mterp_op_invoke_static19: _ZN3art11interpreterL7ExecuteEPNS_6ThreadERKNS_20CodeItemDataAccessorERNS_11ShadowFrameENS_6JValueEbb.llvm.335106805463763666420: _ZN3art11interpreter33ArtInterpreterToInterpreterBridgeEPNS_6ThreadERKNS_20CodeItemDataAccessorEPNS_11ShadowFrameEPNS_6JValueE
2022-07-07 15:25:50.712 14141-14141/? A/DEBUG: *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***2022-07-07 15:25:50.712 14141-14141/? A/DEBUG: Build fingerprint: 'google/flame/flame:12/SP2A.220305.012/8177914:user/release-keys'2022-07-07 15:25:50.712 14141-14141/? A/DEBUG: Revision: 'MP1.0'2022-07-07 15:25:50.712 14141-14141/? A/DEBUG: ABI: 'arm64'2022-07-07 15:25:50.712 14141-14141/? A/DEBUG: Timestamp: 2022-07-07 15:25:50.559643355+08002022-07-07 15:25:50.712 14141-14141/? A/DEBUG: Process uptime: 0s2022-07-07 15:25:50.712 14141-14141/? A/DEBUG: Cmdline: wtf.s1.ezlog.demo2022-07-07 15:25:50.712 14141-14141/? A/DEBUG: pid: 14112, tid: 14112, name: f.s1.ezlog.demo  >>> wtf.s1.ezlog.demo <<<2022-07-07 15:25:50.712 14141-14141/? A/DEBUG: uid: 102872022-07-07 15:25:50.712 14141-14141/? A/DEBUG: signal 6 (SIGABRT), code -1 (SI_QUEUE), fault addr --------2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:     x0  0000000000000000  x1  0000000000003720  x2  0000000000000006  x3  0000007fc9d511202022-07-07 15:25:50.712 14141-14141/? A/DEBUG:     x4  00000000ebad808a  x5  00000000ebad808a  x6  00000000ebad808a  x7  00000000ebad808b2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:     x8  00000000000000f0  x9  f7d7529acf61ddf5  x10 0000000000000000  x11 ffffff80fffffbdf2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:     x12 0000000000000001  x13 0000007fc9d50fe8  x14 0000000000000000  x15 00000000000000082022-07-07 15:25:50.712 14141-14141/? A/DEBUG:     x16 0000007b425b6050  x17 0000007b42592db0  x18 0000007b523fa000  x19 00000000000037202022-07-07 15:25:50.712 14141-14141/? A/DEBUG:     x20 0000000000003720  x21 00000000ffffffff  x22 0000000000000001  x23 00000000000000012022-07-07 15:25:50.712 14141-14141/? A/DEBUG:     x24 000000782576bad8  x25 00000078256bb708  x26 000000000000000b  x27 0000007b515530002022-07-07 15:25:50.712 14141-14141/? A/DEBUG:     x28 0000007fc9d514a0  x29 0000007fc9d511a02022-07-07 15:25:50.712 14141-14141/? A/DEBUG:     lr  0000007b42545aa0  sp  0000007fc9d51100  pc  0000007b42545acc  pst 00000000000000002022-07-07 15:25:50.712 14141-14141/? A/DEBUG: backtrace:2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #00 pc 000000000004facc  /apex/com.android.runtime/lib64/bionic/libc.so (abort+164) (BuildId: cd7952cb40d1a2deca6420c2da7910be)2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #01 pc 00000000000b3f1c  /data/app/~~eRgxj9PHRfJEC-cex2WWJw==/wtf.s1.ezlog.demo-ngUhJZd2NWtJpUNIWy5f_g==/lib/arm64/libezlog.so2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #02 pc 00000000000b22a8  /data/app/~~eRgxj9PHRfJEC-cex2WWJw==/wtf.s1.ezlog.demo-ngUhJZd2NWtJpUNIWy5f_g==/lib/arm64/libezlog.so2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #03 pc 00000000000b2164  /data/app/~~eRgxj9PHRfJEC-cex2WWJw==/wtf.s1.ezlog.demo-ngUhJZd2NWtJpUNIWy5f_g==/lib/arm64/libezlog.so2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #04 pc 00000000000b203c  /data/app/~~eRgxj9PHRfJEC-cex2WWJw==/wtf.s1.ezlog.demo-ngUhJZd2NWtJpUNIWy5f_g==/lib/arm64/libezlog.so2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #05 pc 00000000000b142c  /data/app/~~eRgxj9PHRfJEC-cex2WWJw==/wtf.s1.ezlog.demo-ngUhJZd2NWtJpUNIWy5f_g==/lib/arm64/libezlog.so2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #06 pc 00000000000b1e6c  /data/app/~~eRgxj9PHRfJEC-cex2WWJw==/wtf.s1.ezlog.demo-ngUhJZd2NWtJpUNIWy5f_g==/lib/arm64/libezlog.so2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #07 pc 00000000000c5ad0  /data/app/~~eRgxj9PHRfJEC-cex2WWJw==/wtf.s1.ezlog.demo-ngUhJZd2NWtJpUNIWy5f_g==/lib/arm64/libezlog.so2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #08 pc 000000000007550c  /data/app/~~eRgxj9PHRfJEC-cex2WWJw==/wtf.s1.ezlog.demo-ngUhJZd2NWtJpUNIWy5f_g==/lib/arm64/libezlog.so2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #09 pc 00000000000741ec  /data/app/~~eRgxj9PHRfJEC-cex2WWJw==/wtf.s1.ezlog.demo-ngUhJZd2NWtJpUNIWy5f_g==/lib/arm64/libezlog.so (Java_wtf_s1_ezlog_EZLog_init+28)2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #10 pc 00000000002d4044  /apex/com.android.art/lib64/libart.so (art_quick_generic_jni_trampoline+148) (BuildId: 46df93bc978921840e5b428398c66a57)2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #11 pc 00000000002ca9e8  /apex/com.android.art/lib64/libart.so (art_quick_invoke_static_stub+568) (BuildId: 46df93bc978921840e5b428398c66a57)2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #12 pc 00000000002ee6b8  /apex/com.android.art/lib64/libart.so (art::interpreter::ArtInterpreterToCompiledCodeBridge(art::Thread*, art::ArtMethod*, art::ShadowFrame*, unsigned short, art::JValue*)+320) (BuildId: 46df93bc978921840e5b428398c66a57)2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #13 pc 000000000040ade4  /apex/com.android.art/lib64/libart.so (bool art::interpreter::DoCall<false, false>(art::ArtMethod*, art::Thread*, art::ShadowFrame&, art::Instruction const*, unsigned short, art::JValue*)+820) (BuildId: 46df93bc978921840e5b428398c66a57)2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #14 pc 000000000076d4b8  /apex/com.android.art/lib64/libart.so (MterpInvokeStatic+3812) (BuildId: 46df93bc978921840e5b428398c66a57)2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #15 pc 00000000002c5014  /apex/com.android.art/lib64/libart.so (mterp_op_invoke_static+20) (BuildId: 46df93bc978921840e5b428398c66a57)2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #16 pc 00000000000790a2  [anon:dalvik-classes.dex extracted in memory from /data/app/~~eRgxj9PHRfJEC-cex2WWJw==/wtf.s1.ezlog.demo-ngUhJZd2NWtJpUNIWy5f_g==/base.apk] (wtf.s1.ezlog.EZLog.initWith+10)2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #17 pc 000000000027d840  /apex/com.android.art/lib64/libart.so (art::interpreter::Execute(art::Thread*, art::CodeItemDataAccessor const&, art::ShadowFrame&, art::JValue, bool, bool) (.llvm.3351068054637636664)+644) (BuildId: 46df93bc978921840e5b428398c66a57)2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #18 pc 000000000035a9e4  /apex/com.android.art/lib64/libart.so (art::interpreter::ArtInterpreterToInterpreterBridge(art::Thread*, art::CodeItemDataAccessor const&, art::ShadowFrame*, art::JValue*)+148) (BuildId: 46df93bc978921840e5b428398c66a57)2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #19 pc 000000000040b05c  /apex/com.android.art/lib64/libart.so (bool art::interpreter::DoCall<false, false>(art::ArtMethod*, art::Thread*, art::ShadowFrame&, art::Instruction const*, unsigned short, art::JValue*)+1452) (BuildId: 46df93bc978921840e5b428398c66a57)2022-07-07 15:25:50.712 14141-14141/? A/DEBUG:       #20 pc 000000000076d4b8  /apex/com.android.art/lib64/libart.so (MterpInvokeStatic+3812) (BuildId: 46df93bc978921840e5b428398c66a57)

可以發現,android 的堆棧輸出裏並沒有完整的 Rust 調用堆棧,我嘗試通過 add2line 的方法,但沒有成功。

使用 Rust 的體驗

我在讀完 Rust 官方文檔 後,又跟着 Rust 第一步 敲了一遍代碼。發現這只是個開始,在看了一遍 Rust Nomicon Rust Nomicon 中文和 Async Book 之後,就已經想放棄了。太多晦澀的內容了,比如:UnsafePhantomData,Send and SyncPin...

直接一邊寫項目一邊學吧。

在實際的項目中,開源社區提供的解決方案有時會更合適。比如:crossbeam_channeltokioonce_cellthiserror等等,在 crates.io 上看開源項目文檔也是學習的一部分。

Rust 大部分庫的文檔,對讀者很友好。詳盡的描述,完整的示例。和 Java 的 concurrent 包的文檔讀着一樣舒服。看這樣的文檔,好像就坐在作者的大腦皮層上看他寫代碼。反觀 iOS 和 Andriod 的一些文檔,字裏行間彷彿寫着 “你猜猜看這個怎麼用”。

易用的單元測試,隨時添加#[test]就可以寫一個測試用例。對比在 android 項目寫一個測試用例,我還要先去搜索,有哪些依賴是需要添加的。用 Rust 做開發,和系統 API 無關的業務邏輯,在桌面環境完成並測試,最後到對應的客戶端驗證。效率比起所有邏輯都在手機上驗證高多了。

Rust 可恢復錯誤強制處理,當我第一次看到 Result 那巨長的方法列表,就像看到大閘蟹身上一圈一圈的繩子,要不一剪刀 (unwrap) 了事?我們不想在庫裏直接 panic,就只能看文檔了。避免圖片過長,用了 3 張圖顯示:

習慣了 try catch 的錯誤處理方式,剛開始處理 Result/Option 是懵的。通過文檔示例和 Clippy 提示,花一些時間就能掌握。當我開始熟悉這樣的錯誤處理方式,我不時會懷疑,之前是怎麼在 Java/Kotlin 中只用 try catch 就能寫完那些代碼的。。。

Rust 編譯器的所有權 / 生命週期檢查,必須在編碼的時候就考慮對象的生命週期和內存的分配問題。對比 C/C++ 將編碼的痛苦前置了。對比有 GC 的語音,心智成本大幅提高。

debug 模式下

cargo clean && cargo build -p ezlogFinished dev [unoptimized + debuginfo] target(s) in 14.19s

使用緩存,更改一行代碼,再次編譯

cargo build -p ezlogFinished dev [unoptimized + debuginfo] target(s) in 1.46s

無緩存 release 模式下

target `aarch64-apple-ios`
Finished release [optimized] target(s) in 45.43s

target `aarch64-apple-ios-sim`Finished release [optimized] target(s) in 40.83s

target `x86_64-apple-ios`Finished release [optimized] target(s) in 39.26s

Building armeabi-v7a (armv7-linux-androideabi)
Finished release [optimized] target(s) in 47.64s

Building arm64-v8a (aarch64-linux-android)
Finished release [optimized] target(s) in 50.71s

在小型項目使用 Rust,基本沒有編譯摸魚時間。對於移動端開發來說,有過無編譯優化的中型項目的 Gradle 或者 XCode 構建體驗,這點編譯時間都不算事。如果想要加速 Android 端驗證的效率,那麼最好單獨新增 crate,用 android-ndk-rs 這樣的工具,動態獲取 target,因爲沒有 Java/Kotlin 代碼,跳過 gradle 的構建直接生成 APK 部署。

總結

學習成本

對於新手,Rust 文檔友好,社區活躍。還有官方支持的的包管理工具 Cargo,代碼檢查工具 Clippy。個人認爲上手難度要低於 C++。

開發效率

Rust 的學習曲線陡峭,編譯器對借用,生命週期的檢查。導致新手在初期開發效率低下。隨着對語言熟悉程度的提高,以及易用的測試框架,方便的跨平臺編譯,可以彌補前期的開發效率劣勢,加上 Rust 內存安全的特點,也降低了後期項目維護的難度。

適用

從 0 開始構建一個新的跨平臺 App,所有的非 UI 邏輯,都使用 Rust 實現,構建成單一的靜態 / 動態庫,提供 FFI 支持。Flutter/RN/Compose/SwiftUI 等等的框架做 UI 交互。在這種場景下使用 Rust 很合適。

但是如果想在現有的 App 中大量使用 Rust,那麼二進制依賴是一個問題,如果有多個業務 crate,打包成多個二進制文件。那麼各個 Rust 的產物可能會包含相同的依賴,比如 libc, syn, cfg-if, memchr 等等。最後的包大小會隨着業務的增多快速膨脹。

後續

項目需要繼續完善的地方

除了實現了 android,iOS 的跨平臺,還有一些其他的使用場景。

Tips

在項目中添加 ./.cargo/config,並配置

[build]
target = "aarch64-linux-android"...

註釋掉其他的 target 後,在 vscode 中雙擊 shift 輸入 >> 找到 reload workspace,就可以切換到對應的 target 了。

參考文章及開源項目

FFI

android

iOS

其他

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/B02nZn3cjvBhyirJSHjeMg