disclaimer: 目前这只是一个想法,并没有落地的实现。我打算将这一思想在 quenya client 中实现。
目前,应用程序越来越复杂,想覆盖的用户群体越来越广泛,客户端,或者说大前端涉及的平台也就越来越多。以我经常使用的 Notion / slack 这些 app 为例:
- 移动端:iOS,android
- 桌面端:osx,windows,linux
- 网页端
如果在所有这些端上使用原生的技术,用户体验毫无疑问是最佳的,然而开发效率却是最低的。为了营造很不错的用户体验,同时又有足够好的开发效率,市面上诞生了一大批跨平台解决方案:
- 移动端:apache cordova,react native,weex,flutter 等
- 桌面端:QT,apache cordova,electron,flutter (alpha) 等
- 网页端:flutter (beta)
这些解决方案各有优劣,我就不一一对比,但从架构的角度,大致是以下几种模式。
跨平台解决方案的现有模式
桥接
桥接要解决的核心问题是两种语言(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(这个要命的决定是很多 RN 开发者的梦魇)。
桥接是很古老且自然的技术解决方案,我们在 Python/JS 中使用 C/C 代码,Elixir 中使用 Rust 代码,其实都是在两种语言中做了一个中间层,来协助通讯。只不过大部分时候这样的通讯是在同一个线程中完成,所以是同步的,而 JS bridge 跨线程,异步通讯效率更高。
桥接的代表是:Cordova / React native。两者的区别是在 Cordova 里 UI 层完全由 WebView 里的 html/css/js 接管,桥接只发生在 JS 和设备服务间;而 React native 为了更好的原生 UI 的体验以及更好的 UI 性能,对 UI 层也做了桥接。然而,由于在 JS bridge 层传递 JSON 作为通讯手段,当大量数据在两端传输时(复杂的动画,大列表的快速滑动),通讯层会来不及处理而 UI 层有卡顿的感觉。
进程间通信(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 差不太多(多数情况略好一些,少数情况差一些),在传输大容量数据时会遇到像 react native 一样的问题。所以 Electron 推荐使用 CSS animation,而非常不建议做 JS anination。
图片来自 "is postMessage slow?" [3]
Canvas 绘制
在做跨平台支持时,主流的处理 UI 的思路是:
- 用 JS 来调用原生 UI。这是 React Native 采用的方式。优点是大部分时候性能足够好;缺点是 JS bridge 需要适配所有支持的平台。想让同一套代码在不同平台跑出符合该平台的 HCI 要求的 UI 很多时候是强人所难。
- 用其他技术来模拟原生 UI。这是 Cordova / Electron 采用的方式。优点是代码简单,UI 直接在第三方渲染器(webview)中渲染出来;缺点是 UI 性能受 JS 单线程及 webview 本身渲染性能的影响,在复杂交互时往往表现不佳。
当大多数选择方案 2) 的技术栈都把目光投向 webview 相关的技术时,人们忽略了其实所有的 UI 渲染,最终都是在 canvas 上一个像素一个像素填充出来的。如果做一套系统,略过 dom/css/js 复杂的渲染逻辑,直接定制好各种各样的控件,将其绘制到 canvas 上,是不是获得了方案 2) 的好处,同时没有它的种种问题?
当然,canvas 绘制也有很多技术挑战,它意味着原生平台提供的整个 UI 系统以及消息循环系统都被其略过,因此这里面所有缺失的部分都需要重做一套,比如用户交互时引发的事件冒泡。
Canvas 绘制的代表是 flutter。它使用了 chrome 底层的图形渲染引擎 skia,从底向上设计出来一套可以高效工作的控件库。
现有解决方案的问题
目前所有这些解决方案着眼点都是如何用更少的代码能够做出高效统一接近原生的 UI。但不管什么方案,统一 UI 层的代码还有一个致命的问题:业务逻辑代码怎么办?也用 UI 层同样的语言撰写?那么谁来保证运行时的效率?像 Reactive Native 这样的框架可以花极高的开发代价帮你做 JS 到原生 UI 之间的映射,来让运行时尽可能有靠近原生 UI 的效率,但这些框架无法帮助你优化业务逻辑。如果不用 UI 层的语言撰写,而使用 native 层的语言,那么,费这个劲干嘛?
很多选择了某个 UI 跨平台解决方案的团队在开头一日千里的舒爽过后,渐渐发现「童话里都是骗人的」:自己要维护的代码库不止一套 —— 因为很多业务逻辑用 JS/dart 这样的语言并不适合,到最后可能 iOS 写一部分,android 写一部分,还得做对应的 JS bridge 接口。本来要提升开发效率的,做着做着变成开发团队的梦魇。
除此之外,这些方案还都忽略了一个重要的问题:如今 app 变得越来越复杂,复杂的不仅仅是 UI,还有业务逻辑。业务逻辑支撑着 UI,如何在所有平台上尽可能小代价地做出统一的业务逻辑,是一个比如何做出统一的 UI 更值得关注的问题。
发明快速傅里叶变换的数学家约翰·图基说:
那么,「如何在所有平台上尽可能小代价地做出统一的业务逻辑」这么一个值得关注的问题为何在开源界没有任何回应呢?我想了十天十夜,都想不通为什么没人搞。后来勉强得到一个答案:通用性。不是没人搞,而是各家公司私底下都有自己的解决方案,然而业务逻辑不像 UI 那样,可以做出一套非常标准的东西供别人使用,绝大多数情况下,A 家的代码就算无偿送给 B 家,B 家也顶多能从中得到一些思路,但几乎无法复用里面的代码。
还有一个潜在的因素:没有合适的工具。
在 Rust 成熟以前,C/C 几乎是跨端做业务逻辑的唯一的选择。比如你要在 app 里支持一套非标准的 crypto 算法,只有两个选择:
- 用各个平台的原生语言各自实现一套,要保证所有的实现都是一致的,并且以后升级,每个平台都需要相应更新。
- 用 C/C 实现一次,然后在各个端上用静态链接的方式编译到 app 中。当然,这免不了要做很薄的一层接口:每个平台原生语言到 C/C 的桥接。
一般而言,有实力有工程师资源的公司会选择 2,因为它是一劳永逸的方案:一开始的工作量不少,各个平台的桥接代码开发起来很痛苦,但一旦成型,以后的升级就顺滑很多。
然而,所有解决方案的背后都有代价。方案 2 的代价是:C/C 的代码(相对于 java/kotlin/swift来说)很难撰写,依赖管理,跨平台编译链接有很多坑要踩,就算实现了业务逻辑本身,在并发环境下,异步环境下,还是可能会产生无穷无尽的内存安全或者并发安全的 bug。最终的结果是代码的维护成本越来越高,让「一次撰写,到处链接」的好处最终变成灾难。这也是一般的 app 开发团队不敢去碰的一个重要原因,甚至,有些成熟的且有复杂业务逻辑的团队(如 dropbox)碰了之后又黯然弃坑的重要原因。[4]
好在,现在我们有了越来越成熟的 Rust,工具不再是障碍:Rust 有不输于任何一门现代语言的依赖管理和生态(各种各样高性能库任君采摘),有非常完备的跨平台编译系统和跨语言FFI 支持,而 Rust 本身的不依赖运行时的内存安全和并发安全性,还有几乎最高质量的 webassembly 支持,使其成为上述解决方案 2 中 C/C 的完美替代品。方案 2 的大多数代价在 Rust 中都不复存在,是否采用仅剩下一个关键问题:你是否能找到合适的 Rust 工程师让「一次撰写,到处链接」的 TCO 比用各个平台的原生语言各自实现一套?
这非常取决于业务逻辑的复杂度,以及是否有专门为此孕育而出的工具。幸运的是,除了 rust 本身的跨平台工具链之外,Rust 生态里还有专门为简化与 iOS 原生语言互操作的工具 cargo lipo
(封装 C FFI),以及为与 java 互操作的 jni
,甚至还有专门针对 Android 的 android-ndk-rs
。
接下来,我们需要的就是一套组织各个平台原生语言和 Rust 互操作的思路,来解决通用性的问题。
前端中的后端
啰啰嗦嗦这么多前菜后,我们终于开始聊到今天的正餐:前端中的后端。
所谓前端中的后端,就是在前后端分离的基础上,进一步把前端中偏 UI 的业务逻辑和偏数据处理的业务逻辑分开。而掌管数据处理的这部分功能,我们管它叫前端中的后端。
模型
显而易见的,无论是前端架构中被广泛使用的 MVC 还是 MVVC 模式,其第一个 M,Model(包含数据,状态,以及业务逻辑),就是我们要分离出来统一处理的「后端」。借鉴我们文章一开始提到的 JS bridge 模式,我们可以构想出来这么一套前端代码的前后端分离的模型:
这个模型和之前的各种关注 UI 的跨平台解决方案模型的最大不同是:让所有的相关方处理自己最擅长的事情,而不要强行适配。和平台相关的代码,比如 UI,平台设备的访问等,用更擅长做这件事情的平台原生语言实现(或者 flutter),而平台无关的业务逻辑代码,算法,网络层代码,使用 Rust 来实现。这样,Rust backend 不用去花大量的精力去包裹平台的东西,而只需干好一个 backend 需要干好的事情。
通讯方式
但是,我们知道,语言之间的 FFI 有很大的局限,Rust 有丰富的类型表达,而当我们想要把这样的类型数据传递给其它语言的时候,用 FFI 会让你非常抓狂,需要写很多呕吐代码 —— 见我之前的文章:当我做 hackathon 时我在做什么 (1)。此外,FFI 还破坏了 Rust 的安全性保证,来回传递数据的时候如果按照 C FFI 处理,那么需要大量的 unsafe
,以及一些额外的指针管理。所以,为了安全性和开发效率,我们不得不牺牲一些性能,对数据进行序列化/反序列化。这里我们借鉴了 JS bridge,或者说 JS bridge 所借鉴的前后端分离所用的解决方案:提供一个通信层,数据在此序列化/反序列化。
之前那些 UI 方案,采用的都是 JSON 或者类 JSON 的序列化方案,这可能是前端同学或者初识后端的同学的一个通病:「一见短袖子,立刻想到白臂膊」,啊不对,「一见数据,立刻想到 JSON」。JSON 是效率非常低下,且类型安全度比较低的一种序列化方案,在这样的场景下,我们还有更多更好效率更高类型更安全的方案,比如 protobuf,flatbuffers 等。
那位问了:人家 REST/GraphQL API 不都是用 JSON 做序列化么?为啥这个场景使用就有问题呢?嗯,那是因为当你的数据需要花几十甚至上百毫秒跨越千山万水传输的时候,多出来几毫秒序列化的时间无所谓了;但当数据之间的距离比巴掌还小(CPU → 内存 → CPU),几毫秒的序列化时间都是相当要命的。在 Kartik 的文章 "JSON vs Protocol Buffers vs FlatBuffers" 中,benchmark 了一下三者的性能 [10]:
可见 JSON 的低效。这直接印证了我之前的观点:像 JS Bridge 这样的通讯层,选用 JSON 是非常非常不明智的事情。
我们看看用 protobuf 的话,整个通讯层大概是什么样子?下图是 Mozilla 工程师在 Firefox Sync 上使用的方式[9]:
Rust 和 Kotlin 分别将定义好的 protos 编译成平台代码,然后可以在两端自由地传递 protobuf 的数据。
而一家名为 FullStory 的公司,也公布了他们使用 Rust 做 Mobile SDK 的方案[8],非常值得参考。和 Firefox 不同的是,他们在通讯层使用了效率更高的 FlatBuffers。得益于 Zero-copy deserialization,FlatBuffer 反序列化的性能比 Protobuf 快了一个量级,二者序列化的性能差别在几倍之内。由于 Protobuf 更容易上手和开发,使用者更加广泛,我们可以先使用 Protobuf 来构建通讯层,以后如果性能的瓶颈真的发生在 serialization/deserialization 这里,那么可以考虑切换成 FlatBuffers。
实现
我们先通过一个具体的业务场景来描述这个实现所涉及的一些细节,然后,再将其抽象出通用的部分。
以 Tubi 为例,要展示这样一个首页(以下介绍均为假设,并非 tubi 的实现方式):
后端会提供一个 API 获取电影列表。假设 API 是 GET /api/v1/get_movies
(我杜撰的)。那么前端一般的做法(假设使用 clean architecture)是:建立一个 TubiRepository
处理网络层的请求,请求的响应被反序列化成 Category
/ Movie
models,然后以 Entity 的形式交给 Use cases,最后在 presentation layer 被渲染出来,成为用户在屏幕上看到的内容:
这里,整个网络层,或者说数据层,是我们重点研究的对象。我们假定暴露给 native 层的方法是:.getMovies()
,它内部将参数序列化成 protobuf 传递给一个 Rust 函数 dispatcher
(为了简单起见,我简化了命名,如果是 android 的话,其符合 JNI 的命名方式要比这个复杂得多)。dispatcher
反序列化请求,得知该请求是 RequestGetMovies
,随即将其 dispatch 给 get_movies()
,get_movies()
会从本地 cache 里读取数据,读不到的话再通过 reqwest
从后端 API 获取数据并 cache 之。整个流程如下:
从 native 开发者的角度,她就调用了一个 .getMovies()
的函数,其它的细节,她一概不需要理会。如果返回的 status code 是 OK
,那么,她就可以直接使用反序列化好的 Movie
,Category
等数据结构。
我们再看另一个例子:用户在观看视频的时候,客户端会定期向服务器汇报当前观看的位置。假设这个 API 是 PUT /api/v1/update_history
,同样的,我们在 native 层暴露出一个 .updateHistory()
的方法,然后 dispatcher
将其 dispatch 给 Rust 函数 update_history()
,在这个函数里,我们会做 debounce,让最终的网络请求远小于用户端实际调用 .updateHistory()
的次数,节省服务器的开支。
从上述的例子,我们大概可以看到在 Rust 侧我们可以处理的工作:
- 更高效的网络层:自动管理的连接池,更好的流控,更灵活的安全处理方式,以及,UI 侧无感知的网络层处理,比如有一天我们把 REST API 升级成 gRPC,API 层的签名采用 schnorr signature,或者 HTTP/2 升级到 HTTP/3。native 侧根本无需关心。
- 更好的数据管理。Rust 有丰富高效的数据结构,可以为每一种数据设置量身定制的方案。我们还可以做非常高效的数据缓存。
- 在此之上给数据的赋能。比如为
get_movies()
获取到的数据做简单的索引,方便数据在各个不同维度的展示和过滤。
如何维护这样的「后端」代码?
既然我们把前端做了「前端的前端」和「前端的后端」这样的拆分,那么,一切原本属于前后端之间的 SLA,同样也适用于这里,但可以稍微灵活一些,因为整个前端的代码是一起发布的,不存在版本冲突的问题。由于双方的通讯是通过 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 作为 SLA,那么,甚至我们可以根据 Open API spec 里的信息,生成对应的 Rust 客户端调用方法,以及 Rust 和 Native 间通讯的 gRPC,最终生成所有接口代码。所以理论上,我们有很大的可能性根据 Open API spec 生成整个网络层的跨端代码,不用写一行代码,最终暴露给 native 侧一个简单高效好用的 .getMovies()
。不知道这样的理想能否有一天能在 quenya 中实现。
如何处理 Rust 侧的 event push?
上面讲到的调用流程都是 native 侧往 Rust 侧的主动请求。假设 Rust 侧有某些异步事件,比如 timer wheel 上有事件需要 native 侧处理,或者说来自服务器的事件(websocket push,GraphQL subscription),我们该如何从 Rust 侧向 native 侧通知呢?
道理是一样的,我们需要 native 能暴露给 Rust 侧一个类似的 dispatch
函数,由 Rust 侧调用。然而如果某种语言没有该语言到 C 的 FFI 接口,那处理起来就会麻烦很多。
一个更加通用但不那么高效的方式是 native 侧和 rust 侧之间通过 ZeroMQ 或者 Unix Domain Socket 来传递信息。这些是跨进程的解决方案,效率要比线程间直接传递消息低一个数量级。并且如果没有处理好,可能会有安全上的风险。
为什么不用 Kotlin native?
如果你是个移动端开发者,一定会有个疑惑,为什么不用移动端开发者更熟悉的 Kotlin 呢,毕竟 Kotlin Native 似乎有着一统客户端的雄心壮志?
Benedikt 在他的演讲 "Sharing Code between iOS & Android with Rust" [1] 也提到了这个问题。作为一个 Rust 技能树刚刚点开的移动端开发者,他做了一些简单的 benchmark。首先,他尝试对一个很大的包含各种数字的字符串进行小于 100 的数字的求和。
Rust 代码:
Kotlin 代码:
Swift 代码:
三者的代码非常接近,但性能却差几十倍:
Benedikt 又做了一个简单的 Array chunking 的函数,把数组切片,再切片,然后求和。代码依旧非常简单:
swift 代码我就不贴了。结果发现 Kotlin Native 运行的时候直接超时(可能是 语言的 bug):
由于 Kotlin 的代码运行时间太长,影响了这个图的可对比性,移除 Kotlin 后,rust 和 C 相差不大(20%),swift 比 rust 差了一个量级:
如果把 Swift 和 Kotlin 代码从上面的简单易懂的函数式写法改成更加冗长的命令式代码(用 forloop)后,性能一下子上来了,可见二者对函数式编程的支持还有很大改进的空间:
语言本身的能力之外,第三方库的效率如何?Benedikt benchmark 了 Rust 和 Swift 对 JSON 数据的处理:
二者有 17 倍的性能差距。所以,如果用 Rust 作为客户端来处理 REST API,每次 API 的请求能够节省大量的时间,尤其是很大的 JSON response。
在所有这些 benchmark 中,他还记录了内存使用情况:
在节约内存这块,Rust 是无可挑剔的王者。如果说 Swift / Kotlin 在编译器和第三方库上经过努力,还可以尽可能把和 Rust 的性能差距控制在一个量级之内,内存的占用,是很难优化的,它涉及到语言内部的实现细节。
所以,至少在这个阶段,Kotlin Native 还无法成为 Rust 在业务逻辑代码上跨端开发的一个有力竞争者。而且,我并不觉得它未来能够成为 Rust 在这块的竞争者。因为,没有多少 Kotlin 的开发者会严肃地开发高性能的第三方库,而 Rust 整个社区的氛围都是:更高,更快,更强。目前几乎所有新的算法和数据结构的出现,都会有对应的 Rust 的开源实现,而同样的 Kotlin 或者 Swift 的开源实现,则几乎要靠撞运气。
参考资料
- Sharing Code between iOS & Android with Rust: https://www.youtube.com/watch?v=-hGbMp0sBvM
- How the React Native bridge works and how it will change in the near future: https://dev.to/wjimmycook/how-the-react-native-bridge-works-and-how-it-will-change-in-the-near-future-4ekc
- Is postMessage slow? https://surma.dev/things/is-postmessage-slow/
- The hidden cost of sharing code between iOS and Android: https://dropbox.tech/mobile/the-not-so-hidden-cost-of-sharing-code-between-ios-and-android and its hacker news discussion: https://news.ycombinator.com/item?id=20695806
- Sunsetting React Native: https://medium.com/airbnb-engineering/sunsetting-react-native-1868ba28e30a
- Rust bitcode: https://github.com/getditto/rust-bitcode
- Rust once and share it with Android, iOS and flutter: https://dev.to/robertohuertasm/rust-once-and-share-it-with-android-ios-and-flutter-286o
- How do we use Rust in our mobile SDK: https://news.ycombinator.com/item?id=23008399
- Crossing the Rust FFI frontier with Protocol buffers: https://hacks.mozilla.org/2019/04/crossing-the-rust-ffi-frontier-with-protocol-buffers/
- JSON vs Protocol Buffers vs FlatBuffers: https://codeburst.io/json-vs-protocol-buffers-vs-flatbuffers-a4247f8bda6f
- uniffi - a multi-language bindings generator for rust: https://github.com/mozilla/uniffi-rs