XiaoboTalk

对比 RN 和 JS,深入聊聊 Flutter 和 Dart,以及未来的 AI 跨端编程

最近几年我个人的编码领域,好像被有意无意的框进跨端开发中;最早我做 iOS 开发,后来接触了前端开发和 Android 开发,再后来做低码开发、AI D2C 开发,接触和研究了很多和底层 VDOM 相关的开发,有些较为复杂的项目,还涉及到自定义视图树指令集、微型语法解释器相关的开发工作,这些项目都让我学到了很多开发框架上的知识。
最近一年,我工作的主要内容,也是跨端开发:基于 Hippy-Vue 的跨端开发 ,Hippy 是腾讯开源的跨端开发框架,几乎就是对 RN 的复刻,由于 Hippy 的社区不活跃,官方维护的也积极;又迫于工作需要,我几乎阅读了 Hippy 的整个源码。这让我对跨端开发有了很多思考和展望。
这些经历给我一个明显的感受,跨端开发会越来越流行,也越来越成熟了,除了特定领域的开发:例如 Apple Pencil 手写笔记,相机,图形开发等和平台硬件强相关的业务;日常的普通业务开发,使用跨端开发一定是能节省成本的。
另外,随着 AI 热,我个人认为,AI 跨端开发 Runtime 也会慢慢出现。

RN 是如何工作的

RN 主要基于 JS 语言 + JS 引擎,还有一个 RN 实现的宿主环境引擎。RN 自身来自 React 框架,天然有 VDOM,所以 RN 只需额外把这个 VDOM 结构翻译成 Native 真实的视图树即可,最终的 UI 还是被 Native 原生渲染,RN 引擎只负责翻译工作。

编译阶段

将 RN 编译为 JS 包,包是经过压缩的 JS 代码。JS 代码的主要功能,有两部分:
  1. RN runtime core,用于运行时操作 VDOM
  1. Native 包装器和宿主环境相关的全局变量。

运行时的 JS 引擎

RN 的运行时,就是执行编译阶段打包好的 JS 代码文件,这需要一个 JS 引擎,好在 JS 引擎有很多成熟的框架:
  1. V8 引擎,常见于 Chrome 和 Android 系统,都会被系统内置。
  1. JSC 引擎 (JavaScript Core),iOS 系统默认的 JS 引擎。
  1. Hermes,Facebook 开源的独立 JS 引擎。
RN 集成的时候,可以选择使用 Hemers 这个 JS 引擎,从而模型 JS 引擎的环境差异。引擎除了负责执行 JS,还会作为 JSI Bridge 的基座。
JSI Bridge 是 JS 和 C++ 零拷贝通信的桥梁,相比传统的 Bridge (使用 json 序列化和反序列化的通信机制),JSI,可以非常高效让 C++ 和 JS 通信,然后 C++ 再和 Native 通信。总结:
  • JS C++ 引擎 (V8/JSC/Hermes) 和宿主 App 工作在同一进程,所以共享内存空间,彼此通信有一次线程上下文切换开销。
  • JS 引擎在自己的独立线程中,不会干扰到 App 的主 UI 线程。
  • JS 的执行在 JS 引擎线程,JS 调用栈,是 C++ 程序的模拟栈,工作在同一个栈区,可以零成本交互。

Yoga 或者 Titans 负责 Flex 布局求解

前端开发的布局引擎,主流是 Flex 布局,但 UI 最终渲染的时候,需要确定每个元素的绝对定位。将 Flex 布局求解为绝对定位,由引擎内的 Yoga (RN,Facebook开源) 负责,Titans 也是一个 Flex 求解工具库,C++ 实现,由字节开源。

Dom Manager 翻译 VDOM 为客户端视图

这一层也是 C++ 实现,主要将 JS 虚拟 DOM 树,翻译为 Native 视图树,最终的 UI 被 Native 渲染。

其他的宿主 API 实现

这一部分也是依赖 JS引擎,使用 C++ 注入 JS的全局对象,API 等,例如实现 Global 对象,fetch 函数,console.log 等等。主要是为了对齐 Web API,让前端开发的时候,无需关心平台是否支持。

RN 是如何集成到 Native 的

由于历史原因,JS 最初是解释执行,只支持 JIT 编译,虽然后来 JS 引擎接入了 AOT 对热点函数进行字节码编译,这个编译过程也发生在运行时。所以 RN 代码的打包产物,还是一个压缩 JS 源代码文件。把这个文件放如 App 沙盒,App 运行的时候,先启动 RN 引擎 (内部保证 JS 引擎),然后加载沙盒里的 JS 源代码文件,开始 JIT + AOT 混合模式执行。由于这个特性,JS 相较于 Webview 性能较好,但对比 Flutter 和原生性能较差。

