Rust - 性能超越 Kotlin 的跨平臺方案
現如今很多應用程序,爲了覆蓋儘可能多的用戶羣體會選擇同時在多個主流平臺上進行開發:
-
移動端:iOS,android
-
桌面端:osx,windows,linux
-
Web 端
所有平臺都基於各自的原生技術開發,雖然用戶體驗好但開發效率卻很低。爲了兼顧用戶體驗和開發效率,市面上誕生了一批跨平臺解決方案:
-
移動端:apache cordova,react native,weex,flutter 等
-
桌面端:QT,apache cordova,electron,flutter-desktop 等
-
Web 端:flutter-web
這些解決方案各有優劣,從架構的角度,大致是以下幾種模式:
跨平臺方案的幾種類型
1. 橋接(Bridge)
橋接要解決的核心問題是兩種語言(JS 和原生語言)之間的通信,或者說 JS thread 和 native thread 之間的通信。native layer 的能力被 bridge layer 封裝起來,然後提供給 JS layer 調用,反過來,JS layer 的功能也可以藉由 bridge layer 供 Native layer 調用。
這個模型很像客戶端與服務器之間的通信,客戶端和服務器約定好服務接口(REST API)後,通過 JSON 交換數據。React Native 借鑑了這種模式,通過 JS bridge 來回傳遞 JSON。
橋接的代表是:Cordova / React native。兩者的區別是在 Cordova 的 UI 層基於 WebView 渲染,所以只需要通過橋接調用 Native 基礎服務;而 RN 的 UI 基於平臺渲染,因此在 UI 層也做大量了橋接。由於 JS bridge 層依靠 JSON 通信,當大量數據在兩端傳輸時(複雜的動畫,大列表的快速滑動),JSON 的性能瓶頸會造成 UI 卡頓。
2. 進程間通信(IPC)
在桌面系統上,應用程序有更多的靈活性,可以通過使用多進程來組織自己的應用程序。我們同樣可以通過進程間通信來解決 JS 和 原生語言之間的調用問題,其代表方案是:Electron。
Electron 使用 IPC 某種程度上說也是迫不得已,因爲其依賴的 chromium rengier engine 就是爲每一個窗口開啓一個進程。對於 chrome 來說,這是一個合理的設計,一個 tab 內部的 crash 不會導致整個 chrome crash。然而對依賴於 Electron 的桌面應用來說這樣設計沒有必要,反而增加了 IPC 的成本
進程間通信可以使用很多方式來進行消息的傳遞,比如大家熟悉的管道(pipe)。然而,Eletron 使用了 web worker API postMessage 相同的 structured clone algorithm 來做 IPC 數據的序列化和反序列化。這個方法效率和 JSON 差不太多,在傳輸大量數據時同樣會有性能問題,所以 Electron 推薦使用 CSS animation,而非常不建議做 JS anination。
3. 基於 Canvas 繪製
Canvas 繪製是實現 UI 跨平臺的主流思路:
-
平臺渲染: 用 JS 來調用原生 UI, 這是 React Native 採用的方式。優點是大部分時候性能足夠好;缺點是 JS bridge 需要適配所有支持的平臺,當平臺側 UI 控件想要在 RN 中使用,需要開發者花費額外精力去適配。
-
統一渲染:用其他技術來模擬原生 UI。這是 Cordova / Electron 採用的方式。優點是代碼簡單,UI 直接在第三方渲染器(webview)中渲染出來;缺點是 UI 性能受 JS 單線程及 webview 本身渲染性能的影響,在複雜交互時往往表現不佳。
當大多數選擇統一渲染方案的技術棧只是把目光停留在了 webview ,人們忽略了其實所有的 UI 渲染,最終都是在 canvas 上一個像素一個像素填充出來的。如果做一套系統,略過 dom/css/js 複雜的渲染邏輯,直接定製好各種各樣的控件,將其繪製到 canvas 上,是不是可以魚與熊掌兼得?
做到這點的要數 flutter 了。它使用 chrome 底層的圖形渲染引擎 skia,從底向上設計出來一套可以高效工作的控件庫,比 webview 性能高的同時,又不依賴平臺側的控件。
現有跨平臺方案中的問題
目前所有這些方案的着眼點還是侷限在 UI 層的跨平臺,那麼業務邏輯代碼怎麼辦?用 JS 這樣的 UI 層的語言撰寫難以保證運行時效率,最終還是要訴諸於 native 語言實現,原本一種語言統一天下的初衷,最終發現要學習三種語言,iOS、android、JS/dart 各來一套(flutter 可能做得稍微強一點兒,但是依然需要藉助 channel 調用平臺側的服務)。
"相比 UI 跨平臺,如何在業務邏輯層跨平臺是一個容易被忽略但更值得被關注的問題。"
那麼爲什麼邏輯層跨平臺技術進展如此緩慢呢?一個主要原因是沒有一個合適的語言工具,很難找到一門語言能夠同時覆蓋這麼多平臺的原生語言的優勢。
在 Rust 成熟以前,C/C++ 幾乎是跨端做業務邏輯的唯一的選擇。用 C/C++ 實現一次,然後在各個端上用靜態鏈接的方式編譯到 app 中。當然這免不了要做很薄的一層接口:每個平臺原生語言到 C/C++ 的橋接。
但是 C/C++ 的代碼(相對於 java/kotlin/swift 來說)的撰寫難度較高,跨平臺編譯鏈接有很多坑要踩,最終會遮掩所謂「一次撰寫,到處鏈接」的好處。
如今有了 Rust, Rust 有不輸於任何一門現代語言的依賴管理和生態,有非常完備的跨平臺編譯系統和跨語言 FFI 支持,而 Rust 本身的不依賴運行時的內存安全和併發安全性,還有幾乎最高質量的 webassembly 支持,使其成爲 C/C++ 跨平臺的完美替代品。除了 rust 本身的跨平臺工具鏈之外,Rust 生態裏還有專門爲簡化與 iOS 原生語言互操作的工具 cargo lipo(封裝 C FFI),以及爲與 java 互操作的 jni,甚至還有專門針對 Android 的 android-ndk-rs。
接下來,我們需要的就是一套組織各個平臺原生語言和 Rust 互操作的思路,來解決通用性的問題。
前端中的後端
所謂前端中的後端,就是在前後端分離的基礎上,進一步把前端中偏 UI 的業務邏輯和偏數據處理的業務邏輯分開。而掌管數據處理的這部分功能,我們管它叫前端中的後端。
基本架構
無論是前端架構中被廣泛使用的 MVC 還是 MVVC 模式,其第一個 M,Model(包含數據,狀態,以及業務邏輯),就是我們要分離出來統一處理的「後端」。借鑑之前提到的 bridge 模式,可以構想出來這麼一套前端代碼的前後端分離的模型:
這個模型相對於傳統的 UI 跨平臺方案,其最大不同是:讓所有的相關方處理自己最擅長的事情,而不要強行適配。和平臺相關的代碼,比如 UI,平臺設備的訪問等,用更擅長做這件事情的平臺原生語言實現(或者 flutter),而平臺無關的業務邏輯代碼,算法,網絡層代碼,使用 Rust 來實現。這樣,Rust backend 不用去花大量的精力去包裹平臺的東西,而只需幹好一個 backend 需要幹好的事情。
通信方式
之前的 UI 方案,採用的都是 JSON 或者類 JSON 的序列化方案,JSON 是效率非常低下,且類型安全度比較低的一種序列化方案,在這樣的場景下,我們還有更多更好效率更高類型更安全的方案,比如 protobuf,flatbuffers 等。
以 Rust 和 Kotlin 之間做通信爲例,使用 JSON 以及 Protobuf 的通信流程分別如下:
Rust 和 Kotlin 分別將定義好的 protos 編譯成平臺代碼,然後可以在兩端自由地傳遞 protobuf 的數據。
示例
比如要展示電影網站 Tubi 的首頁,假設我們基於 clean architecture 的 MVP 結構實現此邏輯
-
後端提供 API 獲取電影列表
GET /api/v1/get_movies
-
建立一個
TubiRepository
處理網絡層的請求 -
請求的響應被反序列化成 Category / Movie models,然後以 Entity 的形式交給 Use cases
-
最後 presentation layer 將結果被渲染到屏幕
這裏,嘗試對 Model 層 用 Rust 實現,如下:
-
暴露給 native 層的方法是:
getMovies()
-
getMovies() 內部將參數序列化成 protobuf 傳遞給一個 Rust 函數
dispatcher
-
dispatcher 反序列化請求後得知是一個 RequestGetMovies,隨後被 dispatch 給
get_movies()
-
get_movies() 從本地 cache 裏讀取數據,讀不到的話通過 reqwest 從遠程 API 獲取數據並緩存
從 native 開發者的角度,她就調用了一個 getMovies()
後返回了序列化好的 Movie,Category 等數據結構,其它的細節不需要理會。
再舉個例子,用戶在觀看視頻的時候,客戶端會定期向服務器彙報當前觀看的位置
-
API:
PUT /api/v1/update_history
-
在 native 層暴露出一個
updateHistory()
的方法 -
dispatcher 將其 dispatch 給 Rust 函數
update_history()
從上述的例子,我們大概可以看到在 Rust 側我們可以處理的工作:
-
更高效的網絡層:自動管理的連接池,更好的流控,更靈活的安全處理方式,以及,UI 側無感知的網絡層處理,比如有一天我們把 REST API 升級成 gRPC,API 層的簽名採用 schnorr signature,或者 HTTP/2 升級到 HTTP/3。native 側根本無需關心。
-
更好的數據管理。Rust 有豐富高效的數據結構,可以爲每一種數據設置量身定製的方案。我們還可以做非常高效的數據緩存。
-
在此之上給數據的賦能。比如爲 get_movies() 獲取到的數據做簡單的索引,方便數據在各個不同維度的展示和過濾。
如何持續維護?
如上,我們對前後端進行分離,由於雙方基於 protobuf 實現通信,那麼維護好 protobuf message 的定義非常重要。其中 Request 和 Response 是最核心的兩個消息:
-
Request: one of 類型。裏面包含所有從 native 側調用 Rust 函數的請求接口,比如
RequestGetMovies
,RequestUpdateHistory
等。 -
Response: one of 類型。裏面包含所有從 Rust 側返回給 native 調用者的響應接口,比如
ResponseGetMovies
,ResponseUpdateHistory
等。
每次新的接口被添加進來後,我們只需擴充這兩個消息的定義,添加新的類型。然後對所有涉及的語言做 protobuf codegen,生成新的接口代碼,接着在兩側填充對應的接口代碼。這個步驟是可以自動化的,最好集成在 Rust build.rs 或者 Makefile 裏完成。最後,開發者只需要撰寫相關的 Rust 的邏輯代碼。
如果後端接口基於 Open API spec 描述,那麼,甚至我們可以根據 Open API spec 裏的信息,生成對應的 Rust 客戶端調用方法,以及 Rust 和 Native 間通訊的 gRPC, 理論上可以根據 Open API spec 生成整個網絡層的跨端代碼,不用寫一行代碼,最終暴露給 native 側一個簡單高效好用的 getMovies()
。
和 Kotlin Native 的比較?
Kotlin Native 也是一個不錯的選擇,特備是對於 Android 開發者來說。Rust 相對於 KN 的主要優勢可能就是性能了
Benedikt 在他的演講 "Sharing Code between iOS & Android with Rust" 也提到了這個問題。作爲一個 Rust 技能樹剛剛點開的移動端開發者,他做了一些簡單的 benchmark。首先,他嘗試對一個很大的包含各種數字的字符串進行小於 100 的數字的求和。
Rust
Kotlin
Swift
三者的代碼非常接近,但性能卻差幾十倍
所以對於一些重視性能的底層庫開發上,更加適合用 Rust 而非 KN 進行開發。
最後爲三種跨平臺技術做個對比
總結
簡單來說,凡使用 C++ 跨平臺方案的地方現在可以用 Rust 替代。Rust 的語法更現代,代碼更安全,且跨平臺生態更好,特別適合處理一些通用的數據層邏輯。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/b8lHRfk5G2yN7pkoURU7CA