ByteNoteByteNote

字节笔记本

2026年8月29日

Rust + SwiftUI + Flutter 混合架构怎么落地

API中转
¥120

BeyondTranslate(以前叫比译)是一款跑在 macOS、Windows、Linux 上的划词/截图翻译应用,能接十来家引擎。仓库最近在做一件挺具体的事:核心服务迁到 Rust,翻译窗还是 Flutter,macOS 设置页改成原生 SwiftUI,两边都通过 FFI 调同一套 Rust API。

下面按仓库现状把这条拆法捋一遍,方便你自己的桌面应用照着落。

为什么要把 UI 和核心拆开

翻译这类应用,真正费劲的不是界面,是引擎调度、词典、OCR、权限、设置持久化。这些在三个桌面平台上几乎一样,但窗口形态差很多:翻译窗要轻、要能从任意屏幕取词;设置页在 macOS 上用户更认系统那套列表和分组。

如果把引擎和设置写在 Flutter 里,SwiftUI 设置页只能再抄一份,或者通过 MethodChannel 把 Dart 当中转。反过来,设置写在 Swift 里,Windows / Linux 又没戏。拆开之后,Rust 只暴露「运行时能干什么」,Flutter 和 SwiftUI 各自画自己的窗。仓库 README 里写得很直:核心换 Rust 是为了性能和跨平台复用,UI 层继续用 Flutter,macOS 设置页用 SwiftUI。

翻译窗这边还用了 nativeapi 一类的原生能力,让 Flutter 桌面窗看起来不那么「跨平台壳」。这只影响观感,不改变「业务在 Rust」这件事。

Rust 怎么当共享 API

仓库里其实有两条绑定,职责不太一样。

第一条是给 Flutter 翻译窗用的 flutter_rust_bridge。桌面端 Rust 暴露一组 Runtime API,生成出来的 Dart 侧大致是 RuntimeRuntimeSettingsRuntimeTranslationRuntimeDictionary(还有 OCR、权限、取词等周边)。设置、引擎、翻译这些 domain 写在 apps/desktop/rust 里,flutter_rust_bridge 生成 frb_generated.dart 和对应的 Rust glue。Flutter 里的 DesktopSettingsService、运行时调试页,走的都是这条路。

第二条是 packages/runtime:一个 Flutter FFI 插件,用 UniFFIuniffi-dart 把同一份小 Rust crate 同时暴露给 Dart 和 Swift。这一条是给「Flutter 进程里的原生 Swift 代码」准备的,设置页、以后的 Share Extension 都能直接调,不必再绕 Dart。

packages/runtime 的构建钩子在 hook/build.dart:按当前 Flutter 目标跑 cargo build --release,再注册一个 CodeAssetpackage:beyondtranslate_runtime / uniffi:beyondtranslate_runtime)。Dart 侧的 @Native(assetId: ...) 才能在运行时找到那份 cdylib。

Swift 这边,钩子本身不管。插件另带一个 Swift Package,路径是 macos/beyondtranslate_runtime/,Flutter 的 macOS SPM 会按 pluginClass: BeyondtranslateRuntimePlugin 自动发现。Package.swift 里两个 target:beyondtranslate_runtimeFFI 是 UniFFI 的 C 伞头文件和 modulemap(由 scripts/generate/runtime_bindings.pyswift/Generated/ 镜像过来);beyondtranslate_runtime 再导出生成的 Swift 绑定,外加一个很薄的 FlutterPlugin stub。链接用了 -undefined dynamic_lookup,符号等到运行时再解析。

插件 register 的时候会 dlopen Frameworks/beyondtranslate_runtime.framework/beyondtranslate_runtime。宿主 Swift(AppDelegate、设置页、扩展)要等 RegisterGeneratedPlugins(...) 跑完再调绑定,否则框架还没加载进去。

两条绑定面对的是同一份运行时概念:设置变更、翻译请求、词典查询都落在 Rust。差别只是谁生成胶水、谁在进程里先把动态库装上。

SwiftUI 设置页和 Flutter 翻译窗怎么接到同一套核心

