说实话,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 # 根 Widget:MaterialApp、主题、路由 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 和 shared;features 之间禁止互相 import;两个 feature 都要用的代码,下沉到 shared 或者 core;core 是技术底座,永远不反向依赖业务。
注意:这条依赖规则不是写在文档里就完事的,要在代码评审里真拦截。我见过太多项目头两个月守规矩,第三个月为了赶需求开始跨模块 import,之后这条线就形同虚设了。
至于 domain 里的 usecases 层,对小型项目可以适当做轻。如果一个 feature 内部只有简单的“请求数据、展示数据”,可以省略 usecases,让状态容器直接调用 Repository 接口;保留 domain/repositories 层是有价值的,这是后面上测试和切换数据源时最重要的兜底。但保留却不使用,就妥妥变成了过度设计。
3. 核心设计细节:数据层、状态层、UI 层各自该扛什么
3.1 Repository 为什么值得认真建一层
Repository 模式是数据层的门面,也是整个结构里性价比最高的一个抽象。它的职责是给上层提供稳定的数据语义——“取一个账单列表”“保存一个用户资料”,至于数据到底来自网络、本地数据库还是内存缓存,上层完全不关心。
我做项目时习惯先在 domain/repositories 里定义接口,再来一个 data/repository_impl 的实现。比如:
abstract interface class BillRepository { Future<List<BillEntity>> getMonthlyBills(int year, int month); }第一版实现可以直接走远程 API。后面发现页面切后台再回来时要显示缓存,于是加一层本地数据库缓存,上层调用方式完全不变,只需要把 repository_impl 内部逻辑改一下。再后面如果做离线模式,还是一样的思路。
这里有一个非常关键的好处:测试。单元测试里需要模拟网络异常、空数据、超时等等,只要把实现替换成一个 Mock 类注入进去就行,不用真的起服务、不用依赖网络环境。没有这层抽象,测试就写不动,项目越到后期越不敢重构。
3.2 模型拆分:DTO 与 Entity 不能混用
很多项目里只有一个 Model 类,既解析网络 JSON,又拿去渲染 UI。这在后端和前端恰好一个字段名的时候没问题,但现实是后端给的是 status=1/0,UI 需要的是 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(Map<String, 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.registerLazySingleton<AuthRepository>(() => AuthRepositoryImpl()); getIt.registerLazySingleton<BillRepository>(() => BillRepositoryImpl()); getIt.registerFactory(() => LoginController(getIt<AuthRepository>())); }这里要提醒一个我踩过的坑:不要在业务代码里到处调用 getIt() 取依赖。表面省了构造器传递的麻烦,实际上又制造了大量隐藏依赖,业务类脱离了容器就无法使用,测试也很难注入替代实现。正确的姿势是:依赖的获取集中在状态容器创建和页面入口处,拿到之后再用构造器传给下层。
4.3 多环境配置用 --dart-define,不要硬编码
Flutter 项目做到中后期,几乎必有开发、测试、生产至少三套环境,区别在接口地址、上报开关、日志级别。最常见但错误的做法是写一个配置文件,每次发布改一下再提交,或者用注释来回切换。
正确做法是使用 --dart-define 在编译期注入:
flutter run --dart-define=APP_ENV=dev --dart-define=API_BASE_URL=https://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 { Future<UserEntity> 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 Future<UserEntity> 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; Future<UserDto> login(String username, String password) async { final response = await _http.post('/login', data: { 'username': username, 'password': password, }); return UserDto.fromJson(response.data as Map<String, dynamic>); } }业务用例层把“登录”这个动作变成一个可测试的纯逻辑对象:
// features/auth/domain/usecases/login_usecase.dart class LoginUsecase { const LoginUsecase(this._repository); final AuthRepository _repository; Future<UserEntity> 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; Future<void> 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 改动要担心影响 B,B 发版还要看 A 的时间。解决办法不是靠自觉,而是在代码评审里盯住依赖方向。我自己的经验是:任何一次跨 feature import 都要先问“这个代码为什么不下沉到 shared”,问三次之后,团队就能慢慢地形成条件反射。
6.2 改一个字段全项目报错
后端字段和 UI 字段之间没有隔离层的话,改一个字段就是一场灾难。这个问题的解法已经说过了——DTO 和 Entity 分离。哪怕一开始觉得多写一层麻烦,也建议坚持住。等某一天后端把 status 从 int 改成 string 时,你会发现原先那点“麻烦”是整条迭代链上最值的投资。
6.3 状态越管越乱
状态管理工具本身不会把项目搞乱,搞乱它的往往是业务逻辑落在错误的位置。我见过一个项目把请求都写在 initState 里、把弹窗逻辑写在一个全局 Overlay 文件里,状态管理框架确实用了,但没人遵守统一的调用路径。后来团队把业务逻辑全部收回 Controller,组件只看状态、抛事件,整个数据流重新变得可见。可见的数据流,才可能被维护;不可见的数据流,只会变成惊喜来源。
最后再分享一点个人体会
每次做结构评审的时候,我会拿三个问题来检验一个项目。第一个问题:依赖方向是不是清晰自顶向下,业务模块之间能否独立演进?第二个问题:改一个业务需求时,文件改动范围是不是被压缩在一个模块内?第三个问题:新来的同学不看文档,能不能在一个小时内根据目录结构找到该改的位置?
这三个问题都能答上来,结构就算合格。项目规模变大之后,目录可以继续生长,状态管理工具可以迁移,但“边界清晰、方向一致、上下文聚合”这三条底线,是我在长期迭代里从未变过的判断标准。如果问怎么设计 Flutter 项目结构,这三条比任何目录模板都更接近答案。