BLOG · #Engineering

Flutter 工程结构:package、边界与 add-to-app

如何组织 Flutter 代码库,让边界是被强制的而不是被建议的:一个功能一个 package、一条工具能检查的依赖规则、把路由做成注册表;以及当你嵌入既有原生应用时,add-to-app 真实的代价。

English version: Flutter project structure: packages, boundaries, and add-to-app。这是交易类 App 架构的 Flutter 实现篇,原理见模块化与迁移。

目录约定造不出边界。一个 lib/features/ 目录只是一个建议,任何文件都可以跨过它导入任何别的文件。一旦有超过几个人在同一个代码库里工作,建议就不再成立。

Dart package 是边界真正被强制的最小单位:一个 package 无法导入它没有声明的东西。这就是动用它的全部理由。

一个功能一个 package

apps/
  trading_app/            # 组合根:装配、路由、构建变体
packages/
  design_system/          # 设计令牌、组件、主题
  domain/                 # 实体、值对象、规则,不导入 Flutter
  data/                   # 仓储、api 客户端、数据库
  feature_quotes/
  feature_watchlist/
  feature_orders/

让它成立的只有一条规则:功能依赖 domain、data 和 design_system;功能之间永不互相依赖;只有 app 依赖各个功能。

正是这一条约束带来了全部收益。它意味着一个功能可以被独立开发、测试和删除;意味着改动 feature_orders 在构造上就不可能弄坏 feature_quotes;也意味着你的构建系统可以并行编译和测试它们。

有一个大家会本能抗拒的推论:如果两个功能需要同一样东西,那它属于 domain 或某个共享 package,而不是被横向导入。横向导入正是一个模块化代码库悄悄退化成“带很多 pubspec.yaml 的单体”的方式。

让 domain 完全不含 Flutter。它应该是纯 Dart:实体、规则,以及由外部世界实现的接口。这让它无需 widget binding 就能快速测试,也意味着业务逻辑能比一次 UI 框架更换活得更久,这正是依赖倒置的落地形态。

用工具强制这条规则

没人检查的边界,会在第一个忙碌的迭代里被磨平。让违规直接让 CI 失败。

便宜的做法是加一条 lint,禁止跨功能导入,可以用 custom_lint,或者在分析选项里禁掉特定导入路径。更便宜的做法是:因为各功能本来就是独立 package,一次未声明的导入已经是编译错误。仅仅靠“不要把所有东西放进一个 package”,你就白得了大部分强制力。

再加两条检查:没有任何 package 依赖 app;domain 除了 Dart 之外不依赖任何东西。两条都是一行规则,却能拦住真实的架构漂移。

把路由做成注册表

如果 app 直接导入每个功能的页面,那么新增一个功能就要改 app,而 app 会变成你本想避开的那个耦合点。

改成让每个功能暴露自己的路由,由 app 来组合:

// 在 feature_quotes 中
final quotesRoutes = <RouteDef>[
  RouteDef('/quotes/:symbol', (ctx, args) => QuoteDetailPage(symbol: args['symbol']!)),
];

// 在 apps/trading_app 中
final router = AppRouter([
  ...quotesRoutes,
  ...watchlistRoutes,
  ...ordersRoutes,
]);

功能之间的跳转从此经由路径,而不是经由被导入的组件类。这既让功能互不依赖,又保留了互相跳转的能力。它同时给深链接提供了唯一的校验位置;而按安全那篇的说法,一个进来的链接必须被当作敌意输入,并在目的地重新校验,而不是因为它到达了就被信任。

只在根部做组合

依赖注入属于 app 这个 package。各功能在构造函数里声明自己需要什么,由根部构建对象图。

// apps/trading_app/lib/bootstrap.dart
final db = await openCatalog();
final api = ApiClient(baseUrl: env.apiUrl);
final quotes = QuoteRepository(api: api, db: db);
runApp(App(quotes: quotes));

在功能内部去取一个全局服务定位器,会把这一切抵消掉:该功能从此有了一个不出现在构造函数里的隐藏依赖,在测试中无法替换(除非做全局设置),而且这个 package 不再可移植。定位器放在组合根没问题;在功能深处的某个组件里调用定位器,那只是一个礼貌一点的全局变量。

Add-to-app,以及它的代价

如果你要把 Flutter 嵌入一个既有原生应用,也就是绞杀榕式迁移,有四项成本需要提前规划。

引擎启动。 第一个 Flutter 页面要承担引擎初始化。在应用启动期间预热一个缓存引擎,别让首次跳转成为用户等待的那一刻。

多个引擎。 FlutterEngineGroup 让额外的引擎共享快照和大部分内存开销,这使得“多个引擎同时存活”从不可行变为可行。但它仍然不是免费的:除非你确实需要相互隔离的页面,否则优先用一个引擎加内部路由。

两个导航栈。 原生栈和 Flutter 的路由同时存在,而返回键必须在这条接缝上表现合理。尽早决定某个流程的栈归谁所有,并让所有返回处理都遵循这个决定,而不是等问题冒出来再逐个打补丁。

重复的状态。 会话、主题、语言现在两侧都有。让一侧成为权威,并通过单一通道把变更推过去;不要让两侧各自维护一份,然后指望它们保持一致。

创建引擎时把初始路由和参数一起传入,返回结果走一条窄通道而不是若干临时通道。通道那篇里“把边界做粗”的规则,同样适用于导航。

什么时候这一套是过度的

对一到三个开发者、单个应用来说,这套结构就是开销:更多 pubspec.yaml、更多版本协调(Dart 3.6+ 的 pub workspaces 能消掉其中大部分——整个仓库一次解析、一个 lockfile——但消不掉其余的)、更慢的冷构建,以及一层背后什么也没有的间接。

先用一个 package 加目录起步。等某条边界开始硌人时再抽出 package:你想在没有 widget binding 的情况下测试某一层、两个人总在同一个区域打架、或者你需要在第二个应用里复用某样东西。这些都是观察结果。“我们可能会长大”不是;而一个小团队维护着大团队的结构,是在为一个并不存在的组织支付协调成本。

参考

API 与渲染器相关断言按 Flutter 3.47.0 / Dart 3.13(2026 年 8 月)核对。


上一篇:离线存储与搜索 · 下一篇:行情 MVP 的参考方案