返回列表

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 UI 通过 FRB 与 Rust Core 协作的架构概念图
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
调试业务必须启动完整 AppCore 可以单独测试,也可以通过命令行运行
多个 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 对象。

简单来说,它替我处理了三件重复工作:

  1. 根据 Rust 接口生成 Dart 调用代码;
  2. 在两种语言之间转换参数和返回值;
  3. 把 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查询当前数据,不修改状态
SnapshotCore 已经确认的一份完整状态
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 项目来说,这种边界本身,可能比单纯换一门更快的语言更有价值。

延伸阅读