跨端架构的边界经济学:从 Flutter+Rust 到 Go 的技术路线综述

胡说八道,异想天开,切勿尽信

Dev

这篇文章来自一次持续很久的技术讨论。起点是”为什么越来越多 Flutter 项目用 Rust 写模块”,终点停在 Go 面前。中间经过 KMP、Rust 共享核心、React Native、Expo、Kuikly、Weex、纯 Rust UI。走完之后我确认了一件事:跨端选型的关键不在框架排名,而在于那条把代码切成两半的分界线画在了哪一层,以及它由谁守着。

一、起点:Dart 并不慢,问题在别处

讨论跨端选型最常见的错误,是把”我想引入另一门语言”包装成性能焦虑。Dart 的 AOT 编译产出真正的机器码,对绝大多数应用来说,“Dart 太慢”通常是渲染、重建、线程或内存问题被误判成了语言性能问题。

真正推动 Rust 落地的是三类任务:需要确定性内存行为的场景、需要真正底层能力的场景(共享内存、SIMD、直接操作原生句柄),以及最关键的一条——需要被别的进程、别的语言、别的端加载。

最后这条是后续所有讨论的枢纽。过去从 Flutter 调原生代码要走 platform channel,每次调用都有序列化和线程切换开销,还要同时维护 Kotlin、Swift、Dart 三份实现。FFI 把这条路径压成了进程内的函数调用,这是 Rust 方案能成立的技术前提。所以驱动力首先来自核心资产从界面变成了引擎,而非性能不够。

AppFlowy 的架构思路说得最直白:UI 层刻意做薄,只负责渲染,把复杂业务逻辑全部放进 Rust 的数据层。这样即使将来要更换 UI 框架,数据层一行都不用改。这个动机的本质是分离两种变化速度不同的东西。

二、五种形态,三种画法

把讨论过的方案按”共享与不共享的分界线画在哪一层”重排,比按语言重排清楚得多。

形态分界线位置共享的内容已被验证的场景主要代价
KMP + 各端原生 UI画在 UI 之下模型/仓库/用例/网络/存储企业级 AppiOS 侧适配层、双内存策略
Rust 核心画在业务逻辑之下加密/协议/同步/调度/DB 内核Signal、1Password、Bitwarden、飞书UI 双写、桥接多份
React Native画在系统控件之上逻辑 + UI(原生控件)Meta、Shopify、Discord线程模型、依赖升级
Flutter画在自绘引擎之上逻辑 + UI(自绘)BMW、闲鱼部分业务平台特性滞后、包体积
Flutter + Rust画了两次数据层 Rust,UI 层 FlutterAppFlowy两套语言、两条线各自维护

五行归成三类:前两行把线画在 UI 之下,中间两行画在 UI 之上,最后一行画了两次。

这张表里最激进的是最后一行。AppFlowy 把分界线画了两次:数据层用 Rust,换的是”换 UI 框架时数据层一行不用改”;UI 层用 Flutter,换的是”一套界面代码跑多端”。第一次切的收益见第一节,第二次切的代价见第八节开头。它之所以能这么干,是因为它本来就需要一个跨端的桌面产品,两层的复用都被充分利用了——对大多数团队来说,画一条线就要付一份维护成本,画两条是两倍。

至于为什么有的方案能把分界线画到 UI 之上、有的只能画到 UI 之下,根本原因是有没有一个愿意投入的渲染层。KMP 官方不提供 UI 跨端(那是 Compose Multiplatform 的事),Flutter 自己养了一整个渲染引擎,Kuikly 则是把高阶组件在原生原子之上重写了一遍。

这里需要澄清一点:KMP 本身是技术平台,可以承载 Compose Multiplatform 甚至 Kuikly 这类共享 UI 的方案。本文讨论的是”KMP + 各端原生 UI”这一种具体形态,不是 KMP 的全部可能。

关键差异在于这条分界线画得有多深。KMP 复用的代码面比 Rust 核心大得多——网络、存储、用例这些都在线内;但在某些场景下反而不够用——因为有些东西它这条线覆盖不到:需要被 .NET 或 Python 消费的 SDK、需要 SIMD 的实时图像处理、需要在没有运行时的环境里跑。这些属于引擎,不属于应用逻辑。要共享它们,就得把线往下移。

三、飞书:一个入口函数撑起多端

这是整套讨论里技术含量最高的案例,也是”边界收窄”思想的极致实现。

飞书从 2017 年初引入 Rust,2019 年在 QCon 北京首次公开。Rust 承接非 UI 的全部逻辑,官方表述是覆盖五端;材料里点名的四个是 iOS、Android、鸿蒙、PC,第五个未写明,本文不猜。整个核心对外只暴露一个 C ABI 入口(以下为示意签名,省略了 unsafe、extern "C" 等修饰,只为说明参数形状):

ffi_call(
    *const u8,                              // 请求数据指针
    usize,                                  // 长度
    Option<extern fn(*const u8, usize)>,    // 可选回调(None = 同步)
)

