Ohhnews

分类导航

$ cd ..
foojay原文

用Java构建Flutter应用性能更优

#codename one#flutter#java#跨平台开发#性能优化

[LOADING...]

我通常在功能落地的当周就写博客介绍。这个功能几周前才落地,而在此之前已经开发了好几个月:Codename One 现在可以拿 Flutter 应用的 Dart 源码,把它构建成 Codename One 应用。结果令人惊叹。我压着这篇文章没发,是因为我知道这些说法有多具爆炸性。我希望数据来自每次提交都会运行的 CI 任务,而不是我在自己笔记本上调过一次的基准测试。

Codename One 是什么? Codename One 是一个开源框架,可以用一套 Java 或 Kotlin 代码库构建原生 iOS、Android、桌面和 Web 应用。了解更多请访问 codenameone.com。

与此同时,我们还增加了对 Kotlin Multiplatform + Compose 的支持、对 Swing 和 JavaFX 大部分能力的支持,以及对经典 Android 应用运行在 iOS 上的支持。还有更多在路上。仍然让我惊讶的是,在我们做对比测量的地方,Codename One 相较于原版表现有多好。同一个 Flutter 应用、同样的 Dart 文件,由 Codename One 构建后启动更快、占用内存更少、交付的代码更小。这一点在 Android、macOS、Windows 和 Linux 上都成立,体积优势在 iOS 和 Web 上也同样成立。

同一份 Dart,两种构建

这是 Flutter Gallery,Flutter 团队自己的展示应用。左边是 Flutter 构建的版本。右边是 Codename One 用同一份 Dart 源码构建的版本,未做任何修改。

下载并排对比视频。

仔细看你会发现两者并非像素级一致。在 Reply 收件箱里,Flutter 侧的头像是圆形,我们这边是圆角方形。部分位置的文本换行会差几个字符。翻译并不完美,开发者指南一开头就说明了这一点:Dart 支持是实验性的,某些回调和参数会被接受但随后被忽略,而且目前我们只针对一个大型应用做了验证。如果你要迁移 Flutter 应用,请测试每个屏幕所依赖的所有交互。

但难的部分已经跑通了。布局、导航、主题、文本输入、列表以及 Material 和 Cupertino 控件都能从原始源码渲染出来,你得到的是一个真正的 Codename One 应用,而不是一张截图。

在 Codename One 项目中迁移一个项目只需一条命令:

mvn cn1:import-flutter-project -Dcn1.flutter.import=/path/to/flutter_app

它会复制 lib、资源和字体,把 Flutter 运行时加入构建,生成主类,并告诉你哪些 pub 依赖已被覆盖。构建结果不需要 Dart 或 Flutter SDK。转译器本身是 Java 写的,运行在常规的 Maven 构建中。

本周预告

今天讲的是 Flutter 这件事及其背后的数据。之后每天一篇:

[LOADING...] Compose,运行在 Codename One 上。[LOADING...] Bastion 的安全中心。[LOADING...] Reel 的目录页。[LOADING...] Android 代码,运行在每个目标平台上。

数据

Codename One 的每次提交都会构建两次 Flutter Gallery:一次用 Flutter 自己的 release 构建,一次用 Codename One。CI 在同一台机器上以交错轮次测量两者,因此如果运行器中途变慢,会同等惩罚两个构建。下面的数据来自 10 月 29 日的运行。

[LOADING...][LOADING...] [LOADING...]

CI 收集 26 项指标:六个平台上的安装体积、可执行代码和下载体积,外加四个能公平测量双方的平台上的冷启动和静息内存。Codename One 在全部 26 项上领先。Android 的计时来自模拟器,iOS 的启动和内存未测量;这两点下面都会解释。差距最大的是 Android——应用的启动时间只有三分之一、代码量只有四分之一;以及 Linux——静息内存为 82 MB 对 224 MB。

这个对比是有利于 Flutter 的,而不是相反。这个应用是 Flutter 自己的展示应用,由 Flutter 团队用 Flutter 的惯用写法编写,而 Codename One 是原样运行它。为此,Codename One 携带一个运行时,在 Codename One 组件之上重新实现 Flutter 的 widget、element 和布局模型。这一层比直接编写的 Codename One 代码更慢;Compose 那篇文章展示了同样的效应——同一个屏幕的命令式 Java 版本只做声明式版本三分之一的工作。Codename One 应用从来不需要为这一层付出代价。我们是在为它付出代价的同时赢的。

