
1. 先想清楚项目结构到底是给谁看的很多团队拿到 Flutter 项目后第一件事就是照着网上某个模板把文件夹建好然后开始往里面堆页面。前两周开发速度飞快到了第三个月改一个按钮的样式要翻四个文件夹新增一个接口要同时改动数据层、仓库层、状态管理层最后所有人都不敢动别人的代码。这不是代码写得烂是结构从一开始就没想清楚“它到底在服务谁”。项目结构不是给编译器看的编译器不管你文件放哪里都能跑。结构是给“六个月后的你”和“新加入的同事”看的。长期迭代的项目最贵的成本不是写代码而是“找到该改哪里”和理解“改了之后会不会炸”。一个合格的结构应该让新人打开项目之后能在十分钟内说出“这个功能的代码在哪里”“这个数据的来源是什么”“我要加一个页面该动哪些文件”。如果做不到结构就是失败的不管它看起来多么整洁。很多人混淆了“整洁”和“合理”。按类型分层的结构看起来非常整洁pages 文件夹放所有页面widgets 文件夹放所有组件models 文件夹放所有模型。小项目这样没问题但项目一迭代这种结构就开始出问题。页面和它专属的组件被拆散到两个地方改一个页面要跨目录来回跳模型散落在全局不同业务互相引用久而久之谁都不敢动公共模型。我见过最夸张的一个项目models 目录下有两百多个文件其中三分之一已经没有任何代码引用了但没人敢删因为搞不清楚到底谁在用。所以讨论 Flutter 项目结构不是在讨论文件夹长什么样而是在讨论“业务的组织方式”。这也是为什么这些年 feature-first 结构越来越流行——它本质上不是目录方案而是一种“以业务模块为单位来组织代码”的思维方式。1.1 先对比layer-based 和 feature-first 到底差在哪过去最经典的结构是 layer-based也就是按技术角色分层lib/ pages/ widgets/ models/ services/ utils/这是很多 Flutter 教程默认教的结构也是我最早用的结构。它的优点是上手快文件分类一目了然适合原型期和极小型项目。但它有个致命问题业务的“完整性”被切碎了。比如“登录”这个功能它的页面在 pages/login.dart专属组件在 widgets/login_input.dart网络请求在 services/auth_service.dart数据结构在 models/user.dart。你想完整理解登录功能的链路需要在四个目录之间来回跳。feature-first 结构则是反过来按业务模块切分lib/ features/ login/ presentation/ application/ data/ domain/ core/ network/ router/ theme/ utils/登录功能的所有代码都收拢在 features/login 下面。要改登录只需要进入这一个目录相关的页面、状态、接口、模型全都在里面。理解成本和修改成本都被大幅压缩。而这个结构真正厉害的地方在于边界不同 feature 之间不允许随意互相引用公共的东西下沉到 core每个 feature 内部可以独立演进、独立测试。我在团队里做过一次对比实验两个同期入职的新人一个被安排到 layer-based 的老项目一个被安排到 feature-first 的新项目。两周后在功能相同的任务上feature 项目的同学找代码和定位问题的速度明显更快原因很简单——他在只需要理解一个模块的内部结构而不是整个项目的横向切片。1.2 长期迭代里结构要防的到底是什么长期迭代中最大的敌人有三个循环依赖、隐性耦合、重复造轮子。循环依赖在 Dart 里有时编译期就能发现但更多时候是逻辑上的互相依赖——A 功能调用了 B 的方法B 又反向依赖 A 的数据两个人改着改着就缠在一起。feature-first 对这一点有天然的约束feature 之间是平级的不允许互相 import如果需要协作必须走 core 里的公共能力或者通过事件、回调来解耦。这条规则一旦强制执行很多架构恶化的苗头在 code review 阶段就被掐掉了。隐性耦合比循环依赖更隐蔽。它通常表现为“我以为这个改动只影响自己模块结果把别人的页面弄崩了”。比如全局模型 User 被二十个页面共用你给 User 加一个字段看起来是纯新增结果某个页面的 JSON 解析逻辑因为字段缺失直接抛异常。这也是为什么我越来越倾向于在 feature 内部定义自己的数据模型而不是所有功能共享一套全局模型。不同功能对同一实体的关注点往往不一样订单模块关心的字段和用户模块关心的字段有大量重叠但它们的生命周期和变更频率完全不同混在一起只会互相牵制。重复造轮子则是最容易被忽视的。没有 clear 的公共层每个团队都会自己写一套日期格式化、自己封装一套网络请求。短期看是灵活长期看是维护地狱。core 层的意义不是把工具函数集中堆放而是明确告诉所有人“这类问题已经有标准答案不要自己再写一遍”。2. 一套能撑三到五年迭代的目录骨架我维护过好几个跨三到五年迭代的 Flutter 项目最终沉淀下来一套目前用得最顺手的骨架。它不是拍脑袋发明的是我试过 MVP 结构、clean architecture 严格分层、模块化方案之后折中的结果——既要保持边界清晰又不能过度设计到文件数量爆炸。lib/ main.dart # 入口只做启动初始化和路由挂载 app.dart # 根 Widget配置全局主题、多语言、路由 core/ constants/ # 全局常量、枚举、配置项 theme/ # 主题相关颜色、字号、间距 network/ # dio 封装、拦截器、错误处理 router/ # 路由表和路由工具 utils/ # 纯函数工具类 widgets/ # 跨 feature 复用的基础组件 services/ # 全局服务存储、日志、埋点等 features/ login/ data/ models/ repositories/ datasources/ domain/ entities/ repositories/ # 抽象接口 application/ cubits/ # 状态管理 presentation/ pages/ widgets/ home/ profile/ settings/ shared/ widgets/ # 被多个 feature 复用但与业务相关的组件 extensions/ # 拼接业务相关的扩展方法先说这个结构的核心原则不搞 strict clean architecture 那么重的分层但保留了最重要的依赖方向约束——外层可以依赖内层内层不依赖外层feature 之间不互相引用公共能力下沉到 core 和 shared。对于中小团队和长期迭代来说这个尺度刚刚好既不会像完全自由式那样失控也不会像教科书架构那样每个功能要写六个文件。2.1 core 层只放与业务无关的“基础设施”core 层是全局能力的底座。它有一个很严格的筛选标准放在这里的代码不能知道任何业务细节。比如网络层封装它只管发请求、处理错误码、返回数据但绝不能写“如果用户未登录就跳转到登录页”这种逻辑——那是业务层的活。这层里面最容易烂掉的是 utils 和 widgets。几乎每个项目都会把这两个目录变成垃圾场写了一个只在某个页面用的组件图省事塞到 common widgets 里面结果那个组件越滚越大最后充满了各种业务判断。我的经验是core/widgets 只放真正通用、没有任何业务含义的东西比如空状态占位图、网络图片加载器、防抖按钮。只要一个组件里出现了“订单”“用户”“设置”等关键词它就不属于 core应该下沉到对应的 feature或者放到 shared。core/services 通常包括本地存储封装、日志服务、埋点工具。这些东西也要求中立。比如埋点服务它应该暴露一个 track(eventName, params) 这样的通用接口具体在哪个业务节点埋什么点由 feature 层自己决定。这样一来换第三方统计 SDK 就只需要改 core/services 里的一个文件业务代码完全不用动这个设计在几次第三方 SDK 大版本升级和替换中帮我省了非常多的时间。2.2 feature 内部data / domain / application / presentation 四层怎么分每一个 feature 内部都按这四层组织但对不同复杂度的功能可以灵活裁剪。一个只读列表页面可以不需要 domain 层的实体和仓库接口直接在 data 里定义模型在 application 里写 cubit在 presentation 里写界面。一个复杂的核心业务比如订单流程四层就都要有。data/models 放的是 JSON 解析直接依赖的数据类它们最贴近接口的字段结构。repository 是数据仓库它的职责是决定“数据从哪里来”——优先拿缓存还是强制刷新失败时是否降级这些逻辑都收敛在 repository 里。datasources 则进一步细分remote_datasource 管接口请求local_datasource 管本地缓存和数据库。之所以要拆 App 这一层是因为很多功能的数据来源不只是 API还有本地数据库、SharedPreferences、设备传感器把它们分别封装可以让 repository 只关心“数据整合策略”而不纠结具体技术细节。domain 层的 entities 是纯业务对象不掺杂任何 JSON 解析和数据库字段的逻辑。repositories 目录里放的是抽象接口比如 LoginRepository 的抽象类。为什么需要这层主要是为了单元测试的替身注入测试时可以轻松用一个 MockRepository 替换真实实现验证业务逻辑而不必真的去请求网络。对小型 feature 这一层看起来像多余但一旦业务复杂到需要好好写测试这层就是乘上保险。application 层是状态管理和用例编排的地方。状态管理我用得最多的是 bloc/cubit尤其是 cubit——不是因为它比 bloc 更高级恰恰相反是因为它更轻量。对于大部分页面状态并不需要完整的 event 机制一个方法调用加一个状态变更就够了。cubit 让这类代码少了很多模板噪音结构自然清新。复杂交互流程、需要精确追踪用户操作序列的场景我才会换回完整 bloc。presentation 层就是 Flutter 传统的 widgets 和 pages。它里面只做两件事把 UI 画出来把用户操作转成 cubit/bloc 的方法调用。不在这里写网络请求、不在这里直接拼 JSON、不在这里保存 token——这些属于业务逻辑一旦出现在 presentation 里就是在为未来的混乱埋雷。3. 状态管理选型如何影响目录边界状态管理方案的选型会直接影响目录怎么切这一点很多人没有意识到。有的团队先选了一个状态管理库再照着它的 demo 目录结构搭项目结果发现根本撑不起自己的业务复杂度。反过来才对先想清楚你的业务是什么节奏再选状态管理方案最后用目录结构落实边界。我见过很多用 Bloc 但把状态全部集中在全局的做法登录状态放在一个全局 AuthBloc 里购物车状态放在全局 CartBloc 里用户设置放在全局 SettingsBloc 里。这种做法的好处是“哪里都能访问到”坏处是“哪里都能弄脏它”。一个页面为了读一个用户昵称就把整个 CartBloc 暴露出来等于把不相关的状态耦合在一起。随着迭代这些全局状态类的内部代码会越来越臃肿因为每个人都往里加东西。我的建议是全局状态只保留真正全局的那几个登录态、主题、语言、用户基础信息其余状态一律收拢在各自 feature 内部。登录态的全局性在于它影响路由守卫和多处 UI 判断主题和语言则在启动时就要被加载所以它们值得全局管理。但即便是全局状态在目录中也要有清晰归属——我习惯把它们放在对应的 feature 内部比如 AuthCubit 放在 features/auth/application/cubits/而不是单独建一个 global 目录。3.1 依赖注入不用框架也能守住边界在 Flutter 里做服务定位和依赖注入最轻量可靠的方案是 get_it配合 injectable 可以在编译时生成注册代码省去手写一堆注册逻辑。但 get_it 本身只解决“怎么拿到实例”的问题真正的难点在于“谁负责创建实例”以及“实例的生命周期归谁管”。我常用的模式是每个 feature 自己暴露一个 registerDependencies() 方法在应用启动时由主入口统一调用。feature 内部的依赖只在注册方法内创建外部只通过接口或抽象类访问。比如登录 feature 的 LoginRepository 注册在 get_it 里key 是抽象类业务页面只依赖抽象不依赖具体实现类。这样未来如果要把本地登录换成 OAuth、把密码登录换成验证码登录都只需要替换注册的实例页面代码一行不用改。这里有个很容易踩的坑get_it 看起来是“全局字典”于是很多人把它当作传参的替代品在页面里直接 getIt.registerSingleton 塞各种临时的参数对象结果代码变得毫无可读性。正确的姿势是 register 只发生在确定的生命周期节点上比如登录状态变化、用户切换时重新注册与用户相关的依赖而不是随手 register。3.2 Cubit 与 View 的绑定边界在 feature 内部cubit 文件的位置也有一套讲究。我的做法是 application/cubits 下放 cubit 类但不在那里创建页面与状态的连接关系。正式 UI 的状态连接是靠 BlocProvider 在 presentation/pages 里完成的。这样每个页面只是消费 cubit 的 UI 层并不知道 cubit 是从哪来的——它只需要从 BlocProvider.of (context) 拿实例就行。测试时这个边界特别舒服。因为页面不知道如何创建 cubit我们可以在 widget test 里用 BlocProvider 注入一个配置了 Mock repository 的 cubit页面就自动进入了受控状态。这种能力在回归测试里非常好用尤其是界面交互链路长、容易出错的历史遗留模块。用这种模式改造过一个统计报表页面之前每次改样式都要手工准备真实数据非常痛苦改完结构之后测试数据变成一行注入测试覆盖范围立刻提上来了。4. 路由、本地化与原生交互的隔离设计长期迭代的项目逃不开三个跨切面问题路由怎么组织、文案怎么管理、原生能力怎么桥接。这三个问题放在项目小的时候都不起眼等页面超过三十个、支持两种语言、开始混合开发的时候不提前设计几乎必然返工。4.1 路由集中管理与模块解耦路由是 feature 之间唯一的“合法通信渠道”所以路由表最好集中管理。我在 core/router 里维护一个路由配置类统一注册所有页面的路由名和构造参数。新建页面时只需要在 feature 的 presentation 层写好 Widget然后在路由表中登记一行而不是在代码里到处使用 Navigator.push。集中管理的好处有两条。第一依赖关系一眼可见feature 想要跳转另一个 feature 页面只需要引用路由名和一个轻量参数对象不会直接 import 对方的页面类也就不会在两条业务链上形成硬依赖。第二深层链接和推送通知映射容易挂接后台下发一个 route pathApp 就能根据路由表把用户带到指定页面。这里我要专门提醒一点路由参数尽量不要传复杂对象。Flutter 的路由参数默认要能序列化如果你传实体对象冷启动恢复路由时会炸。我的原则是路由参数只传 id 或者枚举值页面自己从数据层加载详情。这种设计再加一层好处页面天然支持深链、widget test 和从通知栏唤醒的冷启动场景。4.2 原生交互EventChannel 与 Platform Channel 的隔离层Flutter 应用一旦涉及原生能力——定位、相机相册、扫码、推送、蓝牙、NFC、支付——就必须处理 MethodChannel、EventChannel 和基础的原生页面跳转。很多项目的原生桥接代码非常随意到处是 PlatformChannel 的直接调用这是长期迭代的巨大隐患。我会单独建一个 core/services/platform_bridge 目录里面按能力分类location_service.dart、camera_service.dart、payment_service.dart。每个 service 的职责是封装 MethodChannel 的 invokeMethod 调用并把原生的返回值规范成 Dart 侧的类型和错误码。业务代码绝不直接写 MethodChannel(com.example.pay) 这种字符串而是调用 platform_bridge 里的封装方法。这样原生 channel 的命名、参数格式、错误处理都收敛在一个地方。EventChannel 的隔离更关键。EventChannel 是持续订阅的流一旦忘记取消会导致内存泄漏、事件重复。比如扫码功能扫码成功后的连续回调处理不好很容易出现回调炸两次。我在封装的时候会把 EventChannel 的流包装成业务语义清晰的 Stream扫码服务暴露 scanStream业务统一用 StreamBuilder 监听并且所有订阅都在 dispose 里主动取消。这样即使底层原生实现换了回调频率Dart 侧的消费逻辑也不用跟着改。团队里最常见的翻车现场是原生通道封了一版后来原生需求扩展了方法列表于是有人直接在业务页面里新增 NativeBridge.invoke 并硬编码 channel 名导致整个桥接层逐渐失控。规则必须从一开始就定死业务代码写了一个 channel 名字符串代码审查直接打回。只有这个铁律执行到位原生交互才不会成为迭代的成本黑洞。5. 长期迭代的工程保障测试、lint 与演进策略结构设计得再好缺少工程纪律约束也会慢慢腐化。我在项目的长期迭代里最依赖的三个保障是面向测试的代码组织、lint 规则的强制约束、以及按节奏演进目录的转型策略。5.1 测试如何倒逼结构变好很多人觉得写测试是额外工作量我恰恰相反我把测试当作结构的体检仪。如果一段代码很难写测试那多半说明这段代码的职责不清晰、依赖太重或者边界不明确。比如一个页面直接依赖了 get_it 里的全局服务测试时就很难注入替身一个 cubit 直接 new 了一个 repository 而不是通过构造函数注入测试时就无法独立验证逻辑。因此我在结构上做了两个妥协来换取可测试性第一所有 cubit/bloc 都通过构造函数注入依赖默认参数可以在测试里替换这在产品代码里读起来很干净在测试里尤其好使。第二presentation 层只保留 UI 相关逻辑任何需要数据的地方都由 cubit 层提供。第三个是不太直观的——不要在 cubit 里调用全局单例而是通过依赖注入的方式拿当前实现。为了让测试能替换数据库和网络端到端的行为我保留了一个自研的 repository 注册中心测试时把注册中心的内容整体替换成内存 fake业务逻辑就可以在纯 Dart 环境跑通。初期多付出的这一层抽象在项目迭代到中后期时价值极大。统计过我们团队某个核心模块改造前手工测试链路要准备十多种状态组合每次回归耗时大概一天改造后集成测试自动覆盖主路径新需求的回归时间缩短到两三个小时。这个收益足以覆盖初期的写测试成本。5.2 lint 与 CI 的强制保障结构规范的落地不能靠自觉要靠 lint 和 CI 自动检查。Flutter 官方提供的 flutter_lints 是底线我建议在它的基础上做几个定制规则首先是严禁 feature 之间互相 import。这个规则我可以靠目录规范去 review但在大规模团队里人工 review 很容易漏。可以在 CI 里用一个简单的脚本检查 lib/features 下的 import 路径凡是 import 了其他 feature 目录的都直接报错。这是成本最低、收益最高的自动化结构守护。其次是禁止在 presentation 层直接出现 BuildContext 以外的大依赖比如禁止在页面里直接调用 getIt 全局服务强制通过构造函数注入。这类规则需要根据代码特征编写自定义 lint 规则初期投入一点工作量换来的长期收益是对代码质量的持续约束。再一个是统一的命名规范和 Dart 格式化。配置好 dart format 和 analyze 之后CI 一跑任何格式违规都会被拒绝合并。团队里经常有新人以为格式化是小事实际上保持统一的代码风格是维持代码阅读体验的基础也是让 review 富有效率的前提。5.3 结构演进与重构节奏结构不是一次定死终生不变的。项目会经历原型期、快速增长期、稳定维护期每个阶段对结构的要求不同。我在实践中总结的演进节奏是这样原型期先用最简单的 feature 目录一个页面一个文件夹不搞 domain/data 三层分离快速验证产品假设进入快速增长期后按业务模块逐步拆分把数据层和状态管理层补齐到了稳定维护期新功能的代码按完整四层结构落地历史代码按“被动重构”原则——只有需要改动时才顺手重构成规范结构不做大范围的代码横扫。这里有个非常重要的心态不要为了架构洁癖做大范围重构。我见过一个团队在业务高速迭代期花了两周时间把所有页面重构成 clean architecture结果上线后出现了大量低级回归 bug团队花了整整一个月平反。重构一定要在业务节奏允许、并且有足够测试保护的前提下进行而不是拍脑门当技术债偿还。对于普通团队渐进式演进比大爆炸式重写安全太多。6. 常见问题与实操避坑最后整理几个长期迭代项目中几乎必然会遇到的问题以及我踩过坑后的处理办法。问题一feature 之间需要共享一个全局组件该放哪里如果两个 feature 都会用到一个“用户头像带角标”的组件放哪个 feature 都不对。我的处理是放进 shared/widgets这个目录专门放跨 feature 复用、但带有业务语义的组件。它与 core/widgets 的区别就在于有没有业务含义——core 里的组件彻底无业务shared 里的是“多个业务都会用的通用业务组件”。如果组件只被一个 feature 用就算其他功能看起来很像也不要提前抽象等第二个真实使用者出现了再上提这就是“第三次重复定律”。问题二一个 ajax 接口被多个 feature 共用接口封装放在哪里最常见的错误是放在一个叫 api 的全局目录里每个 feature 都来调用。正确做法是接口封装跟随业务源如果这个接口主要属于 user 功能封装在 features/user/data/repositories 的 UserRepository 里其他 feature 通过路由跳转或依赖注入的方式间接获取数据而不是直接跨 feature 发请求。真正跨 feature 必须共享的接口放 core/network 里面做中性封装只暴露原始数据不带业务状态。问题三新人的第一个 PR 就破坏了目录规范怎么办不要直接打回甚至不要批评。我会在代码审查里指出具体违反了哪条规则、正确的做法是什么顺便解释这条规则是为了防止什么。多数情况下新人不是故意破坏而是不理解规则背后的代价。我建议团队维护一份简短的“项目结构指南”把常见场景和对应目录写清楚比任何口头说教都有效。另一方面必要的 lint 自动化能挡掉大量低级失误用人来把关的应该主要是“设计意图”层面的问题。问题四安卓原生项目嵌入 Flutter 页面目录怎么处理混合开发场景下Flutter 端和原生端各自维护一套目录。Flutter 侧照常按 feature-first 组织但需要在 core/services/native_bridge 里封装好与原生的所有交互避免两边团队互相踩到对方的代码。原生端跳转到 Flutter 页面时通常通过一个统一的 Flutter 容器 Activity 承载路由参数约定成 JSON 字符串具体内部导航由 Flutter 侧的路由表解析原生侧完全不感知 Flutter 内部页面结构。EventChannel 的事件流在这种场景尤其要小心——原生页面销毁时要同步取消事件的发送否则 Flutter 侧容易收到悬空事件出现界面跳闪和状态错乱。问题五代码量越来越大编译速度慢怎么优化长期迭代下编译变慢几乎是必然的。结构上的缓解手段是把不常变的公共依赖打成单独模块用 Flutter 的包管理和模块拆分能力让增量编译更高效。更实际的手段是控制 import 图的复杂度——禁止 feature 直接引用其他 feature 内部文件不仅让依赖清晰还能减少因为公共模块变化而触发的所有文件重新编译。另外在开发阶段建议尽量用 Profile 模式跑单页面热重载的体验和完整编译的体验完全两回事。7. 最后分享一点我的真实体会结构设计的最终标准不是文件夹漂亮而是团队迭代的效率。我在好几个项目里验证过一开始多花一天时间把边界画清楚、把依赖方向定死后面每个月省下的“找代码时间”和“互相踩脚修复时间”远远超过最初投入。真正健康的 Flutter 项目应该让每个业务模块像一支独立小分队——内部怎么组织、状态怎么管理、数据怎么获取都自己说了算但对外只暴露清晰的接口。随着团队规模变大这个边界带来的价值会越来越明显。如果你现在正准备启动一个新项目我建议直接按 feature-first 加轻量 clean architecture 的结构来建。如果你正面对一个已经长了几百个页面的存量项目也不要急着推翻重来先从划清 feature 边界开始——把明显属于同一业务的文件收拢到一起把跨 feature 的直接引用慢慢移除每次改代码的时候顺手做一点。长期迭代不是靠某一次大重构而是靠每一次改代码时都在维护结构的完整性。这一点比我上面写的任何目录模板都更重要。