请求与响应统一用 Protobuf 序列化成字节流过桥,靠 payload 里的命令标识分发;同步和异步共用同一入口,由第三个参数是否为 None 决定。这个设计最大的收益是:加业务功能不需要改各端桥接代码,边界可以长期不动。代价是 Protobuf 序列化不适合大数据量和高频调用,所以他们对高频场景考虑 Cap’n Proto / FlatBuffers,性能敏感时直接传裸类型。

线程模型也值得抄(依据 QCon 演讲内容整理):两条入口——服务端推送走 websocket thread,原生 UI 主动调用走 client thread——汇入 thread pool,结果统一由 callback thread 回调给 Client,UI 线程永不阻塞。

内部的 crate 分层同样有参考价值——业务域(lark-chat、lark-calendar)、横切逻辑层(lark-logic)、各域基础层(*-base)、共享地基(lark-share)。规则是每个 crate 可独立构建测试,底层 crate 不常变,过大的 crate 会成为并行瓶颈。这形成一组反向张力:内部要拆细(为了编译速度),对外要收窄(为了边界稳定)。拆内收外,是这套架构的核心手法。

但飞书证明了能走通,成本还得说清楚。LINE 2024 年的工程评估给了一个很具体的对照:Swift 5.5 于 2021 年 9 月正式发布,而 UniFFI 相关支持到 2023 年 2 月才合并,滞后约 17 个月。跨版本稳定的 C ABI 是这条路线最大的优点,它让边界能撑很多年;但”等绑定生成器跟上”也是最真实的风险。同一份评估里还有两个数字值得记:Rust 静态库 25MB,而不含 Rust 的空 Demo App 只有 130KB——注意这是文件大小,不等于最终 App 的安装包增量。另外,作者用同一段 AES 对比 Rust 与 Swift CryptoKit,但示例里一侧是 CBC、另一侧是 GCM,模式不同,这类数字不该用来推导语言性能。

飞书与 LINE 之外,还有第三套桥接工具值得单独看:flutter_rust_bridge。 三大生态各有一套:飞书是自研单入口,LINE 用 UniFFI,Flutter 生态的事实标准则是 flutter_rust_bridge——AppFlowy 生成 Rust 与 Dart 之间绑定的那一步,用的就是它。它做的事比一般 FFI 封装多:根据 Rust 签名生成类型安全的 Dart 接口、把同步函数包装成 Dart async、把 Rust 的 Stream 映射成 Dart 流、把 trait 对象转成 Dart 抽象类,还配套 cargokit 把 Cargo 构建嵌进 Flutter 的构建流程。换句话说,桥接层不是”粘一下”就完事,它有自己的版本节奏、自己的 bug、自己的升级周期。 这是”Flutter + Rust 画了两次线”这笔账里最容易被漏掉的一项:你维护的不只是两条分界线,还有连接它们的那座桥。

飞书自己踩过的坑也很具体:2017 年起步时 Rust 连跨端 HTTPS 都跑不通;压缩库在长期生产使用后才暴露兼容问题;把 error-chain 迁移到 failure 的返工消耗了本该做业务的工时。包体积的成因是泛型单态化展开加生成代码体积,另外 Rust 的 LLVM 构建与 Apple 发布的不是同一个,导致没有直接的 Bitcode 兼容性。

四、KMP:那笔看得见的构建成本

如果说飞书展示了”边界收窄”的收益,KMP 的实测数据展示的是”边界放宽”的代价。

先说清一个前提:为什么这套配置里 iOS 侧是单一 umbrella framework? 因为每个链接出来的 framework 都会内嵌自己的 Kotlin/Native 运行时,包括内存管理与 GC。拆成多个就是多个运行时、多个 GC 在同一进程里各管各的对象图,Kotlin 对象跨过去时收集器之间并不协调。沿严格的运行时隔离边界拆分在理论上可行,但那是一个需要设计的架构决策,不是改个构建配置。

代价落在构建循环上。以下数据来自一个约 750 个 Kotlin 文件、约 700 个 Swift 文件的真实项目,环境是 Apple M4 Pro(14 核 / 48GB)、Xcode 26.5、Kotlin 2.4.0、Gradle 9.6.1,静态 umbrella framework + KMMBridge 流程:

场景耗时
冷构建(仅模拟器,无缓存)80 s
无变更(缓存全热)0.5 s
改一个只有 1 个依赖者的用例13.6 s
改一个被 7 个模块依赖的 model16.8 s

这张表要看的不是四个绝对数字,而是编译和链接这两步的行为完全不同。

这一组是 Gradle 侧的数据,测的是 Kotlin 编译加 framework 链接;下文出现的 4 秒、32 秒和 932 个编译动作是 Xcode 侧的数据,测的是 App 工程的 Swift 编译。两条流水线,不能混着看。