CI 还用 Java 和 Dart 运行十一个计算负载(哈希表、递归、字符串拼接、排序、算术等)。在我们测量的每个原生平台上,Codename One 整体都更快,从 Android 的 1.3 倍到 Windows 和 Linux 的 1.9 倍:

[LOADING...]

Flutter 拿下 43 个单元格中的 5 个。其中两个差距在 6% 以内。真正值得注意的是 Android 和 macOS 上的对象分配,Dart 的分配器对短生命周期对象很快;这已经列入我们的待办清单。

Web 是例外,也是 Flutter 唯一更快的地方。我们把它排除在对比之外,因为 Flutter 的 Web 构建会把某些计算负载算错:整数算术、随机数组访问和快速排序返回的校验和与其他所有平台上的同一份代码不同,原因是 Web 上的 Dart 把数字当作 JavaScript 的双精度浮点数运行。算错答案的基准测试无法与算对的基准测试比时间。在双方结果一致的计算负载上,Flutter 领先。浏览器天生是异步的,而这是 Flutter 的模型对它有利而非拖累它的唯一环境。在体积上我们依然占优。

有些东西没有被测量,图表选择留空而不是猜测。Flutter 无法为 iOS 模拟器提前编译 Dart,因此 iOS 的启动和内存会变成拿 Flutter 的调试引擎对比我们的 release 构建。我们只报告 iOS 的体积。Web 启动需要基准测试目前还没有的浏览器驱动。Android 的计时来自模拟器,因此它比较的是同一台机器上的两个构建,而不是对手机的预测。方法、测试框架和原始数据都在开发者指南的 Flutter 章节中,基准测试位于仓库的 scripts/flutter-bench。

兼容层为何能比原版更快

这些兼容层都是在构建期运行的工具,没有一个是运行在你应用里的虚拟机。

flowchart LR
    D[Flutter / Dart] -->|transpiled to Java| J
    K[Kotlin Multiplatform / Compose] -->|kotlinc + Compose UI layer| J
    S[Swing / JavaFX] -->|bytecode remapped| J
    A[Android app] -->|resources compiled, bytecode remapped| J
    J[Java bytecode + Codename One core] --> P[ParparVM / native ports]
    P --> I[iOS]
    P --> N[Android]
    P --> M[macOS / Windows / Linux]
    P --> W[Web]
    P --> O[watchOS / tvOS]

Dart 转译器在 generate-sources 阶段读取你的 widget 源码并写出 Java。随后一个运行时库在 Codename One 容器之上实现 Flutter 的布局与组合模型(约束向下、尺寸向上、element 与 widget 对账)。它不重新实现 Flutter 的光栅化器,绘制是我们的。Compose 也是同样的方式:kotlinc 编译你的 Kotlin,Compose 运行时原样发布,只有 UI 层是我们的。Swing、JavaFX 和 Android 应用照常针对它们惯用的 API 编译,然后一个重映射步骤把字节码指向我们的实现。

所以开销是真实存在的,而且是加在 Codename One 之上的。结果依然胜出的原因在于底层。ParparVM 把 Java 提前编译成 C,再编译成本地二进制,其死代码消除会丢弃应用触达不到的一切。包里没有 Dart VM,没有 Skia 或 Impeller 引擎,也没有第二个运行时。这就是 Android 构建的可执行代码只有 4.7 MB 而对方是 20.6 MB 的原因。

下面是调用平台的 Dart 代码,就是普通的 Flutter 代码:

final batteryChannel = MethodChannel('com.example.app/battery');

Future<int> getBatteryLevel() async {
  final level = await batteryChannel.invokeMethod('getBatteryLevel');
  return level as int;
}

下面是这个通道的另一端,用纯 Java 编写,不需要插件,也不需要各平台各自的 Swift 或 Kotlin:

MethodChannel channel = new MethodChannel("com.example.app/battery");
channel.setMethodCallHandler(new MethodChannel.MethodCallHandler() {
    @Override
    public void onMethodCall(MethodCall call, MethodChannel.Result result) {
        if ("getBatteryLevel".equals(call.method())) {
            result.success(87);
        } else {
            result.notImplemented();
        }
    }
});

你也不必迁移整个应用。FlutterUI.wrap() 返回一个普通的 Container,因此一个 Dart 屏幕可以放在 Codename One 表单里,与你用 Java 编写的组件并排:

Form dashboard = new Form("Dashboard", BoxLayout.y());
dashboard.add(new Label("Written in Codename One"));
dashboard.add(FlutterUI.wrap(new Text("Rendered from Dart")));
dashboard.add(new Button("Also Codename One"));
dashboard.show();

理念相近

Flutter 和 Codename One 押了同一个注。两者都不封装平台的控件。它们都在轻量级画布上自行绘制每个组件,因此你能控制每一个像素,行为也不会在平台之间漂移。Codename One 仍然提供原生主题,因此应用可以在 iPhone 上看起来像 iOS、在 Android 上像 Material,或者在所有地方都呈现你的品牌外观。正是这种共通的架构,让 Flutter 的 widget 树能如此自然地映射到 Codename One。差异在于核心之外的部分:线程模型、应用如何构建,以及功能如何发布。

Flutter 不能照做吗?

不能。理论上 Flutter 可以像我们运行 Flutter 应用那样运行 Codename One 应用。但那会很慢,因为两个模型的方向相反。

Flutter 的核心是异步的。Dart 运行在事件循环中,平台调用是以 future 形式返回的消息,引擎在自己的线程上绘制。Codename One 是同步的:你的代码调用平台并得到答案。在异步 API 之上模拟同步 API,意味着每次调用都要排队、等待和拷贝。反过来则很廉价:future 就是回调,而回调就是方法调用。

同样的原因也解释了为什么 Flutter 在大多数平台上追不上 Codename One。操作系统、图形 API 和硬件都是同步的。Codename One 直接调用它们。Flutter 则要经过消息通道和自己的引擎才能触及它们。

Android 让这一点一目了然。Android 上的原生平台就是 Java。Codename One 编译到 Java,并以 Android 期望的方式与 Android 对话。Flutter 避开它,在手机已有的引擎之外再附带自己的 VM 和引擎。这就是 Android 差距最大的原因:三分之一的启动时间和四分之一的代码量。## 你每天都能感受到的两处差异

线程模型会体现在基准测试里。下面这两处不会,但你每天工作时都能感受到它们。

构建器

Codename One 应用是一个 Java 项目。原生应用的构建发生在构建器中:我们的云构建服务器或本地构建,它们接收你编译后的代码,并生成 iOS、Android 或桌面端的二进制文件。这相当于把开发者体验中最困难的部分交到了我们手上:

  • 在 Windows 或 Linux 上构建 iOS 应用。 云构建器承载了 Mac、Xcode 和签名步骤。你不需要拥有一台 Mac 就能发布到 App Store。
  • 低成本的 CI。 你的单元测试和 UI 测试以 Java 运行、在模拟器中运行,且可在任何 CI 运行器上执行。无需启动模拟器,也无需为每次提交支付 macOS 运行器的费用。
  • 新功能无需迁移工作。 当 Apple 或 Google 更改要求,或我们新增某项能力时,我们会更新构建器。你的项目会在下一次构建时自动获得这些变化,无需升级或重新配置。

一切都在一处

Codename One 过去追求模块化的核心,把功能拆分到 cn1libs 中。Flutter 现在正经历这个阶段,将其 Material 和 Cupertino 主题等部分从框架中移出,变成独立的软件包。我们走的是相反的方向。Codename One 核心现在覆盖了 Flutter 所做的事情,以及人们在 Flutter 最流行软件包中所需的大部分内容,而本周的文章又增添了更多。