Flutter 和 Dart

来到 Flutter 这边,其实整体的原理和 RN 类似,但额外多了一个渲染引擎(Skia impeller),另外就是 Dart 语言,和 Dart VM 天生既支持 JIT,也支持 AOT;这让 Flutter 的集成和工作机制在 Debug 和 Release 有了明显区分。可以说 Dart 语言就是为 Flutter 而设计的。

编译阶段和集成

编译阶段,Flutter 需要支持 hot reload,会将代码编译为 Dart Kenal Snapshot 中间字节码,Debug 阶段的 Flutter 引擎内的 Dart VM 包含 JIT 模块,可以直接解释执行这种中间字节码,实现 hot reload。由于 Flutter 采用的是 Dart VM;不像 JS VM,iOS / Android 并没有内置 Dart VM,所以无论是开发阶段,还是发布阶段,都需要将 Dart VM 一并打包到 App 程序中。

iOS 端为例,Flutter 是如何集成的

最早接触和开发 Flutter 的时候,Flutter 的集成是比较繁琐的,当时工具链还不完善,很多集成工作都需要开发者自己完成,其中两个核心动态库:
  1. Flutter.framework,包含了 Dart VM、渲染引擎、Platform Channel、渲染管线、Dom manager 等。
    1. Debug 模式,Dart VM 需要同时包含 JIT 模块和 AOT 模块
    2. Release 模式,Dart VM 只需要包含 AOT 模块
  1. App.framework,Flutter 引用程序,Dart 编译的产物
    1. Debug 模式:既 Dart Kenal Snapshot 中间字节码 (通过 Flutter 引擎访问本地端口提供),也包含 AOT 执行所需的机器码(App.framework 本身)
    2. Release 模式,只是一个 AOT 机器码,高效执行。
由于需要严格区分 Debug 和 Release,集成开发的时候需要做很多工作,以 iOS / Cocoapods 为例,需要写一个 pod 插件,区分环境引入不同的模块到 pod 子工程。
不过,随着 Flutter 的不断发展,这一问题得到了有效解决,Flutter 提供了很多配套设施,以的Flutter 3.35.7 为例,Flutter 脚手架会直接将上边两个产物打包进应用程序的 app 压缩包:
$ tree Build/Products/Debug-iphonesimulator -L 1 . ├── App.framework ├── Flutter ├── Flutter.framework ├── FlutterIntegrate.app ├── FlutterIntegrate.swiftmodule ├── FlutterPluginRegistrant └── Pods_FlutterIntegrate.framework
然后,继续看 FlutterIntegrate.app 结构内部:
. ├── __preview.dylib ├── _CodeSignature │   └── CodeResources ├── Base.lproj │   ├── LaunchScreen.storyboardc │   └── Main.storyboardc ├── FlutterIntegrate ├── FlutterIntegrate.debug.dylib ├── Frameworks │   ├── App.framework │   └── Flutter.framework ├── Info.plist └── PkgInfo
app bundle 内会直接打进去 Frameworks 动态库,而 pods 工程只是个壳工程,用来告诉 pods 工程,这些路径下是存在动态库的。
$ file Flutter.framework/Flutter Flutter.framework/Flutter: Mach-O universal binary with 1 architecture: [arm64:Mach-O 64-bit dynamically linked shared library arm64] Flutter.framework/Flutter (for architecture arm64): Mach-O 64-bit dynamically linked shared library arm64
此外,Debug 模式下,也可以看到 Dart VM 中是包含 Snapshot 这个 JIT 模块的:
$ nm -gU Flutter.framework/Flutter | grep Dart 0000000001e52e00 S _OBJC_CLASS_$_FlutterDartProject 0000000001e53120 S _OBJC_CLASS_$_FlutterHeadlessDartRunner 0000000001e52e28 S _OBJC_METACLASS_$_FlutterDartProject 0000000001e53148 S _OBJC_METACLASS_$_FlutterHeadlessDartRunner 0000000000a0ce80 S _kDartIsolateSnapshotData 0000000000856220 T _kDartIsolateSnapshotInstructions 0000000001472240 S _kDartVmSnapshotData 0000000000856220 T _kDartVmSnapshotInstructions
另外 Flutter 在开发模式下,也方便了很多,每个 Flutter module 自带了一个壳工程 Runner 工程,可以直接在在 Flutter 工程下,运行:
$ flutter run
就会自动启动 Runner 程序,并保持 Flutter 热更新能力。一个 Flutter Module 工程的目录:
$ tree -L 2 -a . ├── .android │ ├── app │ ├── build.gradle │ ├── Flutter │ ├── gradle │ ├── gradle.properties │ ├── gradlew │ ├── gradlew.bat │ ├── include_flutter.groovy │ ├── local.properties │ ├── settings.gradle │ └── src ├── .dart_tool │ ├── flutter_build │ ├── package_config.json │ ├── package_graph.json │ └── version ├── .DS_Store ├── .gitignore ├── .idea │ ├── libraries │ ├── modules.xml │ └── workspace.xml ├── .ios │ ├── .symlinks │ ├── Config │ ├── Flutter │ ├── Runner │ ├── Runner.xcodeproj │ └── Runner.xcworkspace ├── .metadata ├── analysis_options.yaml ├── build │ ├── 892b637c16d52470fb7b14b9d9897587.cache.dill.track.dill │ ├── bc3cffc340e74ebd1dfb723aba8a6d51 │ ├── ios │ └── native_assets ├── lib │ └── main.dart ├── my_flutter_android.iml ├── my_flutter.iml ├── pubspec.lock ├── pubspec.yaml ├── README.md └── test └── widget_test.dart
开发好后,如果想在宿主 APP 集成调试,可以使用 :
$ flutter attach
启动调试,此时宿主 APP 中,打开一个 Flutter 引擎,默认会监听一个本地端口,等待 attach 程序发送 Dart Kenal Snapshot 中间字节码程序,实现 hot reload。