编译是增量的。 模块拆分在这里确实有效:改一个只有 1 个依赖者的用例,只重编 2 个模块;改一个被 7 个模块依赖的 model,才重编 8 个;其余模块全部从缓存直接恢复。所以共享层内部拆得越细,日常改动触发的编译量越小。

链接不是增量的。 只要共享层的产物有任何变化,整个 umbrella framework 就要从头链一遍,耗时约 12 秒,和这次改动的规模无关——改一个只有 1 个依赖者的用例要链,改一个被 7 个模块依赖的 model 也要链,都是 12 秒左右。在改 model 那一次的 Gradle --profile 里,链接占了整次构建时间的 72%。

也就是说,你能通过拆模块省掉编译时间,但省不掉链接时间,因为链接看到的是合并后的那一份二进制。

缓存确实救不了这一步。实验是这样的:改 model → 构建填缓存 → 回退到缓存已有状态 → 再构建。9 个模块的 Kotlin 编译全部秒级命中缓存,但整次构建仍耗时 12.3 秒,因为模块的 .klib 变了,链接任务的输入变了,必须重新链一遍。

这些数字要按项目读,不能当成 KMP 的通用常数。 它随共享层规模、依赖多少、导出范围、工具链版本变化。原作者做过一组合成实验,观察到规模与链接耗时之间接近线性,但那份实验的符号统计口径和拟合单位存在矛盾(按表格推算实为每符号约 9 毫秒,而原文写成每千符号 9 毫秒,相差约千倍),所以具体斜率不可引用。可用的结论只有定性的一条:共享层越大,链接成本越高,第三方依赖同样计入——那个项目的 framework 是 93 MB、约 28.9 万符号,其中约 5 万来自 ktor / 协程 / 序列化 / SQLDelight。

真正值得借鉴的不是耗时数字,是那个项目的边界设计:699 个 Swift 文件里只有 78 个 import Shared,其中 26 个在 SharedBridge 里,零个 feature package 直接引用。因为各 feature 编译时依赖的是 Swift 的闭包结构体协议,不是 Kotlin framework。效果是改一个 feature 的 Swift 文件 4 秒重编 2 个文件;改一个共享 Kotlin 模块 32 秒,但只重编 91 个编译动作(全量是 932 个)——影响半径被控制在了 10% 左右。这个边界守不守得住,直接决定开发循环的体感。

KMP 自己的桥接债也很具体。互操作仍走 Objective-C 导出:Kotlin 的 enum 导出后表现为类,缺少原生 Swift enum 的使用体验;Kotlin 的默认参数在 ObjC 里不存在,必须显式展开;Kotlin 的可序列化模型不会自动成为符合 Swift Codable 的模型。协程和 Flow 在 Swift 里其实有对等物(async/await、AsyncSequence),真正的问题是导出与适配是否完整、是否符合 Swift 的使用习惯,而不是”没有对等物”。Swift Export 是新一代方案,到 Kotlin 2.4.0 才补上协程与 Flow 支持,第一次具备承载真实 API 面的形状,但仍处 alpha,不该压在会长期演进的生产边界上。

比第三方评价更有说服力的,是工具方自己的动作:JetBrains 宣布从 IntelliJ IDEA 2026.3 和 Android Studio Rabbit 2(2026.2.2)起,移除 KMP 插件里的 Swift 编辑、语法高亮与跨语言跳转,理由是使用量持续下降、维护成本高,Swift 部分请回 Xcode。需要说清楚的是,iOS 应用运行、Kotlin 调试、Swift Export、SwiftPM 导入都不受影响——这不等于放弃 iOS,但它确实承认了双语言 IDE 这条路没走通。

技术之外还有一层组织成本,往往比构建耗时更致命。

有一份生产实践总结提到,一个 150 多个 Gradle 模块、百万级月活的团队把定价引擎搬进共享层之后,iOS 技术负责人开始质疑实现——在这个视角里 KMP 是不透明的:调试输出难读、结构陌生,出问题要先判断”这是 Kotlin 侧还是 Swift 侧”。更危险的是权力结构:不刻意设计流程的话,Android 团队会把 shared 模块当成随手丢东西的地方,iOS 团队慢慢变成二等消费者。

他们的解法很有代表性:共享模块的每一个 public interface 都要 iOS reviewer 签字才能合并,并且要求这个模块”像一份正经 SDK 那样呈现自己”,而不是”快速导出的 Kotlin 符号”。

五、鸿蒙:反直觉的一端

这部分的结论比较出人意料:鸿蒙是 KMP 更难覆盖的一端,而不是 Rust 更难。

从 Rust 1.78 起,三个鸿蒙目标进入 Tier 2,标准库可以直接用 rustup 装:

rustup target add aarch64-unknown-linux-ohos
cargo build --target aarch64-unknown-linux-ohos --release

