Rust跨平台探索:前端中的后端凡使用 C++ 跨平台方案的地方现在可以用Rust替代。Rust的语法更现代,代码更安全
现如今很多应用程序,为了覆盖尽可能多的用户群体会选择同时在多个主流平台上进行开发:
- 移动端: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 的通信流程分别如下:
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++ | 高 | 低 | 非C系列语言复杂 | 非C系列语言要用胶水层 |
Rust | 高 | 低 | 非C系列语言复杂 | 非C系列语言要用胶水层 |
KMP | 中 | 低 | 简单 | 简单 |
总结
简单来说,凡使用 C++ 跨平台方案的地方现在可以用Rust替代。Rust的语法更现代,代码更安全,且跨平台生态更好,特别适合处理一些通用的数据层逻辑。
转载自:https://juejin.cn/post/6970951874245558286