Dart VM 和 JS VM,Flutter 得 1分

对比下来,语言引擎这一层,一个 Dart VM,一个是 JS VM,由于 Dart VM 自身天然支持 AOT,所以,dart 可以直接 AOT 编译为机器码,让 Flutter 运行效率更高。同时,Debug 下,也可以像 JS 那样进行 JIT 执行,实时 Hot Reload,方便了开发和调试。这一点是 Flutter 的一大进步。

Flutter 渲染引擎,Flutter 得 1分

不想 RN ,需要将 VDOM 翻译为 Native 视图,Flutter 通过 Dart 写视图结构,然后一次通过:
  1. Widget Tree
  1. Element Tree
  1. Render Tree
  1. skia 或者 impeller 渲染引擎,渲染 UI
Flutter 可以自产自销 UI,完全独立渲染,整个链路完全自己掌握,UI 渲染的性能上限更高。并且很多基础的业务能力,也能很方便的支持:例如曝光埋点的实现,Flutter 自身在 Dart 侧就能自己判断视图是否曝光;而 RN 由于被翻译为了 Native,需要查询 Native Bridge 来判断。或者额外计算。

Dart FFI 和 JSI 通信

普通的跨端通信,Flutter 提供了 Platform Channel,这是通过通道的方式,序列号反序列化 JSON 实现的通信,相对低效,一些不频繁调用方法,适合使用 Platform Channel,但是需要频繁调用的场景和性能的时候,Flutter 也提供了 FFI 能力。
FFI 和 JSI 本质上都是跨语言交互,原理是一致的,都借助了 C++ 底层的跨平台能力实现。
  • Dart FFI 的本质就是 Dart 线程 ↔ C/C++ Runtime(FlutterEngine 内)零拷贝调用,原理和 JSI 十分相似:
    • Dart VM 是 C++ 实现,提供 native API 可以注册函数、访问内存
    • Dart 对象可通过 VM API 暴露给 C++,C++ 直接操作
理论上 JS 也可以实现 FFI,不过是通过 JSI 的方式提供。而 Flutter 对 FFI 做了很多基础建设,方便开发者使用。Dart VM 的 FFI 提供了 DynamicLibrary / Pointer / struct 映射,实现了语言层面的 FFI 对接,让开发者更快速的使用 FFI。
整体下来,我认为 Flutter 的设计更加彻底,唯一的缺陷是社区不如 RN,RN 源自 React,前端开发人员几乎可以零成本的开发 RN。
不过 Flutter 的能力上线显然更高,给 Flutter 投一票。

热更新和跨端技术的妥协

要说清楚热更新,需要先了解热更新的方案和一些背景。

解释执行 / JIT / AOT (RN +1 分)