实际配置还需要 OHOS SDK 及其中的 sysroot 与 linker 路径,交叉编译通常要设 OHOS_NDK_HOME,并重定向 CARGO_TARGET_*_LINKER 或 CC_* 环境变量。有一份在鸿蒙 PC 上的实践记录提到,该环境下 rustc 的默认 host 就是 aarch64-unknown-linux-ohos、系统 clang 也是鸿蒙版本,纯 Rust 和含 C 依赖的项目可以直接 cargo build——这属于特定机器与工具链下的体验,不能当成通用结论。

调用入口的差异是确定的。iOS 上 Swift 走 bridging header、Android 上 Kotlin 走 JNI,都是 C ABI 直连;鸿蒙的 ArkTS 跑在方舟引擎上,加载原生代码的通道是 NAPI,你必须在 .so 里注册模块、把数据按 NAPI 规则转换。三条路里最实用的是 ohos-rs(napi-rs 的 fork),它用 napi-rs 风格的宏导出功能,ohrs build 会跑 Cargo、收集多架构产物、并生成 ArkTS 声明文件,生态里有 hilog、mmkv、bcrypt 等现成绑定。UniFFI 的 ArkTS 后端仍在 0.0.1-beta 阶段,不该拿来当生产边界。

KMP 那边,官方目标列表是 Android、iOS、Desktop、Web,没有 ohos。鸿蒙要走社区或厂商定制,社区那条线在 2026 年内把 KMP 和 CMP 推到了正式版,但有版本线分叉问题(旧线必须降 Gradle 8.14.1 而新线支持 9.x,两套经验不能互套)。

对照很清楚:Rust 在鸿蒙是”官方 target + 社区桥接”,KMP 是”社区 target + 厂商桥接”。 两者都不是零成本,但前者的地基在 Rust 官方手里。

飞书的经历提供了一个现实刻度:据公开报道,做鸿蒙原生版时 HarmonyOS NEXT 对 Rust 的支持尚不完善,团队与华为组成联合攻坚小组,两地工程师逐行分析代码,连续一周闭门鏖战完成移植,并把鸿蒙对 Rust 的支持提交回开源社区。这条路被五端产品验证过,但验证它需要一家头部公司与系统厂商联合攻坚。

六、React Native:把边界画在原生控件上

RN 和前面几种方案的根本区别,用一句话说清:它不是画界面,它是让 JS 描述一棵组件树,然后由原生去创建真正的系统控件。

链路是这样的:JS 侧维护组件树,布局交给 Yoga(C++)算位置和尺寸,C++ 的 diff 产出原子操作,最后在 UI 线程上创建和更新 UIView 或 android.view.ViewGroup。

这个选择直接决定了它的收益结构。系统已经解决的问题,它大部分都不用重做——VoiceOver 和 TalkBack 读的是真实语义树,中文输入法走的是系统文本框的完整通道,滚动惯性、返回手势、动态字体都是继承来的。第八节会列出的那串”自绘框架必须自己重建”的清单,在这里大幅缩短。但”大幅缩短”不等于”消失”:无障碍仍需应用自己设语义、角色、状态和标签,键盘避让、受控输入、导航手势也仍要处理,只是工作在框架之上而不是从零开始。

它的历史包袱曾经也在这一层。旧架构的异步 Bridge 让所有 JS 与原生之间的调用都要序列化成 JSON 走桥。新架构用 JSI 换掉了它:JS 直接持有原生对象的 C++ 引用,同步调用成为可能,桥的初始化也被去掉了。0.76 成为默认,0.82(2025 年 10 月)成为唯一架构——设置 newArchEnabled=false 或 RCT_NEW_ARCH_ENABLED=0 会被直接忽略。

关于新架构的收益,需要谨慎对待流传的数字。常见转述是”冷启动快 43%、渲染快 39%“,但这些数字来自商业技术博客的汇总,我未能在官方发布说明中核到对应的原始基准(原始应用、设备、RN 版本、release 配置、指标定义与样本量)。官方架构文档反而提醒:启用新架构不一定立刻改善应用性能,取决于应用瓶颈在哪。所以更可靠的表述是——新架构移除了旧异步桥的核心限制、提供了更直接的互操作与同步布局能力,实际收益取决于你把负载放在哪里。

有一件事没变,也不打算变:那根 JS 线程。 JS 线程和 UI 线程是分开的,任何一边堵住都会掉帧。长列表最典型:cell 生命周期由 JS 维护,条目规模上去之后(具体阈值取决于 cell 复杂度、图片、窗口配置和 JS 负载,没有一个通用数字),滚动会开始抖。解法是换 FlashList v2。它是彻底重写:把原生代码依赖整个去掉,变成纯 JS 实现,靠新架构的同步布局测量自动算出精确尺寸,不再需要 estimatedItemSize;而视图回收机制保留了下来——组件滑出视口不销毁,放回池里复用,这才是它和 FlatList 最根本的区别。另一条路是做”Native 驱动列表”——JS 只提供数据源和 cell 模板,生命周期交给原生。复杂动效同理,Reanimated 把动画搬到 UI 线程能覆盖绝大多数需求,但”同屏大量元素同时动画”仍比 SwiftUI / Compose 难做。

