尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Flutter项目结构设计:从目录崩塌到特性优先的长期迭代方案

Flutter项目结构设计:从目录崩塌到特性优先的长期迭代方案 接手过好几个Flutter项目之后我越来越确定一件事一个项目的崩溃从来不是从某个Bug开始的而是从目录结构先乱的。第一个人把工具函数随手扔进utils第二个人在页面里塞了三百行网络请求第三个人为了省事直接import了隔壁模块的私有类……等你想认真做长期迭代的时候发现改一个需求要翻遍五六个文件夹这时候才意识到Flutter项目结构设计本质上是在设计这个项目的“免疫系统”——它不能保证不生病但能保证病了之后不乱。这篇文章我就围绕Flutter项目结构如何支撑长期迭代这件事把我这几年在真实项目里的看法、踩过的坑、最终沉淀下来的结构方案一次说清楚。1. 项目结构崩塌的早期信号长得好好的项目怎么突然就“乱”了很多人觉得“项目结构乱”是个主观感受其实不是。它有一批非常具体的早期信号出现任何一个都应该警惕。我见过的Flutter项目从清爽到混乱基本都走的是同一条路。1.1 万能文件夹的出现打开项目如果看到lib/utils、lib/common、lib/helper这种“什么都能装”的文件夹而且里面已经堆了二三十个文件这就是第一个警报。这些文件夹的本质是“垃圾桶”——大家不知道该放哪就先放这儿放久了没人清理就成了垃圾桶。我在一个项目里见过utils里同时躺着日期格式化函数、一个自定义的Toast封装、一个图片压缩工具、一段解析UserAgent的正则、还有三个业务上完全无关的常量类。这个文件夹没有任何“内聚性”新来的同事打开它根本不知道什么该放进去、什么不该放进去只能凭感觉。凭感觉做事的结果就是三个月后里面躺了六十多个文件其中一半是重复代码。1.2 页面文件膨胀到无法维护这是最直观的信号一个页面文件超过600行甚至超过1000行。在Flutter里一个StatefulWidget从UI布局、状态管理、网络请求、数据解析、埋点统计全部写在同一个State类里行数就这么堆上去的。这种文件表面上还能跑但你要改一个按钮文案得先从上到下把整个build方法读一遍要修一个状态变更的Bug得在四个setState之间来回跳。我当时重构过一个二手车列表页文件1200行里面有七层嵌套的FutureBuilder状态全靠setState页面间传参全靠一个全局静态的单例ParamsHolder。这些代码运行起来不一定出错但每一次迭代都像是在走钢丝。1.3 依赖关系开始“绕圈”如果出现A模块的某个类import了B模块的类而B模块又反过来import了A模块的类——这就是循环依赖。Flutter/Dart本身对循环依赖的容忍度比一些静态语言高但出现这种绕圈的依赖说明模块边界已经失控了。团队很快就会陷入“改A发现B挂了修B发现A又炸了”的泥潭。1.4 状态管理方案“三足鼎立”项目里同时用着setState、Provider、Bloc或者GetX而且没有任何规则说明什么场景用什么。这比选择哪个库更致命。因为结构设计的前提是“可预期”——同事看到你的代码能根据代码位置判断它的行为方式。如果每个人的代码风格都自成体系整个项目就变成了一部多人合写但没商量过大纲的小说。这些信号出现一个项目还能撑出现两个以上说明结构对长期迭代的支撑能力已经见底了。接下来我讲讲怎么从根上避免这种局面。2. 特性优先还是分层优先两种主流结构的核心逻辑与取舍Flutter项目结构设计绕不开一个基本争论按“层”组织文件layer-first还是按“业务特性”组织文件feature-first。我两种都实践过结论很明确长期迭代的项目优先选feature-first但内部仍然需要分层。2.1 分层优先的典型结构与它的宿命按层组织的结构长这样lib/ models/ services/ screens/ widgets/ utils/所有页面放screens所有网络请求放services所有数据模型放models。看起来井井有条对吧问题出在迭代上当你做一个“订单详情”功能时你要改的代码分布在四个目录里——models/order.dart、services/order_service.dart、screens/order_detail.dart、widgets/order_card.dart。改一个需求你得同时动四个地方的代码而且这四个地方分属不同人维护的话合并冲突就是日常。层与层之间还有一种隐蔽的耦合screens里的页面互相跳转时A页面的代码得直接引用B页面的类。所以widgets目录里慢慢会长出只有某个页面才用的“专用组件”。最终按层组织的结构会退化成一个“大泥球”——表面上分布清晰内里乱成一团。2.2 特性优先的结构逻辑特性优先按业务板块划分目录lib/ features/ auth/ cart/ checkout/ profile/每个特性目录自己内部再分层比如data、domain、presentation。这样做的好处是需求变更时所有相关代码都在同一个特性目录内改动范围一目了然。“购物车满减规则变了”——改features/cart。“登录流程加了验证码”——改features/auth。你不需要跨目录跳跃。更重要的是特性优先让“隔离”变得可行。不同特性之间如果确实需要通信必须走明确的接口而不是直接import对方的私有实现。这种约束在长期迭代中极其宝贵因为它是项目保持可维护性的基础。2.3 为什么“纯feature”也不行不过凡事都有度。如果完全按特性分你把路由表放哪全局主题放哪通用网络客户端放哪这些“横切关注点”不属于任何单一业务特性但它们每个特性都用得上。所以主流的做法是混合模式以feature为主干同时保留一个core目录承载所有横切关注点。这也正是我推荐的结构方案。下面我详细拆解一个经过多个项目验证的目录骨架。3. 我的分层方案拆解从入口到数据源的完整调用链路先把我目前在用的、经历了好几个版本迭代还保持稳定的目录结构完整放出来然后逐个目录解释“为什么这么放”lib/ main.dart app/ app.dart router/ theme/ root/ core/ network/ storage/ native_bridge/ utils/ widgets/ constants/ features/ auth/ data/ domain/ presentation/ cart/ data/ domain/ presentation/ catalog/ data/ domain/ presentation/ shared/ widgets/ extensions/ models/3.1 app目录整个应用的“根”app目录存放应用级的组装逻辑根Widget、路由表、主题、全局配置。这个目录的特点是它只负责“装配”不负责业务。app.dart里做的事情基本上是初始化各种服务、把路由表挂到MaterialApp.router上、套上全局的主题。这一层的东西你基本上在项目启动早期定义一次后面很少改动。路由表单独放到app/router/而不是塞进某个业务特性里是因为路由表是全局的、跨特性的。Flutter里页面导航天然是全局操作某个页面跳转到另一个页面的动作本质上是在跨特性通信这个动作必须在全局层面定义而不是在特性内部用Navigator.push到处裸调。3.2 core目录不依赖任何业务的“地基”core是整个项目中最重要的一个目录它的铁律是core里的任何代码都不得依赖于features里的任何代码。这个约束方向是单向的——业务可以依赖corecore永远不可以反向依赖业务。我按用途拆分说明一下core/network封装的网络客户端比如Dio实例的配置、拦截器、统一的错误处理。core/storage本地存储封装比如SharedPreferences/数据库的访问入口。core/native_bridge所有平台通道的封装。MethodChannel、EventChannel统统收拢到这里业务层不允许直接碰MethodChannel。这一点在热搜词里反复出现实际项目里很多人把平台通道写死在业务页面里导致后面想适配鸿蒙、做多端发布时得满项目找通道代码。收拢到这个目录后换平台、换通道协议都只改这一处。core/utils纯函数工具比如日期格式化、正则校验、文件大小计算。core/widgets完全通用的基础组件比如骨架屏、空状态占位、上拉加载组件。3.3 features目录一个特性就是一个“迷你应用”每个特性目录内部我分成三层data数据层、domain业务层、presentation表现层。以catalog商品目录为例典型的结构是features/catalog/ data/ catalog_repository_impl.dart catalog_local_data_source.dart catalog_remote_data_source.dart models/ domain/ catalog_repository.dart catalog_entity.dart get_catalog_use_case.dart presentation/ catalog_page.dart catalog_controller.dart product_card.dartdomain层存放纯Dart的业务实体和接口定义不依赖Flutter SDK这也是唯一一层可以独立跑单元测试的层。data层负责对接数据源接口、缓存并实现domain里定义的接口。presentation层放页面和状态管理代码只依赖domain的接口不直接关心数据从哪来。这样做的好处最直观的是“替换数据源不碰UI”。我曾经有个项目后端接口从REST迁移到GraphQL因为data和domain用接口隔开了presentation层一行代码没动只重写了数据层实现类。3.4 shared目录跨特性的共用“零件”shared放的是跨特性复用、但又不算基础设施的代码。比如某个自定义的AppButton组件它是业务级的通用组件但还没通用到能放进core/widgets再比如一些从接口返回的共用模型或者String扩展方法。这个目录的定位是“缓冲地”它的存在避免了“什么都往core塞”的冲动。一个管理原则当shared里的某个组件被三个以上特性使用而且和具体业务无关时就该上提到core如果它只被一个特性使用就该下沉到那个特性的presentation里。有明确的上提下沉机制目录就不会腐烂。3.5 一个最小的调用链路示例从页面到数据的完整链路是这样的用户点开商品列表页 -catalog_presentation里的页面/控制器调用GetCatalogUseCase-UseCase调用CatalogRepository接口 - 实际执行的是CatalogRepositoryImpldata层实现 - 这个实现拉取远程或本地数据装配成CatalogEntity列表 - 返回给UI层转成UI状态渲染。所有依赖箭头都指向domain层和core层没有任何一个箭头是data层反向指回presentation的。这个方向的约束比用什么状态管理库重要得多。顺带一提这里也可以解释热搜词里那个高频问题“flutter future的then回调是放入微任务队列吗”——这属于Dart事件循环的基础知识。数据层用async/await处理异步时理解微任务和事件队列的区别能帮助你判断setState何时触发、页面何时刷新排查“数据拿到了但界面没更新”的灵异Bug时会少走很多弯路。4. 跨模块通信的边界路由、事件与原生交互在结构里的位置结构方案搭好后真正的考验在于“模块之间怎么说话”。我在实践里发现很多项目结构乱掉不是目录规划错了而是通信方式一开始就没立规矩。这里重点说三个方向路由跳转、组件通信、原生交互。4.1 路由统一由router管理禁止页面裸跳转我见过的最破坏结构的写法是A页面直接Navigator.push(MaterialPageRoute(builder: (_) BPage()))。这么做的问题在于A页面代码里硬编码了BPage的构建方式两个特性就被紧紧焊在一起了。你要是把BPage的内部结构调整一下、改个构造函数A页面也得跟着改更麻烦的是如果你想给BPage加个深链入口、加个路由守卫根本无从下手。我的做法是所有页面跳转统一通过app/router里定义的路由名或go_router的GoRoute来发起。页面只知道自己要去的“路名”不知道对方页面的构建细节。这样A页面不再依赖B页面而是依赖路由表这个全局配置。路由表变了页面代码不用动。这也是解决“flutter navigator切换页面后会丢失状态吗”这个高频问题的关键思路。在go_router里如果你用StatefulShellRoute来管理底部Tab这类导航切换Tab时状态是保住的而普通的push到新页面旧页面如果被重建状态该丢还是会丢。与其纠结某次跳转后状态在不在不如在结构上约定需要保留状态的页面用StatefulShellRoute或IndexedStack承载普通详情页则用常规push走完即销毁。这个规则定清楚状态问题就不会反复出现。4.2 组件通信优先走状态管理而不是事件总线热搜词里“flutter组件通信”非常火这也确实是新手的痛点。父子之间用构造参数和回调祖孙之间用InheritedWidget或者状态管理库跨模块、跨页面共享状态用全局的Controller或Store。我不太推荐用全局事件总线EventBus做大量业务通信因为事件总线的本质是“你发射一个事件不知道谁在听”。项目小的时候很爽项目大了之后排查一个状态变更的来源就变成考古学。我个人更推荐Riverpod或者Bloc这类显式的状态管理方案——状态依赖关系在编译期就能看出来代码可读性和可维护性都好很多。同样在热搜词里有“flutter eventchannel”我说一下它在结构里的位置。EventChannel是Flutter和原生平台之间的事件流通道适合原生侧持续向Dart侧推送数据比如系统音量变化、传感器数据。在结构上所有EventChannel的接收逻辑必须收拢在core/native_bridge的专门类里把原生事件转换成Dart侧的业务事件后再用状态管理分发给UI。不要让业务页面直接监听EventChannel否则你会在业务代码里看到很多streamSubscription没人取消订阅页面都销毁了还在收事件改起来非常痛苦。4.3 原生交互统一封装在core业务不碰通道细节“flutter跳转原生activity”、“flutter platformview”这些其实都算原生交互。我的结构原则是业务层只定义“我要做什么”——比如“我要打开原生扫码页”——至于这个扫码页是用MethodChannel、EventChannel还是PlatformView实现的业务层完全不需要知道。所以我会在core/native_bridge里定义统一的接口比如abstract class NativeBridge { FutureString? startScan(); Futurevoid openNativeActivity(String pageName, MapString, dynamic params); StreamBrightness systemBrightnessStream(); }业务侧调用接口实现在core里根据平台分支调用MethodChannel。这样做的好处是第一业务测代码对平台细节零侵入方便上flutter test跑单测第二如果未来要适配鸿蒙或者新增Windows等平台只需要在core的接口实现里增加分支业务代码一行不用动。热搜词里那批“flutter平台插件okta适配鸿蒙流程”的探索核心痛点也在这里——桥接边界如果没有提前收拢平台适配时改动的代码量会吓死人。4.4 一个简单的跨特性通信规范我总结了三条硬性规范贴在团队文档里每次Code Review都对照检查跨特性跳转一律走路由表禁止直接import对方页面类。跨特性状态共享一律放全局状态容器里禁止用静态单例存业务状态。跨特性复用组件先看能不能下沉到core或shared如果能就抽出去不能就复制一份也别违规引用。第一条保证特性之间不产生代码依赖第二条保证运行时状态来源唯一第三条保证不会有“A特性的组件偷偷和B特性的组件耦合”这种隐蔽问题。5. 让结构“自动保鲜”的规则命名约束、测试边界与代码审查设计一套结构只是开始。真正的难题是怎么在半年后、一年后让这个结构依然清晰。乱不乱最终取决于团队有没有一套可以执行的“保鲜规则”。我把这些规则总结成三块命名约定、依赖约束、测试边界。5.1 命名约定目录即约定命名约定不是小事它直接影响新人“东西放哪”的判断。我定下的几条基本规则业务目录一律用名词复数features/auth、features/cart不用LoginPageDir这种带功能描述的名字。文件命名和类名必须对应catalog_page.dart里只能放CatalogPage这个类。一个文件一个主类不允许一个文件里塞三四个页面。data层的实现类统一加Impl后缀接口不加后缀。看到Impl就知道它是接口的实现看到没有后缀的就当它是接口。路由路径常量统一放在app/router/routes.dart任何页面不许自己硬编码路由字符串。这些约定看起来“死板”但能省掉无数“这个文件应该放哪”的讨论时间。尤其对新加入团队的同学目录结构本身就是最好的文档。5.2 依赖约束用工具强制而不是靠自觉光靠嘴上强调“core不能依赖features”人类是会犯错的。所以我建议在工程上强制约束依赖方向。最直接的手段是用depend_on_referenced_packages这个lint规则确保每个Dart文件的import都来自pubspec声明的依赖避免“幽灵依赖”。更进一步可以用build_runner配套dependency_validator之类的工具做依赖检查如果你用的是monorepo/多包结构podfile、melos这类工具还能在工作区层面强制执行包之间的依赖方向。对大多数中大型项目我推荐在CI里加一个简单的检查脚本扫描core目录下的文件如果发现import路径里出现了features构建直接失败。这种规则执行起来非常便宜但价值巨大——它用机器保证了人不会犯错。5.3 测试边界与目录结构对齐测试是结构能否长期保鲜的晴雨表。如果你的单元测试目录和项目代码目录不对齐说明测试和结构的映射关系已经乱了。我建议测试目录完全镜像项目代码目录test/ core/ features/ auth/ domain/ data/ cart/ ...每个测试文件对应一个源文件catalog_repository_impl_test.dart测试catalog_repository_impl.dart。这样测试本身就是结构审查的工具——如果你觉得某个文件没法测试大概率是这个文件违反了结构原则比如塞了太多职责。结构设计充分考虑了可测试性这在长期迭代里极其重要。domain层因为不依赖Flutter SDK跑纯Dart单测不看模拟器速度飞快data层通过注入Mock的HttpClient测试接口映射和异常处理presentation层用widget_test跑关键交互路径。分层结构让测试的粒度和成本都可控。5.4 Code Review里的结构检查清单我会在Code Review时按下面这个清单过一遍结构相关项[ ] 新改动的代码是否在自己所属的特性目录内有没有跑到别的特性目录里改文件[ ] 是否出现了core目录importfeatures目录的反向依赖[ ] 是否出现了页面之间直接Navigator.push而不是走路由表[ ] 是否新增了lib根目录下的散落文件lib根目录应该只留main.dart[ ] 是否新增了至少一个超过400行的文件如果是说明拆分还不够[ ] 是否有重复的工具方法没有下沉到core或shared这个清单我用了很久基本能拦住九成会让结构腐化的提交。剩下的第一社会化问题就是集体讨论、及时纠正不要等结构乱到无法收拾了再“大重构”。6. 从当前乱局起步重构的优先级与低成本迁移路线如果你的项目已经进入前文说的那种“乱”的状态别急着推倒重来。Flutter项目推倒重来的成本和风险都极高我见过太多团队重构到一半就放弃了代码库比重构前更乱。比较务实的路线是分阶段、小步快跑地迁移把风险控制在能接受的范围。6.1 第一步先立边界再挪文件很多重构失败是因为第一步就大动干戈地挪目录。文件一挪Git历史全乱合并冲突满天飞团队迅速失去信心。我的建议是先立边界不挪文件。具体做法是在lib下先创建core、features、shared的空目录骨架。从当前最容易界定的代码开始比如网络层、存储层把它们收进core。对于业务页面先定清楚“哪个页面属于哪个特性”但暂时不移动文件只在文档里记录这个映射。同时把路由表建立起来新写的跳转一律走路由表老代码的裸跳转遇到了就改不专门做“清理专项”。先立边界的好处是重构期间项目的代码还能正常迭代不会出现“重构冻结期”这种团队最抵触的局面。6.2 第二步按依赖关系从“叶子”开始拆挪文件不是随机挪的要按依赖关系从依赖的“叶子”开始挪。具体来说先挪那些“不依赖任何业务”的基础代码进core。比如封装好的网络客户端、工具函数。再挪“被多个页面使用”的数据模型和组件进shared。挪之前确认它们没有依赖业务页面。接着按特性拆页面。从最独立的特性开始比如auth、profile因为它们依赖面小挪动风险低。我被问过很多次“我这项目页面之间纠缠得太深了根本拆不开怎么办”这种情况不要追求完美的一次性拆分。先把同属一个业务的页面归拢到一个目录再逐步解耦。哪怕目录里暂时有import跨出边界的代码也比满项目乱放强得多——因为归拢之后方向至少是明确的下一次重构时你知道该把代码往哪个方向推。6.3 第三步用低成本“止血动作”减少新增腐化有些动作不需要等重构完成立刻就能做。比如立即禁用“往lib根目录新增文件”的行为。新写的页面统一走路由表跳转不要再裸Navigator.push。新写的网络请求统一走core/network的实例不要再在每个页面里new Dio()。提交代码前跑一遍flutter analyze把warning减到零。这些“止血动作”在重构期间的意义是保证结构的“熵”不再增加。老账慢慢还但新账绝不赊——这是我从无数项目里总结出最实用的一条经验。等这些新账铺得差不多了老账的清理成本会越来越低因为所有代码都在朝一个方向靠拢。6.4 最后一步关注版本演进的节奏感长期迭代的Flutter项目除了代码结构还得考虑技术栈本身的演进节奏。热搜词里“flutter 3.44”、“flutter impeller”这样的版本和引擎变化本质上都是“项目环境层”的变化。环境层的变化不该干扰业务结构的设计。我的经验是业务代码结构尽量对Flutter版本“钝感”。比如在网络层、路由层、状态管理层做好接口封装后就算某一天Flutter默认启用了新的渲染引擎Impeller或者Dart推出了更强的新语法你只需要升级依赖、微调core层业务特性目录里的代码几乎不用动。项目的抗风险能力就是这样被一层层结构缓冲垫出来的。最后说几句实在的我在实际项目里最深的体会是结构设计不是一锤子买卖而是一个需要持续维护的“活系统”。它不需要一开始就完美但一定要有明确的方向和纠错机制。哪怕你的结构方案和我推荐的不完全一样只要团队认可同一套方向遵守同样的边界规则长期迭代就不会走向失控。如果你现在的项目已经有点乱了别慌从立边界、定规则、止新血这三个动作做起三个月后再回头看你会庆幸今天就开始动手了。
返回列表