对照仓库里的目录,可以想成三层:

  1. Rust 运行时。Runtime 是入口,设置走 RuntimeSettings,翻译走 RuntimeTranslation,词典走 RuntimeDictionary。引擎列表、快捷键、自动翻译这些状态存在这儿,不存在某个 UI 框架的 Store 里。
  2. Flutter 翻译窗。apps/desktop 用 FRB 生成的 Dart API 调上面这些对象。主窗、划词、截图翻译、多引擎结果,都还是 Flutter。需要更像原生窗口时,再叠 nativeapi,不把业务搬回 Dart。
  3. macOS SwiftUI 设置页。apps/desktop/macos/Runner/Features/Settings/ 下面是模型、View、ViewModel、SettingsRepository。仓库里还有 NativeSettingsPlugin,把原生设置页嵌回 Flutter 的调试路由,方便两边对照。Repository 通过 UniFFI 生成的 Swift 绑定调 Rust,不经过 Dart。

同一条设置,Flutter 改完 Rust 里是一份,SwiftUI 再读也是这份;反过来也成立。调试页(NativeSettingsDebugruntime_debug)就是用来看这条回路有没有断。

Windows 和 Linux 没有 SwiftUI 设置页,它们继续用 Flutter 设置,但底层还是同一套 Runtime。拆的是「谁来画设置」,不是「设置存在哪」。

怎么 clone 和跑起来

仓库:https://github.com/beyondtranslate/beyondtranslate
文档:https://beyondtranslate.com/docs/
只想先用成品,去 Releases 下安装包即可。

从源码跑桌面端,README 给的是:

text
git clone https://github.com/beyondtranslate/beyondtranslate.git
cd beyondtranslate/apps/desktop
flutter run -d macos

macos 换成 linuxwindows 即可。本机需要能用的 Flutter 桌面工具链,以及能编 cdylib 的 Rust。第一次跑会走 hook/build.dart 编 runtime,再走 FRB 那份已提交的生成代码。绑定有变动时,UniFFI 一侧看 scripts/generate/runtime_bindings.py,FRB 一侧看 apps/desktop 里的 flutter_rust_bridge.yaml

容易踩的坑

两套 UI 工具包会一直在。Flutter 和 SwiftUI 的窗口生命周期、快捷键、深色模式、字体渲染都不共用。设置页「看起来像系统设置」的代价,是你要维护两套导航和两套控件。不要指望用一套主题文件抹平。

绑定生成是第二处摩擦。FRB 和 UniFFI 各生成一份胶水,改 Rust API 漏跑一边,常见症状是 Dart 能编译、Swift 还在用旧符号,或者反过来。仓库把生成物提交进 git(frb_generated.*swift/Generated/、Dart @Native 绑定),review 时把这些文件和手写的 domain 一起看,比只看 *.rs 稳。

第三条跟加载顺序有关。hook/build.dart 只给 Dart 的 native-assets 注册 cdylib;Swift 靠插件 dlopen 那份 framework。宿主代码如果在 RegisterGeneratedPlugins 之前就调 UniFFI,会遇到符号找不到。Share Extension 这类不走 Flutter 插件注册的进程,要自己链 swift/Generated/ 里的 .swift / .h,并带上同一份 libbeyondtranslate_runtime.dylib

dynamic_lookup 让链接能过,但不保证运行时符号还在。Flutter 进程里正常、纯 Swift 宿主里崩,多半是 framework 没被装进 bundle,或 asset 名和 @Native(assetId:) 对不上。

最后,架构还在升级中。README 自己标了 in progress。对照文档和仓库时,以 packages/runtimeapps/desktop/rust 的当前代码为准。

文档和仓库

只想在 Flutter 桌面应用里复用一份 Rust 核心,可以先抄 packages/runtime 这条 UniFFI + native-assets 的装库方式,再按自己的 domain 加 FRB。macOS 上要再挂一层 SwiftUI,关键是让原生代码在插件 dlopen 之后,调同一份 crate,而不是再写一个设置后端。

分享: