Flutter 项目组里经常有人问我:新项目目录结构该怎么搭?说实话,这个问题比选什么状态管理库、用什么路由框架都重要。状态管理选错了可以换,路由方案不满意可以重写,但目录结构一旦定了,后面每个文件的新增和修改都在往这个框架里塞东西。结构不合理,前三个月开发速度还能撑住,半年后加一个需求可能要牵连七八个文件,改完还会冒出新 Bug。我做 Flutter 也踩过不少坑,从最初所有页面塞在 pages 文件夹里,到后来按业务模块重构,中间折腾了好几个版本,这篇文章就把我目前觉得最能支撑长期迭代的一套结构设计思路完整分享一下。
先说清楚,这套思路不是某一种固定模板,而是设计原则的组合。核心解决三件事:模块间解耦、状态边界清晰、构建和测试不失控。适合参考的人群很广,不管你是刚入手 Flutter 的新人,还是团队里要定代码规范的技术负责人,都能找到用得上的内容。对于新人来说,照着这套目录去组织代码,至少不会写出连自己都找不到文件的项目;对于团队负责人来说,我给出的模块划分方式和依赖约束规则,可以直接拿去做 Code Review 的检查依据。
1. 项目结构设计的底层逻辑
1.1 先搞清楚结构到底在管什么
很多人把项目结构理解成“把文件分类放好”,比如页面放 pages,组件放 widgets,工具函数放 utils。这种按“文件类型”划分的方式不是错,但它只解决了文件夹美观的问题,没解决代码依赖的问题。
举个例子,一个电商 App,有商品列表、商品详情、购物车、订单结算这四个页面。按类型划分的话,四个页面都塞进 pages 文件夹,共用的商品卡片组件放 widgets,商品数据请求放 services,状态管理统一丢给 provider。第一眼看上去很整齐,但是订单结算页要改商品数量的时候,它到底该调 services 里的哪个方法?购物车页用的商品模型和商品详情页用的商品模型是不是同一个?如果我要换一个新的推荐算法接口,spread 范围到底有多大?这些问题靠“类型文件夹”是回答不了的。
长期迭代的项目,文件会越来越多,依赖关系会越来越复杂。项目结构真正要管理的东西不是文件位置,而是模块之间的依赖方向。所以设计目录结构之前,先想清楚你的模块边界在哪里,谁依赖谁,谁不能依赖谁。
1.2 长期迭代的三大杀手
我观察过不少 Flutter 项目,包括我自己早期写的一些代码,在中后期都会撞上同样几堵墙。
第一堵墙是循环依赖。A 模块的页面跳转到 B 模块,B 模块的数据模型又引用了 A 模块的工具类,编译报错的时候才意识到问题,但代码里已经来回勾连了。第二堵墙是状态失控。全局的 Provider 或 Bloc 越来越多,有的页面直接用全局状态,有的页面自己内部 new 一个状态,数据的流向乱七八糟,需求一改就到处牵连。第三堵墙是构建变慢。整项目所有代码挤在一个模块里,没有按 Feature 拆分,改动任何一点都要触发大范围重编译,等编译的时间比写代码的时间还长。
这三堵墙都能通过合理的模块划分来缓解,这也是我下面要讲的核心思路。
1.3 功能性架构优先于分层架构
Flutter 项目里常见的架构分层是 UI 层、业务逻辑层、数据层,然后按层建文件夹。这种分层架构在理论上是清晰的,但在实际业务里容易产生“串层”问题。
比如一个登录功能,LoginPage 在 UI 层,LoginBloc 在逻辑层,AuthRepository 在数据层。看起来是三层分离,但如果你有十个人在同一个仓库里开发,每个人负责不同模块,你会发现 Login 的 UI、逻辑、数据被分散在三个大文件夹里,改一个登录需求要在三个文件夹之间跳来跳去。更麻烦的是,项目里所有功能的 UI 全堆在同一个 UI 层文件夹里,页面一多,UI 层文件夹就会出现几百个文件的大杂烩。
我现在的推荐是功能性架构,也叫 Feature-first 架构。按业务功能组织代码,每个功能模块内部再划分数据、逻辑、展示层。换句话说,登录相关的所有代码放在一个目录里,商品相关的所有代码放在另一个目录里。这样改需求的时候,大部分改动集中在一个 Feature 内部,不会跨层跨文件夹乱跑。
功能性架构也不是不碰分层。它把分层下沉到了 Feature 内部,每一个 Feature 都保持“展示、逻辑、数据”的三层结构,宏观上没有丢分层思想,微观上又实现了高内聚。这相当于用文件夹结构把大问题切成小问题,每个小问题自己内部保持整洁。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一套能扛住长期迭代的 Flutter 目录结构模板
2.1 顶层目录划分
直接上我目前用得比较顺手的一套顶层结构:
code复制lib/
├── main.dart
├── app/
│ ├── app.dart
│ ├── router/
│ ├── theme/
│ └── di/
├── core/
│ ├── constants/
│ ├── errors/
│ ├── extensions/
│ ├── network/
│ ├── utils/
│ └── widgets/
├── features/
│ ├── auth/
│ ├── home/
│ ├── cart/
│ └── order/
└── shared/
├── models/
└── data/
我来逐个解释这些目录的作用。
app 目录放应用的启动和全局配置代码。main.dart 只保留最少的入口逻辑,比如初始化绑定、初始化依赖注入容器,然后调用 App 这个根组件。app.dart 负责组装全局的东西,路由表、主题、顶层 Provider 或者 MultiBlocProvider。所有需要“先于业务跑起来”的东西,都收敛在 app 目录里。
core 目录放与业务无关的基础能力。网络请求封装、通用错误处理、常量定义、字符串扩展、通用组件。这里面有个关键约束:core 目录下的代码不允许依赖 features 目录里的任何东西。一旦 core 里出现业务逻辑,就说明你的分层开始腐烂了。
features 目录是主体。每一个业务功能模块放在一个独立文件夹里,比如 auth 负责登录注册,home 负责首页,cart 负责购物车。每个 Feature 内部有自己的数据层、逻辑层和页面层。Feature 与 Feature 之间不直接引用,如果有共享的业务模型,下沉到 shared 目录。
shared 目录放跨 Feature 共享的业务模型和数据访问。比如用户模型、商品模型,以及操作这些模型的 Repository 基类或者数据源封装。这里要特别注意,shared 目录里放的是“业务共享”而不是“工具函数”。工具类仍然放 core,但模型和业务数据放 shared,这样 Features 之间依赖 shared 而不依赖彼此的代码。
这套结构和“按层建文件夹”最大的区别在于,每一个业务模块是完整独立的。假设你要删除整个购物车模块,只需要删掉 cart 这个文件夹,再把路由表里对应条目移除,不会在其他地方留下散落的引用。
2.2 Feature 内部的标准布局
顶层结构定好以后,Feature 内部的划分更容易写乱。我见过一个 Feature 下直接平铺十几个文件,页面、状态、模型、接口全混在一起。Feature 里面也要有内部层次。
我的习惯是给每个 Feature 建统一的 data、domain、presentation 三个子目录:
code复制features/
└── auth/
├── data/
│ ├── models/
│ ├── repositories/
│ └── datasources/
├── domain/
│ ├── entities/
│ └── repositories/
└── presentation/
├── pages/
├── widgets/
└── controllers/
data 层负责跟外部世界打交道。API 请求、本地数据库、偏好设置都在这层。repository 的接口实现放在这里,datasources 区分远程数据源和本地数据源。
domain 层是业务规则的核心。entities 是纯的业务对象,不掺任何 Flutter 框架代码;repositories 在这里定义抽象接口,具体的实现在 data 层。domain 层不依赖任何其他层,这就是所谓的“依赖倒置”,非常关键。
presentation 层放所有跟 UI 相关的东西,页面、组件、状态管理。页面负责组装组件,组件负责展示数据,控制器或 Bloc 负责状态流转。
三层之间有一个严格的依赖方向:presentation 依赖 domain,domain 依赖 data 的接口定义,但 data 要反过来实现 domain 里定义的抽象接口。我不知道你有没有感觉到,这个设计把核心业务逻辑放在正中间,不受 UI 和第三方库影响。将来把 Flutter 页面换成 SwiftUI 的壳,或者把底层 API 从 Retrofit 换到 Dio,对 domain 层都没有影响。
2.3 入口文件只做入口该做的事
很多人写 Flutter 项目,main.dart 里动不动就是上百行代码,又是初始化数据库,又是注册各种第三方 SDK,还要准备路由表。这些迟早会变成维护噩梦。
我的 main.dart 一般控制在二十行以内,只做三件事:初始化依赖注入配置、初始化全局异常处理、然后 runApp。真正的内容在 app.dart 里,app.dart 加载路由表、主题、注册全局 Provider,然后返回 MaterialApp.router。这样当你看一个项目时,打开 main.dart 就能秒懂项目从哪启动,想要排查路由问题就直接看 router 配置,想改主题直接进 theme 目录,不需要从入口文件开始一路往下翻。
3. 支撑长期迭代的关键机制
3.1 路由设计:用声明式路由表取代散落的导航
Flutter 开发里最容易产生“结构性债务”的地方就是路由。早期教程特别喜欢直接 Navigator.push(context, MaterialPageRoute(...)),代码写起来很快,但代价是页面跳转关系散落在各个页面的代码里。项目大了以后,你想知道“从购物车能不能跳到订单页”,只能全局搜索 push 方法。
长期迭代的项目,我建议用 go_router 之类的声明式路由统一管理。集中定义一个路由表,把每个 Feature 的路由集中注册到一起。比如 go_router 里一个典型配置:
dart复制final appRouter = GoRouter(
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/cart',
name: 'cart',
builder: (context, state) => const CartPage(),
),
GoRoute(
path: '/order/:id',
name: 'order',
builder: (context, state) => OrderPage(orderId: state.pathParameters['id']!),
),
],
);
这样的好处不仅仅是“集中管理”四个字。声明式路由把页面关系变成数据,你可以在任意位置跳转,只需要知道路由名字和参数;将来做深链、Web 端 URL 映射、权限控制这些复杂需求,都有统一的扩展点。它的核心价值是让导航不再散落。
另外一个容易被忽略的点是,Feature 拆分的场景下,路由表本身要支持“分块注册”。如果你把路由表写成一个巨大的单文件,那么这个文件会变成新的“上帝类”。我一般会让每个 Feature 暴露一个 getRoutes 方法,统一返回自己模块内的路由配置,app/router 目录负责整合所有模块的路由片段。这样加一个 Feature,只需要在整合处加一行注册代码。
3.2 状态管理与数据流方向
Flutter 生态里状态管理方案很多,Bloc、Riverpod、Provider、GetX,吵得不可开交。我的观点是,方案本身不是最重要的,数据流方向才是。
项目结构设计时,要给数据流画一条清晰的“高速公路”:UI 事件发给 Controller/Bloc,Controller 调用 domain 层的用例,用例从 repository 拉数据,数据通过状态流回 UI,单向不可逆。不管用哪个状态管理库,这条线路都不应该断。
以 Riverpod 为例,我倾向于在 Module 内部定义 Provider,而不是全部放在全局。比如 auth 模块的登录状态,就在 auth 模块内部声明一个 authControllerProvider,页面在自己的模块里读。只有真正全 App 都要用的数据才放到 app 目录顶层去注册。这样下来,全局依赖很少,每个模块的自治性强。
如果团队更喜欢 Bloc,思路也一样。一个 Feature 内部可以只注册自己的 BlocProvider,不要一股脑地把所有 Bloc 都挂到 app 顶层。很多项目到了后期,全局 Provider 越挂越多,初始化时间变长,状态刷新范围变大,性能问题反而不如模块化内部管理来做得好。
3.3 依赖注入:让 Feature 之间不直接 import
模块之间不能直接 import,这个约束听起来简单,实际执行很难。稍微一不留神,A 模块的图标组件就 import 了 B 模块 Common 里的常量,C 模块的页面就调起了 D 模块的某个工具函数。依赖一多,循环引用只是时间问题。
依赖注入是解决这个问题的好工具。我的做法是,Feature 对外暴露的类通过抽象接口定义,具体的实例在应用组装层通过 get_it 或者 Riverpod 的 override 来注入。比如 cart 模块需要一个计算订单价格的 interface,那么 cart 的 domain 层里定义抽象类,order 模块负责实现这个抽象类,然后在组装层绑定接口和实现。
这样一来,cart 模块不需要 import order 模块的任何代码,它只需要依赖一个接口。模块之间的耦合降到了最低,将来换实现、做单元测试都会非常轻松。
4. 资源、主题与多语言,中后期迭代的隐形杀手
4.1 资源文件命名与自动生成
Flutter 的资源文件管理比 Web 项目更麻烦。图片、音频、字体、JSON 数据,全都得在 pubspec.yaml 里声明。项目小的时候没什么感觉,图片多了以后就会出现两种情况:资源路径写错了编译能过但运行时报错,或者资源文件堆积找不到哪些在用哪些已经废弃。
我比较推荐的做法是,按模块分配资源目录,而不是把所有图片放在一个 assets/images 大文件夹里。
code复制assets/
├── images/
│ ├── common/
│ ├── auth/
│ └── cart/
└── fonts/
每个 Feature 对应的图片资源,在 pubspec.yaml 里通过分段声明引入。这样能非常清楚地看出某个模块自带多少资源,要做图片压缩或者替换时,影响范围一目了然。同时建议启用资源生成工具,用 flutter_gen 之类的工具自动生成资源引用代码,写代码时能避免手写字符串路径的坑。这里有个细节,如果你的项目里有大量 SVG 或图标,建议设计成常量统一管理,否则 UI 一旦换图标样式,你得全局搜索替换。
4.2 主题文件不等于设计系统
Flutter 里的 ThemeData 可以做很多事情,字体、颜色、组件默认样式,都可以在里面配置。很多新手把主题理解成一个配色文件,但长期迭代的项目里,主题应该是设计系统的代码映射。
我的 theme 目录会拆成多个维度:
code复制lib/app/theme/
├── app_colors.dart
├── app_text_styles.dart
├── app_spacing.dart
├── app_radius.dart
└── app_theme.dart
这个拆分对应的是一套完整的设计语言:颜色、字号、间距、圆角全部有常量定义。组件里不允许直接出现 Color(0xFF333333) 这种魔法值,统一走主题常量。这样做的好处是,视觉改版只需要改动 theme 目录,不会出现设计师调整一个主色,你得改十几个页面的尴尬。
有人会觉得这有点小题大做,项目工期紧,直接写个颜色值多快。但到了中后期,你会发现样式的改动频率远超预期,一套好的主题目录结构能节省大量改 UI 的时间。
4.3 国际化的早期投入
Flutter 国际化的标准做法是使用 flutter_localizations 加 ARB 文件。很多项目英文上线都来不及,更别说做多语言。但项目结构如果不在早期预留国际化的位置,后期补起来会很被动。
我通常在 app 目录下建一个 l10n 目录,所有文案抽离到 ARB 文件中。即使当前只有一个语言,也坚持让 UI 代码里不出现裸的字符串常量,一律通过 AppLocalizations.of(context) 读取。这样做还有一个额外的好处:文案变更需求几乎天天有,集中管理文案比在代码里翻找字符串要省事得多。
5. 测试结构设计:目录要为可测试性买单
5.1 测试文件布局与命名
Flutter 的测试代码默认放在项目根目录的 test 文件夹里,但 test 文件夹内部怎么组织,很多项目完全随缘。
我的习惯是让 test 目录结构跟 lib 目录一一对应:
code复制test/
├── core/
├── features/
│ └── auth/
│ ├── data/
│ ├── domain/
│ └── presentation/
└── shared/
这样的好处是,某个 Feature 相关的新测试文件放在哪里,测试数据和测试替身怎么命名,都有据可依。团队协作时不需要做解释,每个人都能快速找到对应的测试位置。
具体到测试粒度,domain 层是最应该写单元测试的地方,因为核心业务逻辑几乎都在这里。data 层可以写一些集成测试或者接口 mock 测试。presentation 层写 widget 测试,重点验证组件的渲染和交互,而不是去重复测业务逻辑。
5.2 测试替身与依赖注入的配合
很多 Flutter 项目的测试写得痛苦,是因为业务代码里直接创建了依赖对象,测试时没法替换。比如你在一个 Controller 里直接 UserApi(),那测试时就没办法模拟一个假的后端。
但如果你的依赖注入是通过构造器传参或者 DI 容器获取的,情况就完全不一样。写测试时只需要 override 掉网络层实现,注入一个 Fake 数据源,就能验证后续所有逻辑。这也从侧面说明了依赖注入设计在项目结构中的重要意义。它不只是为了解耦,更是给测试留了一扇门。
我给 Feature 里的 repository 接口写默认实现时,会额外定义一个 FakeRepository 放在 test 目录里。测试时直接启用 fake,快速验证业务流,不需要走真实的网络请求。这样测试运行稳定、速度快,也避免了很多 flaky test 的困扰。
6. 常见问题与排查技巧实录
6.1 循环依赖的识别与清理
循环依赖是模块化项目里最让人头疼的问题。症状是编译时提示 cycles in the dependency graph,或者某些文件在 IDE 里突然报错找不到符号。根源通常是 A 模块用了 B 模块的类,B 模块又直接或间接引用了 A 模块的东西。
发现循环依赖之后,最简单的处理方式是找两个模块之间真正共享的内容,把它下沉到 shared 目录或者 core 目录,让双方都依赖更低层的东西。这里我给一个小技巧:平时写代码时如果发现一个类被多个模块引用,你的第一反应应该是“这个类是不是放错位置了”,而不是“再在新模块里 import 一次”。
6.2 巨型 Controller / Bloc 拆分
状态管理类越写越大,几乎是每个项目都会出现的正常现象。最初一个订单页的 Bloc 只有几十行,后来加了优惠券、配送地址、备注信息、支付方式,膨胀到上千行。
拆分的标准不是代码行数,而是“单一职责”。如果一个 Controller 里既有购物车数量增减,又有收货地址的增删改查,那它至少应该拆成两个 Controller。拆完之后,通过组合的方式在两个 Controller 之间传递数据。每次重构都保留一个原则:让 Controller 的方法名能直接表达业务意图,比如 loadCartItems()、toggleItemSelection(),而不是一堆 updateState() 这种通用命名。
6.3 第三方依赖升级时的结构应对
Flutter 生态更新非常快,第三方库的版本升级经常会带来破坏性变化。比如最近比较热的 Impeller 渲染引擎迭代,对部分自定义绘制组件的兼容性就有影响。一个结构混乱的项目很难应对这种不确定性,因为依赖库的影响范围不清晰,升级一个包可能要改十几处甚至几十处代码。
模块化结构的优势在这里就体现出来了。第三方库如果只被某一个 Feature 使用,升级影响就限定在这个 Feature 内部;如果被 core 层使用,因为 core 层对外只暴露抽象,升级后只需要调整 core 层内部实现,上层调用方完全不知道底层换了实现。因此,我在项目里会对每个第三方依赖标记一个“使用范围”,一旦某个包被多个模块引用,就考虑先封装一层抽象,而不是让所有模块直接依赖这个包的 API。
6.4 快速判断文件该放哪里
最后分享一个我自己写代码时的决策清单。新增一个文件,先问三个问题:这是不是跟某个业务功能强相关?如果是,放进对应 Feature 的相应层。这是不是跨模块共享的业务数据或模型?如果是,放 shared。这是不是连业务都算不上的基础能力和工具?如果是,放 core。每次五秒钟,就能避免很多文件夹的杂乱。
这个决策清单还可以作为新人培训的入门文档。团队里最怕的不是技术难,而是新人不知道东西放哪,随手建个新文件夹,三个月后项目里出现七八个作用不明又没法删的文件夹。
我自己从最初的无结构到现在的模块化结构,踩过不少弯路,最深的体会是:项目结构一定是为业务服务的。业务是活的,结构也要留出调整的空间,但核心原则——按功能内聚、依赖单向、接口解耦、全局收敛——这些不会变。你可以根据自己项目的体量调整文件夹的层级,但千万不要丢掉这几条原则。等到项目迭代到第三个版本还觉得改代码不吃力的时候,你就能感觉到当初这些设计花得值。