所以判断很干脆:凡是”系统能给”的能力,RN 白拿;凡是”要靠 JS 调度”的能力,RN 就要付利息。 前一类远多于后一类,这就是它这几年活得越来越好的原因。

至于 Expo,它不是一个并列的选项,而是这条路上的一层。框架、SDK、CLI、Router 全部 MIT 免费,收费的只有 EAS 云服务(构建、上架、热更新、CI、托管、监控)。SDK 的价值在于几十个 expo-* 模块一起测试、一起发布、每个版本锁死一个确定的 RN 版本——它把每个人都要重复手写的那层标准化了,这才解释了为什么 2024 年起 RN 官方文档直接建议新项目用框架起步、并点名 Expo。

七、Kuikly:把分界线再往下推一层

KMP 共享逻辑但不共享 UI,Flutter 两个都共享但要自绘。Kuikly 想两个都要,而它的办法是一句很聪明的话:把”原生”重新定义成”原生原子”。

具体做法是:布局和高阶组件放在 Kotlin 跨端层,原生层只做原子组件的轻量映射。

Core 层承担了绝大部分工作:BuildTree 负责 DSL 组件树到 Native UI 树的映射,产出渲染指令并做细粒度 diff(官方称 O(1),但没说明这个复杂度针对的是单次属性更新还是整树处理,引用时宜保留限定);ListView、ViewPager、Waterfall 这些复杂组件在跨端层重新实现,不是靠原生组件拼出来;布局用 yoga,和 RN 同源,所以各端算出来的位置尺寸是同一份。而落到原生层的只有 Text、Image、Input、ScrollView 这几个原子。

这样设计的效果是:多端行为一致了,因为真正复杂的逻辑根本不在原生侧。 Flutter 用自绘换来的那个一致性,它用另一种方式也拿到了。同时因为输入框是真的原生输入框,系统 IME、光标、选中、剪贴板都更容易接上——这是自绘框架最头疼的那块,它绕开了。

它还有一个设计细节值得单独说:Core 与 Render 之间用 callKotlin / callNative 指令通信,而不是 KMP 的 actual/expect 直接依赖,实现了编译隔离。这个隔离解锁的能力就是页面级动态化——跨端层和业务代码可以动态下发,配合腾讯的 Shiply 做按页面维度的热更新,这对运营活动和紧急修复是刚需。

现在说这笔交易的另一面,因为它必须被说清楚。

“原生渲染”不等于”原生控件”。KMP 说的原生 UI,是你拿到 SwiftUI 的 List、Compose 的 LazyColumn,系统控件的行为细节基本继承,新版本 OS 改了下拉刷新动画你通常不用做什么。Kuikly 是把高阶组件在 Kotlin 层重写了一遍,所以拿到的是”用原生方式绘制原子”加”布局同源”,而不是系统控件本身的行为。

代价就落在这里:日期选择器、下拉刷新的动画曲线、Tab Bar 的滑动反馈、返回手势的细节触感,这些要么用框架自带实现,要么自己写。这不是缺陷,是这笔交易的定价——你用”放弃系统控件的行为细节”换来了”UI 一份代码 + 原生级性能 + 小增量包体积”。

顺带把两个容易误读的数据标清楚。体量上,Android 约 300KB、iOS 约 1.2MB,这是 AOT 模式下 SDK 增量,不是完整 App,不能直接和 Flutter 空包约 10MB 相比。性能上,复杂 Feed 流、Mate 60 机型,鸿蒙端打开页面比 RN 快 6 倍、与原生基本一致——这是腾讯技术稿里的厂方自测,特定场景特定机型,不能当通用横评。

而它真正无可替代的地方在鸿蒙。Kuikly Compose 在鸿蒙上没有走声明式 ArkUI,而是调用 ArkUI 的命令式 Native C 接口,因为 C 接口与 Kotlin/Native 可以较直接地衔接,减少中间层开销。KuiklyBase 里还包含自己做的 Kotlin/Native 鸿蒙适配——也就是说”KMP 官方没有 ohos target”这个问题,腾讯的答案是自造一个,然后开源。

八、用 Rust 写 UI:代价集中在哪

需要先区分一下:Flutter 也是自绘 UI,但它是 Dart 写的,背后有 Google 一整个引擎团队在维护渲染管线、无障碍桥接、文本排版和输入法协议。所以”自绘有代价”这句话对两者都成立,区别在于这些代价由谁承担——Flutter 用户承担的是”平台新特性滞后 + 包体积”,Rust 用户承担的是”框架本身还不够成熟,很多块要自己接”。

按”逻辑 + UI 全 Rust”看,有 WebView 套壳(Tauri)、DSL 混合渲染(Slint)、GPU 自绘(Makepad / GPUI / Iced / egui)三条路。第三条最彻底,也最能说明这笔代价具体长什么样。