通常来讲,热更新都是基于解释执行来完成的,虽然理论上基于 JIT / AOT 也可以,但是 iOS 平台限制不可以加载外部二进制文件,所以比较稳妥的热更新方案,只能基于解释执行来实现。
解释执行 / JIT / AOT 三者的主要区别,就是最终是否依赖 VM 虚拟机进行执行:解释执行必须依赖 VM,可以是解释执行源代码,也可以是字节码,此时 CPU 执行的是 VM 的代码指令,然后 VM 负责提供栈和寄存器的执行环境,用来解释执行源代码或者字节码。CPU 不会直接运行程序的执行,中间被 VM 模拟执行。
也就是 CPU —> 执行 VM 指令 —> VM 模拟出栈和寄存器环境,来执行源代码。
而 JIT / AOT 模式,最终的产物是机器码,或者叫二进制码,二进制码是面向特定终端的 CPU 指令集编码,可以被 CPU 直接执行。但 VM 还需要额外提供一些运行时环境,用来提供 GC 垃圾回收、异步调用机制、闭包、状态机等。而实际的指令直接被 CPU 执行。
CPU —> 直接执行程序机器码 VM 辅助提供 GC 、异步等运行时环境
其中 JIT 和 AOT 的区别在于,JIT 是在运行时一遍翻译源代码或者字节码为机器码,然后再提交给 CPU 执行这些机器码。AOT 则是在编译阶段一次性将代码全部编译为机器码,运行时无需额外翻译,CPU 直接执行机器码。
注意解释执行,虽然也是运行时边解释,边执行,但是没有翻译机器码这一步骤,VM 直接读取一条字节码,或者源代码,然后在自己的模拟环境中实现栈和寄存器执行环境,来虚拟实现程序执行,所以叫虚拟机。

字节码和机器码

字节码通常是一种中间格式 (IR),类汇编语言,通常是代码前端编译的产物,比如 JS / Dart 都能将源代码编译为 IR 字节码。此时的字节码,是平台无关的中间层,没有指令集相关性,只能被虚拟机 VM 识别,无法被 CPU 直接执行。所以只要将 VM 和字节码一起打包带走,就可以在任意平台上执行,实现跨平台。
而机器码,也就是二进制码,直接面向 CPU 架构和对应的指令集。可以被特定的 CPU 直接执行。可执行文件中存放的都是二进制指令码。

热更新以及平台安全性限制

热更新,即无需重新打包二进制文件,就可以实现执行程序的更新。理论上,只需要实现二进制文件运行时下载和替换,即可实现热更新。但是这样会让计算机程序很不安全,所以通常平台不会允许动态替换二进制码文件,每个二进制可执行程序在下载安装的时候就被平台进行了签名和加密,防止运行时无故篡改二进制文件。
所以直接在运行时下载和替换二进制可执行文件,是不合规的,无法实现。但是还有种二进制文件,不是可执行文件,而是动态库。相比可执行文件,可以简单的认为,动态库没有main函数入口程序,但是可以被程序在运行时动态加载。索引,我们可以将主程序(二进制可执行文件)中的部分业务代码,通过动态库的形式下发和打包。这样后续就可以通过更新动态库实现热更新。
然后,这一方案,在 Android 平台上可以实现。在 iOS 上不可以,iOS 还限制了,不能运行程序包(主 bundle)以外的动态库。所以,无法在运行时候动态下发和执行一个外部动态库。
所以,为了兼顾 iOS 平台,只能通过下发源代码(比如 js 代码),或者字节码(IR 码),然后配合 VM 的解释执行,来动态的执行一个源代码或者字节码文件。这个文件可以是 APP 主 Bundle 以外的路径下的文件。

性能

很明显,二进制机器码,可以直接被 CPU 执行,也可以更好的在编译器做编译优化,代码执行效率最高。
  • 其中 AOT 直接一次性将代码编译为机器码,运行时无需额外工作,直接逐行执行即可。
  • JIT 虽然也通过编译为机器码,让 CPU 执行,但是有运行时开销,程序运行阶段,先将源代码或者字节码翻译为二进制,然后交给 CPU 执行。这一过程 VM 扮演的是翻译角色。效率比 AOT 差一些。后来的 JIT 加入了热点函数编译缓存能力,相对提供了 JIT 的执行性能。
解释执行流程中,VM 不是翻译官,而是执行环境,相当于 CPU 先执行 VM,VM 再执行程序,多了一层中间层,所以性能自然差。此外,解释执行也有运行时开销,需要在运行期间,先解释代码,然后 VM 再执行。性能进一步被拉低。
 
所以,要想实现热更新,就需要通过解释执行的方案,那么性能就无法保证。想要性能好,就需要通过 AOT 提前编译为二进制码,这样执行性能最好,但是无法实现热更新。
RN 在 0.7 版本之后,release 下会,将 JS 代码先编译为字节码 bytecode,然后打入程序包中,然后运行时通过 JS 引擎解释还行字节码来运行。所以天然支持热更新,并且相比刚开始直接解释执行 JS 代码,性能要高一些。
Flutter 在 Release 模式下,则直接生成 AOT 二进制机器码,运行性能最好,但是无法在 iOS 上实现热更新。
鱼和熊掌不可兼得。

我想自己基于 wasm + vm 的方案实现一套全新的跨平台框架

(未完待续)