更大的核心不会让你付出任何代价,因为你不会为没有使用的东西付费,这在两个层面上都成立。ParparVM 会剔除应用访问不到的每一个类。构建器更进一步:它会检查你编译后的代码调用了什么,只在某个功能需要时才添加相应的原生库。本周的加密支持就是一个很好的例子。其针对 secp256k1、Ed25519 和哈希的原生内核正是让它快的原因,而一个从不接触加密 API 的应用不应该携带它们。它确实不会。调用钱包或签名 API 时,构建器才会把它们链接进来,你便获得了原生速度——我们的 CI 会对照参考库(包括 libsecp256k1 和 OpenSSL)进行测量。上面的构建之所以比 Flutter 的更小,原因就在于此。取而代之,你得到的是:

  • 一个版本。 无需维护一堆需要相互兼容的软件包版本矩阵,也不会因为两个软件包意见不合而导致升级失败。
  • 没有弃置的依赖。 不会有你的应用所依赖、而其作者两年前就停止维护的软件包。
  • 短的供应链。 这是最重要的一点。依赖树中的每一个软件包,都是你把用户设备托付给的一个人。一位维护者如果心力交瘁、卖掉软件包、心生不满或被黑,就可能在下次更新时把代码推入你的应用,而针对开源软件包的攻击正是这样发生的。当功能都在核心中时,你应用的依赖就是一个项目、一套审查流程,而不是一棵由陌生人提供的软件包树。这与本周的其他内容一脉相承:Bastion 模板之所以是一个加固的银行系统,是因为我们认真对待安全,而银行和其他受监管的组织都在 Codename One 上构建应用。对他们来说,短的供应链是一项硬性要求。
  • 一切同步演进。 当我们修复渲染、新增平台或更改构建时,每项功能都会随之更新,包括那些小众功能。
  • 单一事实来源。 开发者指南记录了所有内容,当出现问题时,有一个团队负责给出答案。

我们终于可以展示代码了

多年来,每一场 Codename One 与 Flutter 的讨论都如出一辙。我会解释为什么采用轻量级 UI 的预编译 Java 应该能产出更小、更快的应用。有人则会解释为什么不会。我们双方都没有用两种方式构建同一个应用,所以最终取决于你本来就属于哪个阵营。

现在有一个应用,用两种方式构建,并在每次提交时进行测量。如果你认为某个数字有误,测试框架就在代码仓库中,你可以自行重跑。

我没预料到的是,这件事让 Codename One 提升了多少。每一层兼容性都暴露出我们多年来遗漏的问题:

  • 样式被反复解析。 每个组件都会从主题中重新解析其按下和禁用状态的样式。将它们缓存后,Gallery 中的组件创建时间从 131 毫秒降至 17 毫秒,常驻内存从 247 MB 降至 144 MB。
  • 每个字符串常量都在启动时创建。 ParparVM 在 main() 运行之前就把每个字符串字面量实例化。将字符串池改为惰性加载后,这一步的耗时从 119 毫秒缩短到 46 毫秒。
  • 圆角图片被复制。 为图片添加圆角会构建第二个位图,在首帧期间发生了 32 次。改在片段着色器中处理后,消除了这些复制,并阻止了内存在不同运行之间来回波动。
  • 图片在首帧内解码。 将解码推迟后,首帧时间从 302 毫秒降至 213 毫秒。
  • 空闲应用仍在消耗 CPU。 Compose 相关工作发现并修复了一个 bug:在带刘海的手机上,安全区域内边距不断触发布局排队,导致空闲应用的 CPU 占用率维持在 14% 到 26%。

这些修复没有一项是 Flutter 专属的。如果你用纯 Java 编写 Codename One 应用,从不接触 Dart 文件,你的应用同样能获得这些改进:更快的启动、更低的内存占用,以及真正保持空闲的空闲 CPU。

本周更多内容

你的 Compose 代码,运行在更多屏幕上

Kotlin Multiplatform 是一个不错的平台,Compose Multiplatform 也构建得很好。Codename One 现在可以让同一份 Compose 代码运行在更多目标平台上,在我们的基准测试屏幕上每帧约占四分之一的 CPU,并允许你在关键之处下沉到 Java。

[LOADING...] 同一个 Compose 屏幕:左边是 Compose Multiplatform,右边是 Codename One。

$ kotlin
class MyApp : Lifecycle() {
    override fun runApp() {
        ComposeForm { LayoutScreen() }.show()
    }
}

周六的 Compose 文章 给出了具体数字及其注意事项。

手机上的 Swing 和 JavaFX