桌面上它其实已经可行。GPUI 是 Zed 的引擎,Zed 渲染能到 120fps(这是 Zed 团队博客里的设计目标与演示能力,不代表任何场景都能维持),大仓库下内存表现明显好于 Electron 系方案。但 GPUI 是 Zed 仓库里的一个 crate,官方 README 明确写着 pre-1.0、API 会经常变、学习常常要读 Zed 源码,当前平台说明也以桌面为主。它的 crates.io 发布版停在 0.2.2,那是 2025 年 10 月的事,此后一年没发过新版;主分支一直在动,但发布节奏停滞,社区被逼出了自己的办法:gpui-kit 团队(Longbridge 的 Jason Lee)把 Zed 仓库里的 gpui* 改名成 gpui-pre-* 自行发布到 crates.io——gpui-pre 从 2026 年 9 月起持续更新,已经到 0.3.12——因为在 crates.io 上 gpui、gpui_platform、gpui_macros 这三个名字归 Zed 所有,而 Zed 极少发布。一个生态要靠非官方快照才能用上最新代码,这件事比任何版本号都更能说明它的状态。Xilem 的情况更值得警惕:2026 年一位核心贡献者写了公开批评,说泛型过重导致编译时间爆炸、双向数据绑定强迫每个 view 携带状态类型,不做重大重置就不可行——这是贡献者的批评与改造提议,不代表所有维护者共识。

Dioxus 常被归到自绘阵营,但它默认走 WebView,WGPU/Skia 后端是实验性的,分类时要看清用的是哪条路径。

移动端有一个可核验的样本:Robrix,用 Makepad 写的纯 Rust Matrix 客户端,桌面三平台和 Android 可直下、iOS 走 TestFlight,截至 2026 年 11 月已到 v1.0.0-beta.6。但翻它 10 月那次 beta 的发布说明(v1.0.0-beta.4),有一句轻描淡写的话很说明问题——修复了 iOS 27 上点击文本输入框时的崩溃;而它的特性宣传里专门列了”支持 CJK 输入法”。

这两条指向的是一整类成本,与语言无关。无障碍要自己重建,因为 VoiceOver 和 TalkBack 通过系统语义树工作,你把界面全画在 GPU 上,系统就什么都不知道。输入法要自己重建,CJK 的预编辑字符串、候选词、光标定位、组合状态,必须走系统 IME 的完整通道。Iced 0.14 把”优化输入法集成、显著改善中日文输入”当版本特性宣传,说明这一块长期是薄弱环节(不是说此前所有版本所有场景都不可用)。还有一长串系统集成:推送、深链、Widget、权限弹窗、键盘避让、滚动惯性、返回手势、动态字体、RTL。

需要区分的是工作量归属:框架本身承担了一部分(比如 AccessKit 这类语义层抽象),业务团队仍要承担另一部分(每个控件的角色、标签、焦点顺序)。但整体上,放弃系统控件就意味着那些控件身上已经解决的问题要重新处理一遍。 组件生态也一样,富文本编辑器、虚拟滚动、日期选择器、图表,在 Flutter 里是一行 pub add 的事,这里要自己写。

还有个设计层的矛盾容易被忽略:Rust 的所有权模型和 UI 的树形结构天然冲突。树有父子双向指针(环),业务代码还要跨节点共享状态。常见结果是所有权被迫降级到 Rc<RefCell<>> 加 Weak 拆环,把 GC 思维搬了回来但要手动管引用计数。也有更讲究的做法——arena、ID 句柄、集中式状态管理,都能避开这个坑,但它们都需要架构上的预先设计,不是写起来就有的。这是 Rust 写 UI 的主要摩擦,不是不可逾越的障碍。

九、Weex:中间态要付的代价

这是整场讨论里最完整的失败样本,但有些常见说法需要修正。

先说它当年有多强。2016 年双 11,Weex 覆盖了 1,747 个会场页面(占 1,754 个的 99.6%)。手淘通过预加载和首屏优化,把 JS bundle 的加载时间大幅压低:iOS 从网络平均 296 毫秒降到预加载 18 毫秒,Android 从 696 毫秒降到 54 毫秒。注意这四个数字测的是bundle 加载,不是完整首屏;所有会场的平均秒开率(加载加首屏渲染小于 1 秒的比例)是 83.9%,主会场是 94.4%。年底捐给 Apache 基金会,成为该基金会旗下首个由中国团队主导的移动项目。

病灶清单非常具体。闲鱼当年换容器时做过一次诊断:内存上,Weex 页面重度访问是客户端 OOM 的主要场景之一,waterfall 组件加载 5 页带来 3000 个未释放节点、40MB 内存增量;渲染上,不支持 overflow: visible、不支持 z-index、不支持 display: inline;一致性上,工程里充斥着 if(isIOS){...} else if(isAndroid){...};扩展上,无法在元素上屏前获取布局信息,绑定滚动的动画性能不佳,输入框无法自动聚焦。飞猪的复盘是另一组观察:低端机帧率不足、部分活动页 Bundle 过大导致首屏偏慢,最后他们把部分核心页面迁回了 Web。

每一条都是 bug,合起来是一笔结构性的账。 Weex 站在中间:不完全自绘,拿不到像素级一致的红利;也不是纯原生,拿不到完备性。两头的短板都占了。这也解释了为什么它后来被 Flutter(换一致性)和更新的原生渲染方案(换完备性)两头挤压——但要说清楚,这不等于整类原生渲染路线失效,RN 和 Kuikly 恰好都是这条路线的反例,它们活得不错。

关于结局,有个常见的混淆要拆开。2021 年 5 月 Weex 退出 Apache 孵化,但退出孵化不等于停止维护——团队明确声明 retire 不等于 deprecated,随后把开发迁回了 alibaba/weex。评估它现在的维护风险,要看版本发布、关键平台适配、问题响应和维护人力,不能只看孵化器状态或仓库推送时间。据第三方对仓库活跃度的追踪,最后推送在 2025 年 12 月,star 增长接近零;对还在用它的团队来说,痛是分层的:原生 SDK 对新系统的适配跟进、Vue 2 生态整体迁移、.we 文件缺少对应的现代构建插件。框架停止演进之后,迁移是一项超出框架本身的工程。

两个反直觉的点值得记下来。一是捐给基金会不等于上了保险,Apache 给治理、商标、流程,不给人力和预算,人力还在原公司,而原公司的重心可能已经转向别处。二是它的出身决定了它被寄予的期待与实际能力之间的落差:回看诞生动机,iOS 审核周期长、大促页面用 H5 体验差、包体积接近临界值——三个痛点全是关于动态化、大促、包体积。它被大量使用在”整套 App 的 UI 层”上,而它最初是为运营活动页设计的容器。

十、GC 到底是什么

讨论进行到后半程,有个问题值得单独澄清:GC 就是内存管理器吗?

答案是:GC 只是内存管理里”回收”这一个环节的一种实现方式。内存管理包含四件事——分配、跟踪、回收、整理。

策略代表何时释放典型缺陷
手动 + RAIIC / C++你写 free,或析构函数/智能指针触发忘记、重复、悬垂指针
编译期检查Rust 所有权作用域结束(确定性,析构在运行时执行)学习成本、树形结构难写
引用计数Swift ARC、Rc/Arc最后一个引用消失时循环引用、计数开销
追踪式 GCGo、JVM、Dart、Kotlin/Native运行时认为该扫的时候停顿、内存放大

由此可以纠正三个常见误判。

Swift 的 ARC 不是追踪式 GC。 它是编译期插入 retain/release 的引用计数,没有独立扫描线程,释放时机是确定的。所以”KMP 在 iOS 上多一个 GC”这个说法不够准确,更准确的是进程里并存两种内存管理策略。

Rust 也有内存管理器。 它有分配器、有堆结构,编译器负责检查所有权并安排析构,实际的分配和释放在运行时执行。而且 Rust 也不是绝对没有自动管理的工具——Rc/Arc 就是引用计数版。没有 Go 式的 GC 和调度运行时,也不等于没有分配器开销、内存碎片或需要留意的峰值。

“多一个 GC”的真正代价不是”慢”。 Go 的 GC 已经能做到百微秒量级的 STW,但这不是所有工作负载的上限——GC 的 CPU 工作和分配协助同样会影响延迟。真正的代价是三条:内存放大(GOGC 控制的是目标堆大小,公式里还包含 GC roots,说”涨到存活数据两倍才回收”是过度简化,但方向对——它确实倾向于留出余量,而 App Extension、Widget 有硬内存上限)、不确定性(哪怕停顿不到 1 毫秒,你也无法承诺”这一帧不会遇到”)、以及整个运行时的包袱(调度器、栈增长、类型元数据会一起进别人的进程)。

所以更精确的说法是:问题不在”有没有 GC”,而在是否引入一套独立的内存与调度运行时,以及它与宿主的资源、线程和对象生命周期如何协同。

十一、Go 能不能顶替 Rust 的位置

能,但约束更紧。 这个区别很重要。上一节问的是”运行时带来了什么”,这一节把同一个问题落到具体候选人身上:如果站在线内做客的那个运行时是 Go,这笔账会变成什么样。

先看 Rust 为什么常被选中。一个嵌入型的共享核心,理想状态下不带自己的内存管理器、不带自己的调度器、没有不可控的内存峰值、能被链接器完整剪裁。这几条没有一条关于速度,全是关于”做客时别带太多家具”。Rust 满足它们不是因为优化得好,是设计上就没有这些东西。

Go 会把 runtime 一起带进去。GOGC=off 不是关掉 GC,是让自动回收不再由 GOGC 触发——运行时还在,显式 GC 和内存限制机制仍然有效,而且如果持续产生未回收的堆分配,内存照样会涨。所以对内存预算严格、包体积敏感、需要细粒度资源释放控制、或者要求极高频跨语言调用的库,Rust 更容易满足约束。

