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

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

- Canonical (HTML): https://kaguc.com/blog/flutter-project-structure-zh/
- Date: 2026-08-28


*English version: [Flutter project structure: packages, boundaries, and add-to-app](/blog/flutter-project-structure/)。这是[交易类 App 架构](/blog/trading-app-architecture-zh/)的 Flutter 实现篇，原理见[模块化与迁移](/blog/modular-evolution-zh/)。*

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

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

## 一个功能一个 package

```text
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 框架更换活得更久，这正是[依赖倒置](/blog/modular-evolution-zh/)的落地形态。

## 用工具强制这条规则

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

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

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

## 把路由做成注册表

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

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

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

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

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

## 只在根部做组合

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

```dart
// 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 嵌入一个既有原生应用，也就是[绞杀榕式迁移](/blog/modular-evolution-zh/)，有四项成本需要提前规划。

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

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

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

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

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

## 什么时候这一套是过度的

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

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


## 参考

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

- Dart 文档，[Pub workspaces](https://dart.dev/tools/pub/workspaces)（Dart 3.6+）——单仓多包共享一次解析、一个 lockfile。
- pub.dev 上的 [`custom_lint`](https://pub.dev/packages/custom_lint)——项目自定义 lint，包括能让 CI 失败的导入边界规则。
- R. C. Martin，["The Clean Architecture"](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)，2012——依赖规则：源码依赖只能向内、指向策略层。
- D. L. Parnas，["On the Criteria To Be Used in Decomposing Systems into Modules"](https://doi.org/10.1145/361598.361623)，*CACM* 15(12)，1972——模块应当隐藏一个可能变化的决策。
- M. Fowler，["Inversion of Control Containers and the Dependency Injection pattern"](https://martinfowler.com/articles/injection.html)，2004——依赖注入与服务定位器的对比，以及各自在可测试性上的代价。
- Flutter 文档，[Add Flutter to an existing app](https://docs.flutter.dev/add-to-app) 与 [Multiple Flutter instances](https://docs.flutter.dev/add-to-app/multiple-flutters)——引擎预热、`FlutterEngineGroup`，以及多个存活引擎的代价。

---

*上一篇：[离线存储与搜索](/blog/flutter-offline-store-zh/) · 下一篇：[行情 MVP 的参考方案](/blog/flutter-trading-mvp-zh/)*

