2026年8月30日 · 约 18 分钟读完
Flutter 负责界面,Rust 负责核心:一次跨语言架构实践
从一个逐渐变复杂的 Flutter 项目出发,聊聊为什么引入 Rust Core、FRB 在中间做了什么,以及这种架构怎样让业务边界更清楚。
一个 Flutter 项目是怎样慢慢变重的
Flutter 很容易让人产生一种“一套代码什么都能做”的满足感。
快速开发没有错。
写页面用 Flutter,状态放进 Controller,网络请求交给 Service,本地数据存进数据库。需要文件、音频或系统能力,再找一个插件接进来。项目刚开始时,这条路很顺,功能也出得很快。
我的音乐 App 最初也是这样。
后来它支持的音乐来源越来越多,又加入曲库扫描、元数据解析、搜索、播放队列、歌词、缓存、断点恢复和后台播放。原本只负责页面状态的 Controller,慢慢开始知道数据库、网络、播放器和其他 Controller。一个点击事件背后,可能连着好几组异步任务和监听器。
真正失控的是状态边界。
代码仍然可以运行,但我越来越难回答一些看起来很简单的问题:
- 当前播放队列到底以谁为准?
- 页面已经显示成功,数据是否真的写进了数据库?
- 一次同步中途失败,旧数据还能不能相信?
- App 重启以后,哪些状态应该回来?
- 修改一个来源,会不会意外触发另一个模块刷新?
我试过继续拆 Controller,也拆出了不少 Service 和 helper。文件确实变小了,调用链却没有真的变短。很多对象仍然共享同一批可变状态,只是从“一个大文件”变成了“一群互相认识的小文件”。
项目缺的不是更多目录,而是一条更难被随手跨过的边界。
我为什么把 Rust 引进来
一提到 Flutter 加 Rust,最容易想到的是性能:把计算密集的部分交给 Rust,Dart 继续写页面。
性能当然是一个理由,但不是我这次改造最看重的东西。
如果只是把一个解析函数改写成 Rust,再通过 FFI 调用,项目的结构不会发生太大变化。Controller 仍然可能同时管理 UI、数据库和业务状态,只是调用链中间多了一门语言。
我真正想要的是一个可以离开 Flutter 独立存在的 Core。
这个 Core 不知道 Widget、BuildContext、路由和弹窗。它只知道业务里的实体、状态和规则:一份数据怎样同步才算成功,一次操作怎样提交,播放队列如何变化,失败以后应该保留什么。
边界从约定变成工程事实。
在纯 Dart 项目里,我们当然可以规定 Domain 不许依赖 UI。但项目赶进度时,一次方便的 import、一次 Get.find(),就可能让规则慢慢失效。独立 Rust crate 根本无法引用 Flutter Widget,反向依赖会直接卡在编译阶段。
核心不再需要等 UI 启动。
我可以直接运行 Rust 测试,也可以通过 CLI 打开数据库、诊断来源、扫描文件和验证状态转换。很多以前只能在模拟器里点半天才能复现的问题,现在可以变成一条命令或一个固定测试。
复杂状态终于有了明确的主人。
数据库同步由一个写入者提交,播放命令进入同一个有序队列,Flutter 不再自己维护另一份“差不多一样”的核心状态。状态少了一份,很多同步问题自然也少了一层。
所以这次引入 Rust,并不是因为 Dart 做不到,而是我希望核心具备四个特点:
我想要的 Core 应该独立、稳定、可测试,而且只能通过明确入口被调用。
Rust 恰好很适合承接这样的角色。至于这门语言本身为什么适合守住安全边界,我在重新学习 Rust 时的取舍记录里写过更多背景。
这套架构其实没有想象中复杂
如果先不看各种分层名词,它只有三个主要角色:
Flutter UI <-> FRB <-> Rust Core
可以把 Flutter 想象成驾驶舱。它接收用户操作,展示速度、方向和当前状态,但不会自己决定发动机里的燃油怎样燃烧。
Rust Core 更像发动机和控制系统。它维护真正的业务状态,执行规则,再把已经确认的结果交还给界面。
FRB 则是两边之间的线束。它让 Flutter 能把命令送进 Rust,也让 Rust 的结果可以变成 Dart 对象回到页面。
| 角色 | 它最关心什么 |
|---|---|
| Flutter | 用户做了什么,页面应该怎样显示 |
| FRB | 命令和结果怎样跨过语言边界 |
| Rust Core | 业务状态怎样安全、确定地发生变化 |
更完整一点,实际调用关系是:
用户点击或系统事件
↓
Flutter View / ViewModel
↓
Dart Repository / Facade
↓
flutter_rust_bridge
↓
Rust API
↓
Rust Core
↓
数据库、网络、文件或音频引擎
结果再沿着相反方向回来,最终变成 Flutter 可以渲染的状态。
这里的 Rust Core 不是服务器后端。 它和 Flutter 一起打包在 App 里,是一个本地原生核心。调用不需要经过网络,只是跨过了 Dart 与 Rust 的语言边界。
对 Flutter 开发者来说,最直观的变化可以先看成下面四件事:
| 以前容易发生的情况 | 加入 Rust Core 以后 |
|---|---|
| 页面状态和业务状态混在一起 | Flutter 只整理展示状态,核心事实来自 Rust |
| 调试业务必须启动完整 App | Core 可以单独测试,也可以通过命令行运行 |
| 多个 Controller 都能修改同一份数据 | 关键状态只保留一个明确的写入者 |
| 更换数据库或底层实现会影响很多页面 | 内部实现藏在 Core 后面,Flutter 继续使用同一份合同 |
后面的分层、桥接和同步机制,其实都在为这四个变化服务。
Flutter 只做 UI,不是只剩 Widget
“Flutter 只负责 UI”听起来很极端,好像 Dart 里除了 Widget 什么都不能留。
我实际采用的边界没有这么死。
Flutter 仍然负责它擅长的东西:
- 页面、组件、动画和响应式布局;
- 路由、弹窗和临时输入;
- Loading、空页面、错误提示等展示状态;
- 把 Rust 返回的数据整理成页面容易使用的形式;
- 将点击、拖动、刷新和平台回调转换成命令。
ViewModel 也仍然存在。它负责“这个页面现在应该怎样显示”,但不再负责“业务事实究竟是什么”。
比如用户点击播放按钮以后,Flutter 可以马上显示一个等待状态,却不会直接认定音乐已经开始播放。真正的播放结果由 Rust Core 确认,Flutter 收到新状态以后再更新按钮和进度。
点击播放
→ Flutter 显示 pending
→ Rust 检查队列和资源
→ 播放状态发生变化
→ Rust 返回确认后的快照
→ Flutter 显示 playing 或错误
这种差别看起来很小,实际非常重要。
以前页面状态和播放器状态可能各自变化,最后靠监听器尽量对齐。现在 Flutter 展示的是 Core 的结果,而不是另一套独立判断。
Rust Core 里面放什么
我没有用“所有非 UI 代码都进 Rust”作为判断标准。更实用的判断方式是看一段逻辑换了 UI 以后是否仍然成立。
| 更适合进入 Rust Core | 更适合留在 Flutter 或平台侧 |
|---|---|
| 稳定的业务模型和校验规则 | 页面跳转、动画和布局 |
| 数据同步、合并、搜索和索引 | 权限申请时的系统弹窗 |
| 数据库事务与版本迁移 | Keychain、Keystore 等平台入口 |
| 队列、任务、下载、播放等状态机 | AirPlay、CarPlay 和锁屏控制 |
| 超时、重试、取消和恢复策略 | 只影响页面外观的临时状态 |
| 文件解析、媒体处理和重计算任务 | 系统音频焦点等平台交互 |
Rust 可以定义“我需要取得某个凭据”或“播放被系统中断了”,但不需要知道 iOS 用哪个类完成这些事情。平台侧把能力实现出来,再通过一个小接口交给 Core。
在 Rust 内部,我仍然会继续分层,不过文章不需要先记住这些名字:
domain 最稳定的实体、规则和状态变化
application 把一次完整业务操作组织起来
ports Core 需要哪些外部能力
infrastructure 数据库、网络、文件和音频实现
api 面向 Flutter 的桥接入口
最重要的只有一条:里面的核心规则不依赖 Flutter,也不依赖 FRB。
FRB 只是最外面的一扇门。未来换成 CLI 或其他宿主,门可以不同,屋子里的规则不需要跟着重写。这一点和我之前整理的 Core/Variant 分层实践很接近:稳定能力不应该随着产品外壳一起复制。
FRB 帮我解决了什么
flutter_rust_bridge 是一套 Dart/Flutter 与 Rust 的绑定生成工具。
我们在 Rust 中写普通函数、结构体和枚举,FRB 根据这些定义生成 Dart API 和中间的转换代码。异步 Rust 方法到了 Dart 侧,可以像普通 Future 一样调用;Rust 的结构化结果也可以变成 Dart 对象。
简单来说,它替我处理了三件重复工作:
- 根据 Rust 接口生成 Dart 调用代码;
- 在两种语言之间转换参数和返回值;
- 把 Rust 的同步、异步调用接进 Dart 的使用方式。
一个简化后的调用大概是这样:
pub async fn search_catalog(
session_id: String,
query: String,
offset: u32,
limit: u16,
) -> CatalogSearchResult {
// 调用 Core,再把结果转换成桥接对象
}
Flutter 侧看到的则是:
final result = await core.searchCatalog(
sessionId: sessionId,
query: keyword,
offset: 0,
limit: 30,
);
如果没有 FRB,我需要自己维护更底层的 FFI 函数、内存和类型转换。FRB 把这些重复工作收了起来,让跨语言调用更接近日常 Dart 开发。
FRB 只负责把桥搭起来。桥上应该运什么,仍然要由我们自己决定。
我最后只让几类内容通过它:
| 内容 | 用途 |
|---|---|
| Command | 请求 Core 做一件事,例如同步、播放、暂停 |
| Query | 查询当前数据,不修改状态 |
| Snapshot | Core 已经确认的一份完整状态 |
| Event | 已经发生的事实,例如播放开始或同步完成 |
| Failure | 可识别的错误类型,而不是一段随时会变的文字 |
数据库连接、HTTP Client、播放器句柄和带密码的 URL 都不会越过这座桥。它们是 Rust 内部的实现,不是 Flutter 需要知道的业务结果。
桥接接口宁可少一点,也不要碎一点
为什么细接口容易失控。
刚开始设计 FRB 接口时,很容易把 Rust 对象的每个方法都暴露给 Dart。专辑有一个接口,歌曲列表一个接口,每个字段还可以继续查询。
这样做很像普通面向对象代码,却不适合跨语言边界。
每一次调用都要经过调度和数据转换。接口越细,Flutter 越容易拿到一堆中间结果,然后在 Dart 里重新拼出一份业务状态。最后 Rust 看起来是 Core,真正的流程仍然掌握在 Flutter 手里。
一次表达完整意图。
我现在更喜欢一次表达完整意图:
syncCatalog(source)
searchCatalog(query, page)
replacePlaybackQueue(items, startIndex)
seek(position)
currentPlaybackSnapshot()
Flutter 告诉 Core “我要什么”,Rust 返回“最后发生了什么”。
再加一层手写边界。
这也是我没有让页面直接使用 FRB 生成代码的原因。中间还有一层手写的 Dart Facade 和 Repository:
Widget
→ ViewModel
→ Repository
→ 手写 Facade
→ FRB 生成 API
这几层看起来比直接调用多,但页面从此不用认识 session id、Rust DTO 和桥接异常。以后升级 FRB 或调整 Rust 接口,变化也能停在边界附近。
最大的收益,是状态终于只有一份
这次改造里,我感受最明显的收益不是速度,而是“唯一来源”变得更容易实现。
曲库只在完整成功后换版本。
拿曲库同步来说,旧结构里可能同时存在远端 Provider、Flutter 数据库和页面缓存。同步失败时,如果代码又悄悄回到旧逻辑写一次,就很难判断用户最后看到的是哪一份数据。
迁移到 Rust 后,已经切换的来源只允许 Rust 写入。一次完整同步先在临时区域准备数据,所有分页和扫描都成功以后,再用事务提交一个新版本。中途失败或取消,旧版本继续有效。
旧数据 revision 12
↓
后台准备新数据
↓
成功:一次提交 revision 13
失败:继续保留 revision 12
Flutter 不会看到一半新、一半旧的数据,也不需要猜同步到底进行到了哪一步。
播放命令只进入一条队列。
所有播放、暂停、Seek、切歌和系统中断都进入 Rust 中同一个有序队列。Core 按顺序处理,再发布新的播放快照。Flutter 可以缓存快照用于渲染,却不能自己维护第二套队列规则。
少维护一份状态,往往比优化一段代码更能提高长期稳定性。
Core 可以离开 App 独立活着
调试不再从启动 App 开始。
以前调试曲库问题,我经常需要启动 App、准备页面状态、点击入口,再从一长串日志里找真正失败的地方。
有了独立 Core 后,同一套能力可以直接通过 Rust CLI 调用:
cd rust
cargo test
cargo run -p imusic_cli -- doctor
CLI 不只是一个演示程序。它让我可以在没有 Flutter 的情况下:
- 验证某个来源是否能连接;
- 对固定测试库执行扫描;
- 检查数据库版本和迁移;
- 重放一次搜索或同步;
- 观察播放状态机的变化。
这会反过来改善 Core 的设计。如果一段“核心逻辑”离开页面就无法调用,通常说明它仍然依赖了 UI 环境。
每一层验证不同的问题。
纯规则和状态变化使用 Rust 单元测试;数据库、协议和文件解析使用 fixture;播放状态机接 fake audio backend;FRB 再单独验证两端合同;最后才是真机上的后台播放、蓝牙、中断和生命周期。
Core 规则测试
↓
真实 Adapter 测试
↓
FRB 合同测试
↓
Flutter 页面测试
↓
iOS / Android 真机验证
Rust 不会自动让业务正确,但它让“核心到底有没有脱离 UI 被验证”变成了一个很直接的问题。
Flutter 和 Rust 怎样保持同步
跨语言架构最怕两边各自运行得很好,放在一起却不同步。
我现在主要依靠 Snapshot 和 revision 解决这个问题。
Snapshot 是 Core 在某个时刻确认的完整状态,revision 是它的版本号。Flutter 收到更新以后,只接受比当前更新的版本。如果事件中断、监听建立太晚或版本出现跳跃,就重新向 Core 读取完整 Snapshot,而不是自己猜漏掉了什么。
Rust: revision 40 → 41 → 42
Flutter 收到 40 → 42
↑
发现缺口,重新读取完整状态
Event 适合告诉 Flutter “刚才发生了什么”,Snapshot 则负责回答“现在到底是什么”。两者用途不同。
还有一些协调细节也很重要。
Core 实例不能跟着页面反复创建。
数据库连接、播放核心和后台任务通常是 App 级对象,由 Repository 或 Runtime 统一持有。Widget 重建不应该重新打开 Rust Core,页面也不需要看到内部句柄。
取消必须真的进入 Core。
Flutter 不再等待一个 Future,不代表 Rust 中的扫描已经停止。长任务需要明确的取消命令,而且已经取消的结果不能在晚一点偷偷提交数据库。
错误要让 UI 看得懂。
认证失败、超时、网络不可用、数据损坏和服务不兼容,对用户来说不是同一种问题。Rust 返回稳定错误类型,Flutter 再决定显示重新登录、重试还是修改配置,而不是解析一段错误字符串。
高频数据不能无限过桥。
播放进度和扫描进度可能更新得很快。它们需要节流、合并或设置有界缓冲,不能假设 Dart 永远消费得过来。真正重要的状态还要能通过 Snapshot 重新取得。
这些机制听起来比一次普通方法调用复杂,但它们解决的其实是同一个问题:
Flutter 可以暂时落后,不能拥有另一套答案。
从旧项目迁移时,我没有选择重写
看到目标架构以后,最危险的冲动是一次性把所有 Dart 业务翻译成 Rust。
我没有这么做。
先迁移一条完整链路。
实际迁移是按一条条完整业务链路进行的。先选一个范围清楚的能力,让它从页面动作经过 FRB 到达 Rust,再把结果完整送回 Flutter。
一个页面动作
→ 一个 Flutter Repository
→ 一份 FRB 合同
→ 一个 Rust Use Case
→ 一个真实的数据或引擎 Adapter
→ 一个可以渲染的结果
这条链路跑稳以后,再迁移下一条。
再切换状态的主人。
迁移期间可以保留旧实现做兼容,但每一类状态都要提前决定什么时候切换主人。切到 Rust 后,旧代码可以继续负责尚未迁移的能力,却不能再偷偷修改已经交给 Core 的数据。
最后删除旧入口。
我现在判断一个阶段是否完成,不是看新增了多少新目录,而是看旧的状态所有者能不能删掉。
如果新架构只是不断增加中间封装,旧 Controller 仍然掌握真正状态,那只是把调用链加长了。只有旧写入逻辑、旧队列和旧业务分支退出以后,边界才真正成立。
Rust Core 的稳定不是靠“Rust”两个字
Rust 提供了内存安全、类型系统和不错的并发工具,但它不会替我设计正确的业务。
我在 Core 里最看重的,反而是一些很朴素的原则。
| 做法 | 它避免的问题 |
|---|---|
| 不同含义的 ID 使用不同类型 | 把来源 ID、远端 ID 和全局 ID 混在一起 |
| 数据库只有一个写入者 | 多条异步链路互相覆盖 |
| 完整操作使用事务提交 | UI 读到一半新、一半旧的数据 |
| 播放状态只由 actor 改变 | 点击、系统回调和恢复逻辑争抢状态 |
| 长任务都有超时和取消 | 页面离开后任务仍无限运行 |
| 错误使用稳定分类 | Flutter 只能解析随时会变的报错文字 |
日志也算稳定性的一部分。Core 会记录操作 ID、来源 ID、状态变化和提交版本,但不会记录密码、Authorization Header、token 或带签名的播放地址。
对外的 FRB 合同则尽量稳定。Rust 内部可以换数据库、网络库甚至音频实现,只要命令和结果的含义不变,Flutter 就不需要跟着重写。
我后来越来越认同一件事:
稳定不是内部永远不变,而是内部变化时,外部不需要一起震动。
构建会比纯 Flutter 多一层成本
这套架构不是没有代价。
项目现在通过 Native Assets 构建 Rust。Flutter 构建时会触发 Cargo,再把生成的原生库一起打进应用。FRB 官方也提供了 Native Assets 集成说明。
它省掉了一部分手工 FFI 和平台打包工作,却没有让原生工具链消失。iOS 真机与 Simulator、Android ABI、NDK、Rust target、最低系统版本,以及 FFmpeg 这类 C/C++ 依赖,仍然要分别处理。
FRB 本身也需要版本管理。Rust crate、Dart runtime、codegen 和 build hook 最好保持一致,升级时重新生成绑定,再跑一轮 Rust、Flutter 和平台测试。
这也是为什么我不会建议所有 Flutter 项目都马上接 Rust。
| 得到什么 | 同时付出什么 |
|---|---|
| 更硬的业务边界 | 多一门语言和跨语言调试 |
| 可以独立运行的 Core | 多一套 Cargo 与原生构建链路 |
| 更清楚的状态所有权 | 需要认真设计桥接合同 |
| 复用到 CLI 或其他宿主 | 需要维护多平台二进制产物 |
如果应用主要是普通接口和表单,业务规则不复杂,也没有独立 Core 的需要,清楚的 Dart 分层已经足够。多一门语言、多一套构建和跨语言调试,不一定划算。
但当项目出现下面这些特征时,Rust Core 就值得认真考虑:
- 有复杂而长期存在的业务状态;
- 有大量文件、媒体、索引或本地数据处理;
- 多个异步入口经常争抢同一份状态;
- 希望核心脱离 Flutter 独立测试;
- 未来可能有 CLI、桌面端或其他宿主;
- 团队愿意维护 Rust 和原生构建链路。
关键不是项目有多少页面,而是有没有一块真正稳定、值得独立出来的核心。
如果再改造一个旧项目
经历这次实践以后,如果再面对一个越来越大的 Flutter 项目,我会先画出状态流向,而不是先安装 FRB。
我会找到最容易失控的那份状态,确认现在有几个模块可以修改它;然后为它选一个唯一负责人,定义一条足够完整的命令和结果;接着用最小纵向链路打通 Flutter、FRB 和 Rust。
过程中我会特别留意几个坑:
- 不把旧 Controller 原样翻译成 Rust;
- 不让 Flutter 和 Rust 长期双写;
- 不让 Widget 直接调用生成 API;
- 不设计一大堆细碎的 FFI getter;
- 不把 Future 返回误认为业务已经提交;
- 不只跑 Rust 单元测试,而忽略真机生命周期;
- 不为了“保险”保留永远不会删除的旧权威。
这些经验并不只适用于音乐 App。
下载管理、文档处理、图片和视频工具、离线数据库、同步引擎、加密工具,甚至一些复杂设备协议,都可能使用同一种思路:Flutter 保持对交互和平台体验的控制,Rust Core 保存真正需要长期稳定的业务能力。
回头看
我最初以为 Flutter + Rust 的重点会是 FFI、性能和原生构建。
真正做下来,最有价值的变化却是职责变清楚了。
Flutter 不再需要理解数据库怎样提交一次同步,也不需要维护播放器内部的第二套状态。它只表达用户意图,再把 Core 已经确认的结果呈现出来。
Rust Core 也不关心按钮是什么颜色、页面从哪里跳转。它负责让一条命令以确定的顺序发生,让失败有明确含义,让可以恢复的状态真正被提交。
FRB 位于中间,不替任何一边做业务判断,只保证两边能使用同一份合同说话。
Flutter 负责体验
FRB 负责连接
Rust Core 负责事实
这大概就是我目前对这套架构最直观的理解。
它没有消灭复杂度,也没有让项目从此不出错。它只是让复杂度不再到处流动:界面的复杂留在 Flutter,核心的复杂留在 Rust,两者之间只通过少量、稳定而清楚的接口合作。
对一个已经开始变重的 Flutter 项目来说,这种边界本身,可能比单纯换一门更快的语言更有价值。