但这不是”不能”。官方文档确实说明了 gobind 的能力:生成 Java / Objective-C 绑定,供 Android / iOS 调用 Go 库。不过这份文档要连着它的状态一起读——项目从 2014 年建仓至今一直标着 experimental,明写 Google 和 Go 团队都不提供终端用户支持,最近虽有推送,PR 合并率却接近于零。这些加起来更像是”保留可用”,而不是”鼓励押注”。再加上目标只有 android 和 ios、类型系统有明确限制、跨语言引用环和 panic 处理都有坑,它适合验证想法,不适合当长期地基。

几处常被引用的论据需要修正口径。

gomobile 从 2014 年 12 月建仓,README 里那句”experimental,Google 和 Go 团队都无法提供终端用户支持”确实还在,安装前置条件也确实还写着 Go 1.5——文档陈旧是事实,但这不等于项目十几年没有维护,两者要分开。

cgo 的开销常被说成 50~120 纳秒,但那个数字主要来自 Go → C 方向的空调用基准。宿主调用 Go 核心走的是 C / JNI / Objective-C → Go,方向不同,不能直接套用。而且这个数字随硬件、版本、参数转换量变化,不是常数;所谓”每秒几十万次就要警惕”也不是通用阈值,要结合数据转换、线程行为、调用工作量和延迟预算来定。

还有一格是空的,而且空得很干净:鸿蒙。gomobile 的目标只有 android 和 ios,官方没有 ohos 目标,社区也没有成熟替代。所以如果你的目标端里包含鸿蒙,Go 核心这条路不是”约束更紧”,而是”这一端没有解”。这与第五节的结论正好互为镜像——那一节说鸿蒙是 Rust 相对 KMP 的优势端,这里要补一句:它同时也是 Go 的空白端。

这里还有个逻辑要自洽:前面几节一直主张”收窄边界、批量化调用”,那么把”共享核心必然是小颗粒高频调用”当成前提去否定 Go,就站不住。正确的说法是——如果你的接口设计得足够粗粒度,Go 的边界开销可以被摊薄;如果你的接口天然细碎,那两种语言都要为此付出代价,只是 Rust 的代价更低。

反例 Tailscale 常被用来说明”Go 能做移动端”,但它真正说明的是”Go 在某些位置特别合适”:它移动端的位置是 Network Extension 那类后台网络数据面,不是普通 App 的 UI 逻辑。有意思的是,那段代码对内存的敏感度反而很高——Tailscale 专门写过博客讲他们怎么优化 Go 链接器和内存占用。同样的分工在 1Password 身上也能看到:管理员工具的后端用 Go 写,而客户端核心有约 63% 的代码是 Rust(这是 2022 年官方访谈的数据,不是 2026 年现状统计)。

如果核心只需要覆盖服务端和 CLI,Go 就是那个核心,一行 Rust 都不用写。 技术选型最怕的,就是把别人家的硬约束当成自己的问题。Go 桌面侧现在也已经够用,Wails、Fyne、Gio 之外,2026 年 9 月开源的 MyGo 用纯 Go 无 cgo 的方案做到了全平台交叉编译、单文件几 MB,还支持从 Go 代码自动生成 TypeScript 客户端。

十二、四条可以带走的判断

第一,没有脱离场景的总成本排名,只有成本落在哪里的问题。 KMP 省掉了 FFI 契约设计,代价是 iOS 进程里并存两种内存策略、共享层每次变更的重新链接、以及必须手写的 Swift 适配层。Rust 核心换来了官方覆盖每个平台的编译目标,代价是 UI 写两遍、桥接维护三份、构建流水线多几条。该问的不是”哪个更好”,而是”这笔成本落在哪、我熟不熟、是一次性投入还是每次迭代都要付的利息”。在给定业务和团队条件下,某个方案确实可能总成本更低——但那个结论只对那组条件成立。

第二,边界必须有人拥有。 飞书只暴露一个 ffi_call 入口、Bitwarden 把 Rust SDK 当唯一真相源(各端只做薄壳,加密与同步逻辑只在 SDK 里实现一次)、前面那个要求 iOS reviewer 给每个 public interface 签字的团队——这三件事看着是技术决策,其实是同一件事:谁拥有边界,谁就拥有这套架构的演进方向。

第三,分工有阈值,超过就反向。 两种语言是协作,三种开始有治理成本,四种变成组织问题。每多一门语言,都会扩大构建、测试、排障和人员协作的成本面。飞书能用五端一套核心跑九年,是因为养得起一个专职的跨平台团队。

第四,判断一个框架能不能用,别问”它会不会被砍”,问退出成本。 具体是:哪些模块可以替换、数据格式和构建链能不能独立迁移、替代方案是否已经验证过、业务能承受多长的停滞期。这四个问题答得出来,风险就是可控的;答不出来,无论它现在多少 DAU,都应视为一笔需要持续监控的债务。


参考资料