Flutter项目结构怎么设计才能支撑长期迭代?

📅 发布时间:2026/10/19 10:46:33
Flutter项目结构怎么设计才能支撑长期迭代?
说实话Flutter 项目在头一个月都很好过。页面一个个加组件一层层抽跑起来很顺畅。但等页面过了十几二十个、迭代超过半年问题就会像滚雪球一样冒出来改一个列表要翻三个文件新来的同事找不到该改哪里某个全局状态被谁动了只能靠猜。这个标题问得实在——Flutter 项目结构该怎么设计才能支撑长期迭代。我做过好几个不同规模的 Flutter 项目带过小团队也评审过其他人的项目结构。踩过不少坑也总结了一批到今天还在用的设计原则。这篇不是给你一个“标准答案”而是分享一套从目录拆解到路由、状态、依赖注入、错误处理的完整思路以及可以直接复制的落地动作。适合正在从“文件夹堆页面”走向模块化的团队也适合想在项目起点就把结构债控制住、不想三个月后哭着重构的开发者。1. 先想清楚长期迭代的项目到底在迭代什么1.1 项目结构要对抗的四种腐烂症状很多人讨论项目结构时会陷入一个误区忙着比较 MVVM 还是 Clean Architecture纠结于 Riverpod 还是 Bloc却忘了结构设计的真正目标。结构不是用来好看的它是在压缩“改一个需求时需要的文件改动范围”。好的结构改需求像拉开抽屉拿东西差的结构改需求像在仓库里搬家。我观察到长期迭代的项目如果不加干预通常会走向四种腐烂症状。第一种是横向依赖失控。页面 A 想复用页面 B 里的一个小组件最省事的做法是 Import 整个页面 B于是两个本来无关的模块被绑在一起。这种关系日积月累最后所有人都不敢删代码因为不知道哪个角落里还有人在用。第二种是上下文碎片化。做“用户登录”这样一个功能你需要到 Models 里找一个数据类到 Api 文件夹里找一个请求类到 Widgets 文件夹里找表单组件到 Utils 里找校验工具。一个功能点在文件系统里被拆成了好几块每次改动都要靠记忆和全局搜索把上下文重新拼起来。需求每次变你都得重新经历一次寻宝。第三种是底层模型被顶层直接穿透。同一个类既当数据库表又当网络返回体又当 UI 展示对象。后端接口的字段一改从网络层到页面全部报错所有相关页面都得跟着改一遍。这就是典型的领域边界缺失。第四种是全局状态和单例泛滥。为了调用方便把 Service 全部做成全局单例最后没人说得清这个状态在哪里被修改、被谁修改测试也完全没法写。1.2 先定业务边界再谈技术分层每次帮别人评审项目我第一个问题从来不是“你用了什么状态管理”而是“你的系统里有哪些相对独立的业务能力域”。登录注册是一个域账单列表是一个域消息通知是一个域个人中心是一个域。每个域有自己清晰的职责、自己的演进节奏也应该有自己的文件夹和依赖边界。业务边界一旦定下来技术上的分层、模块化、依赖注入都是把边界外化出来的手段。反过来如果业务边界自己都说不清上再多的架构也只是给烂代码披了一件好看的外衣。一个很实用的做法是项目启动时花半天时间拉着团队画一页纸的模块地图。横轴是业务模块纵轴是技术和公共资源。把“谁依赖谁”“哪些模块禁止互相依赖”写清楚贴在文档里。这是团队共识也是之后代码评审时用来拦截坏味道的标尺。2. 目录方案选型按类型分层还是按业务模块分层2.1 纯按类型分层的隐忧很多 Flutter 教程和脚手架默认给出的目录是 Models、Pages、Services、Widgets、Utils 这种按技术类型划分的结构。项目早期文件少这种结构非常直观按文件类型找东西也方便。但项目一旦进入多模块阶段问题就来了。我举一个实际例子要改“导出账单”这个功能你需要在 Pages 里找到一个账单导出页面在 Services 里找到导出服务在 Models 里找到账单模型可能还要去 Utils 里翻一下格式化方法。文件与文件之间没有任何业务上的“聚合”关系纯粹靠命名约定和程序员的记忆力来维系。一百个页面之后Pages 文件夹会变成一个需要滚动好几屏才能看完的大列表每次改动都是跨目录的多次跳跃。这个结构就像把所有螺丝钉放在一个盒子里把所有工具放在另一个盒子里。仓库看起来整整齐齐但你要修卫生间台盆的话得在螺丝盒里翻半天、在工具盒里再翻半天一来一回腿都跑细了。时间就消耗在这样的来回上。2.2 按业务特性组织模块的优势后来我切到 Feature-first 的思路每个业务功能在文件系统里拥有一个独立的一等公民目录。登录相关的一切收在 features/auth 里面账单相关的一切收在 features/bill 里面。每个 feature 内部再按 data、domain、presentation 做纵向分层。这种组织的核心价值是“需求的上下文聚合”。你要改登录功能百分之九十的代码改动都落在 features/auth 下面不用再满项目跑。团队扩展时也可以按 feature 分配责任人两个同事同时改两个不同模块Git 合并的冲突概率大幅降低。当然它也有代价。代价是你必须在项目早期就想清楚模块边界怎么切。边界切得不好就可能出现两个 feature 之间偷偷互相 import 的情况最后变成披着 Feature 外衣的巨型耦合。2.3 我现在推荐的项目目录骨架综合几轮项目经验我现在最常用的是“核心底座 业务模块 共享组件”的混合结构。直接给出目录lib/ main.dart # 入口初始化依赖、加载配置、启动 App app/ app.dart # 根 WidgetMaterialApp、主题、路由 route/ app_router.dart # 全局路由表集中管理所有页面跳转 theme/ app_theme.dart # 主题、颜色、字体 core/ constants/ # 全局常量、枚举、正则表达式 errors/ # 统一异常类型与转换映射 extensions/ # Dart 扩展方法 network/ # 网络客户端、拦截器、日志 features/ auth/ data/ datasource/ # 远端数据源、本地数据源 models/ # DTO跟后端字段对齐 repository_impl/ # Repository 接口的实现 domain/ entities/ # 业务实体跟业务语言对齐 repositories/ # Repository 接口抽象 usecases/ # 业务用例、交互逻辑 presentation/ pages/ # 页面 widgets/ # 该模块私有组件 controllers/ # 状态容器 bill/ # 同样的内部结构 shared/ widgets/ # 跨模块复用的展示组件 services/ # 跨模块的通用服务目录立起来了规则必须同步立起来否则只是一个好看的空壳。依赖方向是这条结构的生命线features 可以依赖 core 和 sharedfeatures 之间禁止互相 import两个 feature 都要用的代码下沉到 shared 或者 corecore 是技术底座永远不反向依赖业务。注意这条依赖规则不是写在文档里就完事的要在代码评审里真拦截。我见过太多项目头两个月守规矩第三个月为了赶需求开始跨模块 import之后这条线就形同虚设了。至于 domain 里的 usecases 层对小型项目可以适当做轻。如果一个 feature 内部只有简单的“请求数据、展示数据”可以省略 usecases让状态容器直接调用 Repository 接口保留 domain/repositories 层是有价值的这是后面上测试和切换数据源时最重要的兜底。但保留却不使用就妥妥变成了过度设计。3. 核心设计细节数据层、状态层、UI 层各自该扛什么3.1 Repository 为什么值得认真建一层Repository 模式是数据层的门面也是整个结构里性价比最高的一个抽象。它的职责是给上层提供稳定的数据语义——“取一个账单列表”“保存一个用户资料”至于数据到底来自网络、本地数据库还是内存缓存上层完全不关心。我做项目时习惯先在 domain/repositories 里定义接口再来一个 data/repository_impl 的实现。比如abstract interface class BillRepository { FutureListBillEntity getMonthlyBills(int year, int month); }第一版实现可以直接走远程 API。后面发现页面切后台再回来时要显示缓存于是加一层本地数据库缓存上层调用方式完全不变只需要把 repository_impl 内部逻辑改一下。再后面如果做离线模式还是一样的思路。这里有一个非常关键的好处测试。单元测试里需要模拟网络异常、空数据、超时等等只要把实现替换成一个 Mock 类注入进去就行不用真的起服务、不用依赖网络环境。没有这层抽象测试就写不动项目越到后期越不敢重构。3.2 模型拆分DTO 与 Entity 不能混用很多项目里只有一个 Model 类既解析网络 JSON又拿去渲染 UI。这在后端和前端恰好一个字段名的时候没问题但现实是后端给的是 status1/0UI 需要的是 bool 开关后端把 userName 改成 nickname你要么跟着全局改要么在 UI 里写兼容逻辑。我从第二次重构起坚持把模型拆成 DTO 和 Entity 两层。DTO 放在 data/models严格对齐后端的接口字段只负责序列化和反序列化。Entity 放在 domain/entities对齐业务语言是 UI 层真正依赖的对象。中间只需要一个 mapper 函数做转换。// data/models/user_dto.dart class UserDto { const UserDto({required this.nickname, required this.status}); final String nickname; final int status; // 后端字段1 在线0 离线 factory UserDto.fromJson(MapString, dynamic json) { return UserDto( nickname: json[nickname] as String, status: json[status] as int, ); } } // domain/entities/user_entity.dart class UserEntity { const UserEntity({required this.name, required this.isOnline}); final String name; final bool isOnline; }mapping 逻辑可以放在 DataSource 里也可以单独写个 mapper 文件。转换看起来多写了几行代码但后端把 status 从 int 改成 string 的那天你会庆幸这个隔离层帮你挡住了所有页面级别的大改。3.3 状态管理不是选工具而是定约束状态管理的选型经常被过度拔高好像选错了框架项目就会失败。我自己的经验是真正决定长期迭代质量的是状态管理背后的约束而不是工具本身。约束就两条单向数据流业务逻辑不进 Widget。所谓单向数据流就是页面事件 - 状态容器调用业务用例 - 得到新状态 - 刷新 UI。状态只能在状态容器里被修改页面拿到状态后只读展示。听起来简单但项目一乱就会出现某个 Widget 在 initState 里直接 new 了一个 Repository 发起请求然后 setState 更新本地状态。这个页面实际上自己成了半个状态容器它的内部逻辑别人看不到也管不了。选型上我给一个很朴素的经验页面内部的临时状态用 setState 完全没问题全局或跨页面共享状态、需要脱离 Widget 生命周期管理的用 Provider 或 Riverpod 这类简单方案就够了如果团队规模大、项目规则严格、需要通过代码结构强约束行为再考虑 Bloc。选哪个都能把项目做出来但“业务逻辑放在哪一层”这个约束必须前后一致。Riverpod 也好Bloc 也好都只是工具。项目结构能不能支撑长期迭代看的不是你用了多先进的工具链而是这些工具能不能被约束在清晰的分层里。3.4 页面与组件展示逻辑向上抛业务逻辑向下沉页面和组件的拆分是 UI 层最容易被忽视又最影响维护体验的细节。我把组件分成两种纯展示组件和业务组件。纯展示组件是可控的好公民它接收数据渲染界面用户操作通过回调抛给上层自己不发请求、不持有状态容器。比如一个账单卡片给它一个 BillEntity它负责显示金额、分类、时间点击事件通过 onTap 回调通知外面。这种组件可以放 shared/widgets任何模块都能安全复用。业务组件就比较危险。如果某个自定义组件在 initState 里请求网络、自己管理一整套业务状态那它看起来是个组件实际是披着组件外衣的半个页面。这种组件一旦被多个页面复用就同时绑定了多套业务上下文改一个地方很容易炸到另一个地方。我对团队的要求很简单组件里不出现 Repository、不出现网络请求最多出现回调。业务逻辑统一上收到页面或者状态容器里处理。4. 长期迭代最需要盯住的四个关键机制4.1 路由集中管理别到处 Navigator.push用过一段时间 Flutter 之后你会发现跳转页面是项目里最高频、最容易埋雷的操作。最原始的做法是到处用 Navigator.push 传字符串路由名页面 A 跳页面 B参数拼在 Map 里页面 C 也想跳这个页面又把参数格式抄一遍。等路由多了参数改了全局搜索能搜出一堆需要跟着改的地方。我现在的做法是把路由表收敛到一个文件里用静态常量定义所有路由名abstract final class AppRoutes { static const String login /login; static const String home /home; static const String billDetail /bill/detail/:billId; }页面之间的跳转统一走路由表参数通过路由配置里的泛型传递不做字符串拼接传参。如果用了 go_router在这个集中配置里还可以顺手做登录鉴权拦截、深层链接映射、转场动画配置。好处非常具体想知道项目现在有哪些页面看一个文件就够想知道这个页面接收什么参数也看这一个文件。4.2 依赖注入只靠 new 会有一天改不动代码分层和接口设计得再好最后都要解决一个现实问题谁在什么时候把实现类实例化并交给上层用。最简单的答案是构造器注入直接在构造函数里把依赖传进来class LoginController extends ChangeNotifier { LoginController(this._repository); final AuthRepository _repository; }这种做法简单、直观、可测试我建议能用的时候优先用。但项目大了以后手写构造器会把组装代码散落在各个页面里一个新来的同事很难快速搞清楚谁依赖谁。这时候可以引入服务定位器我用 get_it 比较多。在入口文件里做统一注册final getIt GetIt.instance; void setupDependencies() { getIt.registerLazySingletonAuthRepository(() AuthRepositoryImpl()); getIt.registerLazySingletonBillRepository(() BillRepositoryImpl()); getIt.registerFactory(() LoginController(getItAuthRepository())); }这里要提醒一个我踩过的坑不要在业务代码里到处调用 getIt() 取依赖。表面省了构造器传递的麻烦实际上又制造了大量隐藏依赖业务类脱离了容器就无法使用测试也很难注入替代实现。正确的姿势是依赖的获取集中在状态容器创建和页面入口处拿到之后再用构造器传给下层。4.3 多环境配置用 --dart-define不要硬编码Flutter 项目做到中后期几乎必有开发、测试、生产至少三套环境区别在接口地址、上报开关、日志级别。最常见但错误的做法是写一个配置文件每次发布改一下再提交或者用注释来回切换。正确做法是使用 --dart-define 在编译期注入flutter run --dart-defineAPP_ENVdev --dart-defineAPI_BASE_URLhttps://api.example.com代码里这样读取class AppConfig { static const String env String.fromEnvironment(APP_ENV, defaultValue: dev); static const String baseUrl String.fromEnvironment( API_BASE_URL, defaultValue: https://api.example.com, ); static bool get isProd env prod; }这样环境切换完全不改业务代码打包脚本里维护各个环境参数就好。密钥类的内容不要写进代码仓库更不要写进 git 历史交给持续集成环境里的密钥管理或者专门的环境配置文件处理。4.4 全局错误捕获和分级日志要从第一天就铺好没有日志的项目线上出问题就只能靠用户截图和客服转述排查效率极低。这个事实我踩过太多次了。好消息是 Flutter 项目铺日志的成本很低关键是第一天就铺不要等出事再补。我在入口文件做全局兜底void main() { WidgetsFlutterBinding.ensureInitialized(); FlutterError.onError (details) { // 上报到日志平台再决定是否打印到控制台 reportError(details.exception, details.stack); }; PlatformDispatcher.instance.onError (exception, stackTrace) { reportError(exception, stackTrace); return true; }; runApp(const MyApp()); }日志本身用统一的 logger 封装分 debug/info/warn/error 几级。开发环境控制台过滤 debug 日志线上环境只上报 warn 和 error。需要留意的另一件事是不要在日志里打印用户密码、Token、身份证号这类敏感信息日志平台本身也可能成为泄露源。5. 从零搭建一套可长期迭代的结构这一节我用一个虚构但很常见的需求场景来演示整个结构怎么落地假设我们要做一个“某跨平台对账系统”第一期的核心功能是登录和账单列表。我带着你在目录层面和数据流层面从头走一遍。5.1 初始化项目与搭建目录先用 flutter create 建出工程然后把上一节的目录骨架完整建出来。这一步不需要什么神奇的命令主要是一次性的文件夹组织工作。注意 features 目录先建立 auth 和 bill 两个模块哪怕 bill 还没开始写目录先占位。因为从第一天就明确“这是一个独立模块”比日后从一堆零散页面里去硬划边界要容易得多。接下来在 pubspec.yaml 里加入基础依赖。我通常先加 go_router 做路由get_it 做依赖注入以及自己选定的状态管理方案。不要贪多先把机制装好后面按需再加。5.2 集中路由与自动依赖注册路由配置收敛在 app/route/app_router.dart 里final GoRouter appRouter GoRouter( initialLocation: AppRoutes.home, routes: [ GoRoute( path: AppRoutes.login, builder: (context, state) const LoginPage(), ), GoRoute( path: AppRoutes.home, builder: (context, state) const HomePage(), ), ], );依赖注册集中在 main.dart 附近void main() { WidgetsFlutterBinding.ensureInitialized(); setupDependencies(); runApp(const MyApp()); }如果有几十个注册项可以引入 injectable 生成注册代码也可以保持手写注册列表。手写的优势是每次注册都经过人眼评审不会自动生成出很多没人知道的依赖关系我更偏向在中小型项目里保持手写可控性优先。5.3 完整实现一个登录功能模块登录功能是最典型的业务模块样例我按依赖方向从内到外讲清楚每个文件应该长什么样。先定义 Repository 接口业务方只依赖这个抽象// features/auth/domain/repositories/auth_repository.dart abstract interface class AuthRepository { FutureUserEntity login(String username, String password); }接口定义完了写实现它负责调用网络数据源// features/auth/data/repository_impl/auth_repository_impl.dart class AuthRepositoryImpl implements AuthRepository { const AuthRepositoryImpl(this._remoteDataSource); final AuthRemoteDataSource _remoteDataSource; override FutureUserEntity login(String username, String password) async { final UserDto dto await _remoteDataSource.login(username, password); return UserEntity(name: dto.nickname, isOnline: dto.status 1); } }远程数据源只关心 HTTP 层把 JSON 变成 DTO// features/auth/data/datasource/auth_remote_datasource.dart class AuthRemoteDataSource { const AuthRemoteDataSource(this._http); final Dio _http; FutureUserDto login(String username, String password) async { final response await _http.post(/login, data: { username: username, password: password, }); return UserDto.fromJson(response.data as MapString, dynamic); } }业务用例层把“登录”这个动作变成一个可测试的纯逻辑对象// features/auth/domain/usecases/login_usecase.dart class LoginUsecase { const LoginUsecase(this._repository); final AuthRepository _repository; FutureUserEntity execute(String username, String password) { return _repository.login(username, password); } }状态容器负责承接页面事件、调用用例、管理 loading 和错误信息// features/auth/presentation/controllers/login_controller.dart class LoginController extends ChangeNotifier { LoginController(this._usecase); final LoginUsecase _usecase; bool _loading false; UserEntity? _user; String? _errorMessage; bool get loading _loading; UserEntity? get user _user; String? get errorMessage _errorMessage; Futurevoid submit(String username, String password) async { _loading true; _errorMessage null; notifyListeners(); try { _user await _usecase.execute(username, password); } catch (e) { _errorMessage 登录失败请重试; } finally { _loading false; notifyListeners(); } } }页面只做一件事把用户的输入交给 Controller再把 Controller 的状态渲染出来不直接碰 Repository 和网络层。整个数据流清清楚楚LoginPage 触发 - LoginController 调用 LoginUsecase - AuthRepositoryImpl 使用 RemoteDataSource 请求网络 - DTO 转成 Entity - 回传到 UI。依赖方向和分层一目了然这就是这套结构能长期迭代的真正原因。6. 常见问题与排查技巧实录以下问题是多个项目评审和实际开发中反复出现的整理成速查表遇到症状直接对号入座。现象根因解决方案Feature 之间互相 import模块边界名存实亡公共代码没有及时下沉图省事跨模块调用确立依赖规则评审时拦截公共组件下沉 shared公共能力下沉 core 或 shared/services后端改一个字段名UI 层全部报错DTO 和 Entity 没有分离模型被所有层直接使用数据层保留 DTO 做解析业务层使用 Entity中间用 mapper 转换Widget 里开始出现网络请求和 setState 管理业务数据缺少状态层约束业务逻辑在 UI 层横向蔓延把业务逻辑迁入 Controller/状态容器组件只保留展示和回调路由参数越来越多跳转逻辑到处复制路由没有集中管理参数靠 Map 和字符串传递统一路由表页面跳转收敛到路由配置参数对象化传递单元测试写不动依赖全是隐藏单例没有接口抽象也没有依赖注入入口定义 Repository 接口通过构造器注入依赖测试时传入 Mock 实现线上出问题无法排查缺少统一日志和全局错误捕获在入口注册全局错误捕获日志分级输出warn/error 上报平台每次换环境都要改源码再提交环境配置硬编码在常量文件里用 --dart-define 在编译期注入环境参数配置文件只维护默认值状态管理器越用越复杂没人说得清数据流把状态管理工具当架构缺少单向数据流约束回归业务边界和分层状态容器统一入口页面只读状态、触发事件6.1 模块相互依赖失控模块相互依赖是长期迭代里最隐蔽的债务。表面上看只是多了一句 import实际上两个模块的生命周期从此绑定A 改动要担心影响 BB 发版还要看 A 的时间。解决办法不是靠自觉而是在代码评审里盯住依赖方向。我自己的经验是任何一次跨 feature import 都要先问“这个代码为什么不下沉到 shared”问三次之后团队就能慢慢地形成条件反射。6.2 改一个字段全项目报错后端字段和 UI 字段之间没有隔离层的话改一个字段就是一场灾难。这个问题的解法已经说过了——DTO 和 Entity 分离。哪怕一开始觉得多写一层麻烦也建议坚持住。等某一天后端把 status 从 int 改成 string 时你会发现原先那点“麻烦”是整条迭代链上最值的投资。6.3 状态越管越乱状态管理工具本身不会把项目搞乱搞乱它的往往是业务逻辑落在错误的位置。我见过一个项目把请求都写在 initState 里、把弹窗逻辑写在一个全局 Overlay 文件里状态管理框架确实用了但没人遵守统一的调用路径。后来团队把业务逻辑全部收回 Controller组件只看状态、抛事件整个数据流重新变得可见。可见的数据流才可能被维护不可见的数据流只会变成惊喜来源。最后再分享一点个人体会每次做结构评审的时候我会拿三个问题来检验一个项目。第一个问题依赖方向是不是清晰自顶向下业务模块之间能否独立演进第二个问题改一个业务需求时文件改动范围是不是被压缩在一个模块内第三个问题新来的同学不看文档能不能在一个小时内根据目录结构找到该改的位置这三个问题都能答上来结构就算合格。项目规模变大之后目录可以继续生长状态管理工具可以迁移但“边界清晰、方向一致、上下文聚合”这三条底线是我在长期迭代里从未变过的判断标准。如果问怎么设计 Flutter 项目结构这三条比任何目录模板都更接近答案。