ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Flutter项目结构设计:从feature-first到长期迭代避坑指南

Flutter项目结构设计:从feature-first到长期迭代避坑指南 1. 先想清楚再动手项目结构的本质是管理复杂度做 Flutter 项目这几年我见过太多团队在结构设计上走弯路。最常见的是两种极端一种是把所有页面、组件、工具函数全部塞进lib根目录文件一多就变成一锅粥改一个需求要在七八个文件里跳来跳去另一种是看了几篇架构文章就强行上 MVVM、DDD目录分层比俄罗斯套娃还复杂结果写完一个 feature 要创建十几个文件连资深开发都容易迷路。先说结论项目结构不是越复杂越好也不是越简单越好。它要服务的核心目标只有一个——让业务迭代不受阻。一个能支撑长期迭代的 Flutter 项目至少要满足三个条件新成员能快速定位代码、改一个功能不会引爆无关模块、技术栈升级时能局部替换而不是通体重写。那到底怎么设计我建议不要直接照搬任何一家公司的开源模板而是先想清楚你的项目处在什么阶段、团队什么规模、业务边界在哪。这听起来像废话但很多人恰恰是跳过了这一步直接去 copy 了一个看起来很高大上的目录结果水土不服。举个例子一个刚启动的 MVP 产品和一个已经跑了三年的中大型 App结构诉求完全不同。前者可能一个features目录加一个shared目录就够了后者则需要考虑模块化边界、代码生成、路由解耦、多端复用等等。所以下面我给出的不是唯一正确答案而是一套经过多次项目验证的、可以按需裁剪的设计方法。2. 一套能落地的目录模板从入口到特性模块2.1 从 layer-first 到 feature-first 的转变早期 Flutter 项目的经典分层是models / views / controllers / services也就是按技术职责分目录。这种 layer-first 的结构在项目小的时候还行一旦业务迭代起来就出问题你改一个订单列表要同时动views里的订单页、controllers里的订单逻辑、models里的订单模型、services里的订单接口四个目录来回切上下文频繁切换改起来特别累。feature-first 则是按业务功能切分每个 feature 独立拥有自己的 UI、逻辑、模型和数据源。这样改订单功能时你的视野基本锁定在features/order这一个目录里定位快、隔离好、删除也干净。我现在的团队已经全面转向 feature-first如果是从零开始的项目我强烈建议直接采用。2.2 推荐的目录结构模板下面这套结构是我在多个月活百万级的 App 项目里跑过的方案兼顾了模块隔离和公共复用lib/ ├── main.dart ├── app/ │ ├── app.dart # MaterialApp 配置、主题、路由表 │ ├── router/ # 路由定义与导航守卫 │ ├── theme/ # 颜色、字体、间距等设计令牌 │ └── di/ # 依赖注入容器初始化 ├── core/ │ ├── constants/ # 全局常量、枚举、正则 │ ├── errors/ # 统一异常定义 │ ├── network/ # Dio 实例、拦截器、日志 │ ├── utils/ # 纯函数工具不依赖 Flutter 框架 │ └── extensions/ # 常用扩展方法 ├── shared/ │ ├── widgets/ # 跨 feature 复用的通用组件 │ ├── mixins/ # 复用的混入逻辑 │ └── design_system/ # 按钮、输入框、弹窗等规范组件 ├── features/ │ ├── auth/ │ │ ├── data/ │ │ │ ├── models/ │ │ │ └── repositories/ │ │ ├── domain/ │ │ │ └── entities/ │ │ ├── presentation/ │ │ │ ├── pages/ │ │ │ ├── widgets/ │ │ │ └── controllers/ # bloc/cubit 在这里 │ │ └── auth_routes.dart # 该模块的路由贡献 │ ├── home/ │ └── order/ └── bootstrap/ # 启动流程、splash、初始化编排2.3 逐层解释每个目录的职责先看app层这一层是整个应用的总装车间。app.dart里只放MaterialApp的配置包括主题、路由、本地化。router/独立出来是因为现在 Flutter 端路由已经不是简单的Navigator.push了尤其是用了go_router或自定义路由中间件之后路由守卫、状态同步、深层链接都要在这里统一处理。theme/不建议只放一个ColorScheme我习惯把间距、圆角、字体、阴影都抽成设计令牌Design Tokens这样后续接设计系统或者做暗黑模式时会省很多事。core层是最容易被误解的一层。很多人把什么都往core里丢最后core/utils变成垃圾场。我的原则是core里只放非业务的技术能力。比如网络层它不应该知道订单、用户这些业务概念只负责发出请求、处理重试、统一错误。如果一个函数或类被多个 feature 复用而且它本身不包含业务语义才放进core。反之如果某个工具只有订单模块用哪怕只是三行代码也应该放在features/order内部而不是全局共享。少一个公共模块就少一个耦合点。shared层放跨 feature 的 UI 复用件。注意这里的组件不能带业务逻辑比如一个用户头像其实带业务语义从用户中心取数据这应该属于某个 feature而带图标和文字的通用按钮这种才适合放shared。判断标准很简单如果你要给它传业务参数而不是纯展示参数那它就不该放在 shared 里。features目录下每个子模块内部再分data / domain / presentation三层这就是经典的 clean architecture 在 feature 级别的落地。data管数据源domain管实体presentation管页面和状态。对于中小项目domain不一定要有 usecase 层实体甚至可以直接复用 data 的 model这个可以后面细说。2.4 为什么不建议把所有东西都塞进 lib 根目录我见过太多项目lib底下直接躺着几十个页面文件、十几个自定义组件、还有几个叫common或helper的目录。这种结构在项目起步时写得飞快但到了第三个月你就开始觉得不对劲想找个文件得靠 IDE 的全局搜索新来的同事看代码全靠猜git 合并冲突的频率也在上升——因为大家都在同一个目录下加文件改动范围经常重叠。说句不客气的话项目结构混乱的根源往往不是技术问题而是团队没有在早期约定边界。所以即便你的项目已经从lib根目录的乱摊子开始了也值得花时间梳理并逐步向 feature-first 迁移。方向对了早晚能到方向错了跑得越快输得越惨。3. 状态管理和依赖注入怎么定结构才不会乱3.1 bloc、cubit 与文件组织状态管理选型直接决定你的目录长什么样。如果你用的是 Provider那么presentation/pages下面跟一个providers目录就够了如果用的是 Bloc那通常每个页面会有一个 bloc 文件和对应的 event/state 文件。现在 Bloc 官方的新版本里cubit也越来越常用——它砍掉了 event 层适合处理简单的异步状态。几十个项目练下来我的建议是复杂交互场景用 Bloc简单状态同步用 Cubit同一个项目里两者可以共存文件组织方式统一即可。具体到文件命名我推荐下面这种模式features/order/ ├── data/ │ ├── models/order_model.dart │ ├── repositories/order_repository.dart │ └── datasources/order_remote_datasource.dart ├── presentation/ │ ├── pages/order_list_page.dart │ ├── widgets/order_card.dart │ ├── bloc/order_list_bloc.dart │ ├── bloc/order_list_event.dart │ └── bloc/order_list_state.dart注意这里 bloc 是跟页面绑定的不是跟模块绑定的。一个 feature 里可以有多个页面对应多个 bloc每个 bloc 只负责一个具体的页面状态。千万不要搞一个巨大的OrderBloc去管所有跟订单相关的页面状态那种上帝 Bloc一旦膨胀起来reproduce 一个 bug 能把人逼疯。3.2 part 与私有文件拆分热词里提到了part这里专门说一下。part是 Dart 提供的用于拆分库文件的机制在旧项目里常被用来把一个类拆到多个文件典型的是 JSON 序列化生成的.g.dart文件。不过我要提醒一句part是有代价的这里拆分出的每个文件虽然代码在同一个库内但 IDE 的导航、断点调试、代码补全体验都可能受影响而且part循环引用时定位问题很难受。我的实践经验是能用part解决的一定也能用普通 import 解决只有极少数情况比如定义私有类/私有大扩展且希望跨文件可见才需要part。如果你在审代码时看到某个模块大量使用part这往往不是一个好信号建议看下是否有更清晰的拆分方式。3.3 依赖注入容器让模块可替换Flutter 项目里依赖注入做法不外乎三种手写 InheritedWidget、用 provider/GetIt、或者用 riverpod 的容器机制。我长期使用的是 compose 风格的多容器注册说实话 Flutter 社区没有统一标准但核心诉求都一样外部依赖要可替换测试时能轻松 mock。拿网络层举例如果你在OrderRepository里直接Dio()一个实例那测试订单逻辑时就免不了真的发一个请求这不现实。而如果你依赖http.Client或者注入一个ApiClient抽象测试时替换成一个假客户端就能跑通单测和 widget test。这个原则听着简单实际项目里贯彻得好的不多主要原因就是大家写代码时图省事直接 new 了。我的建议是在 di 目录里统一注册所有全局服务feature 内部的对象由 feature 自己创建别在页面里到处GetIt.instance或者context.read服务这样以后想换实现才不用挖整个项目。3.4 BLoC 与业务逻辑的边界经常有人问我bloc 里能不能直接调用 repository我的回答是能但要有度。bloc 里调 repository 拿数据是正常的但如果回购逻辑里有复杂的业务判断比如满减、优惠券、价格计算那判断不应该藏在 bloc 里而应该放到 domain 层或者 repository 层。否则你的 bloc 文件会变成几千行的一片混沌UI 逻辑和业务逻辑混在一起这是长期迭代最大的暗病。定义一个简单规则bloc 只做状态转换不做业务计算。这条规则能让你在项目变得复杂之后依然保持清爽。4. 基础设施层的边界设计路由、网络、存储、事件通道4.1 路由表怎么组织路由设计看似和目录结构无关实际上关系很大。很多项目的路由表是集中在一个routes.dart文件里里面几百行 route 定义新来的同事加个页面还得翻开这个文件看半天才能找到在哪加。而按 feature 划分之后我更推荐每个 feature 内部自带一个xxx_routes.dart暴露该模块的路由贡献最后由app/router统一合并。GoRouter 的声明式路由很适合这种模式。每个 feature 定义自己的GoRoute在AppRouter里用[...routes]拼装。这样改订单模块的路径时只需要看features/order/order_routes.dart不用动全局路由文件。路由的配置文件和目录解耦这个细节在小项目里无所谓但在多人协作的迭代中减少文件改动面就等于减少冲突概率。4.2 网络层统一入口 可观测性网络层是绝大多数项目的刚需。Dio 是 Flutter 生态里最流行的选择但不建议每个 feature 都自己 new 一个 Dio 实例。我的做法是在core/network里初始化一个ApiClient统一配置 baseUrl、超时时间、通用 headers、拦截器。拦截器里做三件事日志打印debug 环境下、token 刷新、统一错误转换。统一错误转换这一点很关键后端返回的错误码、网络异常超时、断网、解析失败都要转换成业务层看不懂的异常类型之前先在网络层做一个映射。这样 UI 上弹什么错误提示只需要 switch 一个AppException类型而不是在几十个页面里到处检验 DioException. 网络层还应该考虑取消机制页面向外走的时候还需要取消发出去的请求吗这听起来是优化级需求但如果你的页面在频繁进入退出不处理取消请求会导致回调时已经 unmounted然后还拖拽 UI。4.3 本地存储选型与目录归属Hive / Isar / Drift / SharedPreferences 这几条技术路线各有适用场景。但我想说的是存储的实现细节不应该泄露到 feature 层。你希望业务代码里看到的是一个UserStorage接口而不是直接Hive.box(user). 数据层文件夹里可以有一个storage或者local子目录然后 Repository 里通过注入来使用。未来如果要从 Hive 换成 Isar只需要改 data 层内部实现业务和状态层完全不感知。这就是依赖倒置在数据层的应用。4.4 事件通道与原生交互的封装热词里有人搜Flutter EventChannel和Flutter 跳转原生 Activity这类原生交互在项目里非常常见但很多项目把它们散落在各个页面里调取原生能力时直接在 didChangeAppLifecycleState 或某个按钮的 onPressed 里编写通道代码。这种写法在工具项目里没问题但在一个要长期迭代的业务项目里是非常危险的坑——因为原生能力往往被多个业务模块同时依赖。我的习惯是把每一个原生能力封装成一个 service比如BatteryService、LocationService、AuthService指纹等它们统一放在core/services或者shared/services下面对外暴露异步方法内部隐藏 MethodChannel/EventChannel 的实现细节。这样页面代码永远只看到纯 Dart 的方法原生通道的命名、参数格式、错误处理都只存在于 service 内部。等到项目要适配鸿蒙Jo principal) 或其它平台时你只需要替换 service 的实现而不是去几十个页面里翻找 platform channel 调用。4.5 Gradle 插件的配置与 Android 原生嵌入热词里有一句非常典型you are applying flutters main gradle plugin imperatively using the apply。这是 Flutter 老的 Gradle 配置写法在新版工具链下的报警。这类问题出现频率高恰恰说明 Flutter 生态变化快且原生侧和 Flutter 侧的工具链耦合比大家想的更紧。长期迭代的项目构建配置这一块一定要盯紧AGP 版本、Gradle wrapper 版本、Flutter gradle 插件版本、kotlin 版本任何一个升级都要用兼容矩阵来评估要不然本地构建突然挂掉排查起来远比改目录结构痛苦。另外一个很常见的场景是原生项目嵌入 Flutter 页面。此时 Flutter 工程的目录结构最好独立成模块module原生侧通过FlutterEngineGroup创建引擎页面间通信通过MethodChannel走统一协议。这个模式下Flutter 侧仍然保持 features/core/shared 的结构但多了对外暴露的接口层相当于一个嵌入模式特有的“API 门面”。如果不做设计原生和 Flutter 直连的页面多了以后你知道要维护多少通道名和参数格式吗我见过一个项目里通道名不统一叫 get_user_info 的有三处全都在做类似的事改起来欲哭无泪。5. 进阶议题代码生成、模块化与渐进式改造5.1 代码生成在目录结构中的位置Flutter 项目几乎不可能完全躲开代码生成JSON 序列化用 json_serializable、状态管理用 freezed、路由用 go_router_builder、依赖注入用 injectable。生成的.g.dart文件会出现在你结构的各个 data 和 domain 目录里。这些文件建议直接提交到版本控制里因为大部分团队没有 CI 强制跑 build_runner 的流程新同事拉代码后少了生成文件直接编译报错会很崩溃。还有一点代码生成的导入路径里经常会依赖 Flutter SDK 版本。现代 3.27 的这些 Flutter 版本更新和 Xcode 的适配导致很多老 package 的版本不兼容热词里搜到的xcode27 很多 flutter 包报版本低其实就是这类问题。解决思路是尽早锁定 package 版本漂移范围定期做 dependency 升级而不是等到新工程拉不到依赖了才处理。5.2 按需模块化不要一上来就组件化现在 Flutter 的包管理和多模块可以通过dart pub的能力把层拆成多个 package也可以直接在一个项目的lib里通过目录隔离。这两种方式没有绝对优劣。组件化的同事会用到 melos 脚本管理多个 package维护成本是实实在在的。对于大多数项目来说先用一个 app package 清晰目录边界跑起来等团队规模上来之后再把稳定的模块拆成独立的 package是性价比更高的路径。5.3 存量项目渐进式改造的思路如果你的项目已经乱了不要想着推倒重来。Flutter 项目里大规模重构的风险是页面数量多、状态逻辑散、测试覆盖少一次大改极易产生回归。我建议用渐进式模块抽取的策略从最稳定的业务模块开始比如用户中心把散落在各处的页面、模型、服务逐步挪进features/user目录里每挪一个功能就清理一个路由入口保证每次合并都是可编译、可验证的。这个过程中的关键是先划定一个目标结构再分阶段往目标靠拢。每次提交都应该是小步的、可回滚的。宁可慢一点不要用一整个假期去改写系统。我见过太多重构项目死在半路原因不是方案不好而是节奏没控制好一个重构分支拖了两个月主线继续加功能最后冲突大到无法合并只能弃分支。6. 长期迭代里最常踩的坑与排查经验6.1 依赖地狱怎么防Flutter 插件生态非常丰富但依赖冲突也是长期项目的最大痛点之一。你升级一个第三方库结果它依赖的meta版本与你项目里另一个库要求的版本不兼容flutter pub get就开始拉一长串警告。热词里有人说flutter 3.44还有人提到flutter windows 3.47.5 下载这些版本跳跃背后都是同样的规律Flutter SDK 和依赖包之间存在细致的兼容层升级时必须关注每个包各自的适配情况。我自己的习惯是每次 Flutter SDK 版本升级都先建一个分支跑一遍flutter test和flutter analyze然后按模块逐步升级依赖包。flutter pub outdated这个命令非常有用可以一键列出过时的依赖和它们的升级选项。依赖升级不能全凭喜好要看 changelog 里有没有 breaking change特别是涉及EventChannel、MethodChannel和状态管理包的那些更新。6.2 Web 启动慢与 Impeller 渲染细节热词里有人搜flutter web 引擎启动慢、flutter impeller这属于运行时性能问题但它影响的是迭代体验。启动慢的要义在于web 端首次加载的资源大小是地狱级问题建议从代码拆分deferred import、资源 CDN、以及骨架屏三方面下手千万别在业务代码里堆大文件同步初始化。Impeller 是新渲染引擎iOS 和 Android 上的表现和旧版 Skia 有差异升级后如果看到个别界面样式差异先想想是不是引擎更换导致的透明度和阴影渲染行为变化再考虑是不是自己代码有问题。6.3 热重载失灵时的代码定位Flutter 开发里最退步的体验就是热重载hot reload失效了。导致热重载失效的原因很多最常见的一种是全局变量和静态变量被修改后没有完全重置比如你在di里用了一个全局单例改了它的初始化逻辑后热重载可能不会重建它。还有可能是代码生成文件变化后没有重新跑build_runner。此时的排查套路是先flutter clean再build_runner --delete-conflicting-outputs然后全量flutter run。这三次下来大多数问题可以解决要是还不行就看看是不是原生代码改动导致的。6.4 文件级命名习惯避免路由 / model 混淆多人协作的长期项目里命名规范的重要性有时候超过目录结构本身。比如Order作为 entity 名同时存在于 data model、domain entity、UI model遇到大中型业务时简直防不胜防。我建议每个 feature 内部统一前缀OrderModel数据层、OrderEntity领域层、OrderVo页面用。同理路由常量名建议orderList、orderDetail这样全局唯一避免跳转时跳到同一个名字的另一个模块页面。6.5 调试工具与测试基建最后说一句关于测试。技术债里最容易积累的是测试债但测试是长期迭代最重要的安全网。我建议至少要保证每个 feature 里repository 层有单元测试mock datasource、bloc 层有 bloc testmock repository、核心页面有 widget test只验证主要交互流程。这套测试基建不用覆盖全部 UI但能保证你重构时不会被自己的改动炸到。测试文件的位置应该紧跟被测代码比如order_bloc_test.dart就放在presentation/bloc/目录下让后续维护的人一眼就能找到。快节奏迭代的项目里谁都不敢说结构永不变化。设计好目录、定好边界、让依赖方向清晰同时留出足够的演进余地和应急出口剩下的交给团队协作和工程纪律去慢慢打磨。项目结构这件事做对方向比做得完美更重要毕竟结构是服务于业务的业务活着结构调整的机会就一直存在——这也是长期迭代里最实用的一条心法。
返回列表