大量真实的 Swing 和 JavaFX 代码现在可以为 iOS、Android 和 Web 构建:我们测试的示例包括 JDK 自带的演示程序 SwingSet2 和 Notepad。在 Linux 上,原生构建的压缩体积比同一应用在 JVM 上小 4 到 6 倍,并且在全部 8 个应用中都启动更快,即使对手是用 AppCDS 调优过的 JVM。

[LOADING...] JDK 演示程序 SwingSet2 在 iPad 上。 [LOADING...] 手机上的 JavaFX 2048 游戏。 [LOADING...] Swing 控件。 [LOADING...] JTable。 [LOADING...] JavaFX。 [LOADING...] FXML 与 CSS。

周日的 Swing 和 JavaFX 文章 详细介绍了哪些内容可以沿用,以及为什么你不会怀念那些不能沿用的部分。

一个可供研究的银行,以及核心中的钱包

我们的用户中有很大一部分是银行和金融机构。Bastion 是为他们准备的参考实现:一个虚拟银行和小型交易所,具备通行密钥、TOTP、签名请求、设备密钥、哈希链式审计日志、复式记账账本以及审计员控制台。Codename One 还新增了以太坊、比特币和 Solana 的钱包 API。

[LOADING...] 四种货币。 [LOADING...] BTC/USD 市场。 [LOADING...] 安全中心。

$ java
Wallet wallet = Wallet.fromMnemonic(words, "");
EvmAddress eth = wallet.getEvmAddress(0, 0);
BitcoinAddress btc = wallet.getBitcoinAddress(BitcoinNetwork.MAINNET, Wallet.BIP84, 0, 0, 0);
SolanaAddress sol = wallet.getSolanaAddress(0);

周一的 Bastion 文章 逐层讲解了加固措施。

流媒体与课程

Reel 是一个 Java 项目中的流媒体服务及其服务器。切换一个预设,它就能变成订阅服务、YouTube 风格的创作者平台,或是一个包含课程、课时和测验的学院。

[LOADING...] 目录。 [LOADING...] 播放器。 [LOADING...] 课程。

$ java
StreamRequest req = new StreamRequest(manifestUrl)
        .setDrm(new DrmConfig(DrmScheme.WIDEVINE, licenseUrl));

周二的 Reel 文章 详细介绍了上传、审核和 DRM。

Android 代码无处不在,包括 GrapheneOS

Android 兼容层现在覆盖 Room、WorkManager、定位、传感器、前台服务和文档选择器 intent,而且这些在 iOS 上也能运行。GrapheneOS、LineageOS、CalyxOS 和 /e/OS 现在也获得官方支持,并且有一条由 CI 验证的边界:同一个 APK 在运行时检查 Play Services,而推送通过 UnifiedPush 工作。

[LOADING...] Material 3,左边是 Android,右边是 Codename One。 [LOADING...] RecyclerView,同一份源码。

周三的 Android 文章 提供了详细信息。

现代后端,以及会打字的模拟器

模拟键盘在一个注册表单上滑出。

周四为一周收尾,带来八项后端升级(有界缓存、出站 OAuth 令牌、PKI、JSON 编解码器、流式上传、错误建议、ORM 中的 java.time 以及测试切片),以及一个能滑出虚拟键盘并录制视频的模拟器。详见 后端与模拟器文章。## 通用平台

上周我论证了 Java 文字处理器胜过其 Rust 版本。本周的变化更大。Flutter、Compose、Swing、JavaFX 和 Android 代码都能构建成同一类应用,并运行在 iOS、Android、桌面端、Web 和手表上。

你不再面向操作系统编程,而是面向 Codename One 编程,Codename One 则成为操作系统。这种抽象通常要付出的代价是性能。我们不收这个代价。你能获得更高性能、更多功能、更好兼容性和更多平台,而且免费且开源。

如果你有 Flutter 应用,请在一份副本上运行导入,并为模拟器构建它。告诉我们哪个界面最先出问题;这是我们现在能收到的最有用的 bug 报告。如果你使用 Kotlin、Swing、JavaFX 或 Android,本周剩下的内容就是为你准备的。如果你认为上面某个数字有误,基准测试就在代码仓库里。重新运行它并提交 issue。