ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter项目结构设计:分层架构、状态管理与依赖注入实战

Flutter项目结构设计:分层架构、状态管理与依赖注入实战

做 Flutter 开发这几年,我见过太多项目从“一个人写得很爽”变成“一群人维护想哭”的过程。尤其是项目一上规模,目录乱、依赖绕、业务和框架代码揉成一团,改一个需求要翻三个文件,提一次 PR 要解决五处冲突,这种痛苦和环境无关、和框架无关,就是项目结构没设计好。Flutter 项目结构说白了就是一个项目的骨架,骨架正了,后面加功能、加人、加模块都是往骨架上挂肉;骨架歪了,每加一次功能都是在给项目上枷锁,长期迭代就成了长期负资产。

这篇文章我想把自己在多个 Flutter 项目里沉淀下来的结构设计思路完整讲一遍,包括分层架构怎么定、目录怎么组织、状态管理怎么选、依赖怎么注入、路由怎么设计、测试和规范怎么配套,以及最容易踩的坑会是哪些。这套方案不追求花哨,追求的是“能扛住业务变化”和“新成员能快速上手”,适合那些打算做成长期产品的团队参考。

1. 为什么项目结构能决定项目的生死

1.1 项目结构反映的不是文件摆放,而是职责边界

很多人对 Flutter 项目结构的第一反应就是“目录怎么建”,觉得无非就是pages、widgets、models这些文件夹的排列组合。但真正在项目里待过几年后你会明白,项目结构本质上是对软件职责边界的显式约定。一个文件放在哪里,不是看它是什么类型的文件,而是看它在这个系统里承担什么职责、被谁依赖、能依赖谁。

举个例子,一个登录页面的TextEditingController逻辑,如果直接写在页面 Widget 里,那它属于 UI 层;如果被抽到一个LoginModel里,那它属于业务层;如果再进一步放到一个抽象的AuthService里,那它属于服务层。同样是“获取用户名”这件事,代码落点不同,整个项目的扩展性和可测试性就完全不同。

我见过不少团队在项目初期为了图快,把所有东西都往pages里堆,一个页面文件动辄两千行,里面塞了网络请求、状态管理、UI 构建、本地存储,甚至埋点逻辑。这种代码在刚写完的当天是“能跑的”,但等产品开始迭代第二个版本、第三个版本时,每一次改动都要在这些巨型文件里小心翼翼地找“哪里能改、哪里不能碰”,改坏一个边界就是一次线上事故。项目结构的本质,就是把这种“隐性风险”变成“显性规则”。

1.2 长期迭代的敌人不是需求多,而是熵增快

软件工程里有个词叫“熵增”,放到业务项目里就是:每一次小改动如果不加约束,系统整体的混乱度就会上升一点点。需求本身多不多根本不是致命问题,致命的是项目没有一套机制去对抗熵增。

Flutter 项目尤其容易熵增,原因是 Flutter 的声明式 UI 风格让“临时加个状态”“随手建个 Model”“顺手 import 一个工具类”变得格外轻松,代码层面的熵增速度比其他框架快得多。而且 Flutter 的状态管理和依赖方案非常多样,不同时期入职的开发者会带着不同的惯性进来,A 用 Provider、B 用 Bloc、C 在页面里自己setState,如果项目结构里没有明确的“哪里该用什么方案”的约定,代码风格很快就会四分五裂。

结构设计的意义,就是在熵增最快的几个节点上预设好“约束轨道”。比如规定页面只属于 UI 层、业务逻辑必须进 Controller 或 Cubit、网络请求必须走 Repository、跨模块调用必须经过接口。这些约束本质上就是把“每个人凭感觉写代码”变成“大家都在同一套轨道上行驶”,虽然前期会慢一点点,但项目跑起来之后,每一次迭代都是在给轨道加增益,而不是在野地里重新开路。

2. 分层架构的设计思路

2.1 技术分层:UI、业务、数据三层是最稳的地基

Flutter 项目结构设计第一步是确定技术分层。我用了很多项目之后,最终稳定在“UI 层、业务层、数据层”三层模型上,这也是很多成熟 Flutter 团队的默认选择。

UI 层负责渲染和交互反馈,这一层只关心“用户看到了什么、点了什么”,不关心数据怎么来、业务规则是什么。具体到 Flutter 里就是 Widget、Page、自定义组件,还有和 Widget 生命周期绑定的局部状态。业务层负责状态管理和业务规则,用户点了一个按钮之后系统要做什么、哪些条件可以执行、执行成功后状态怎么变,这些逻辑全部在这一层完成。数据层负责和外部世界打交道,网络请求、本地数据库、SharedPreferences、系统通道调用,全部封装在这层,向上只暴露干净的数据接口。

这三层之间有个铁律:UI 层可以依赖业务层,业务层可以依赖数据层,反过来坚决不允许。数据层不知道业务层存在,业务层也不知道 UI 层存在。如果哪天你在数据层里看到BuildContext,在 UI 层里直接写了http.post,那基本可以断定这个项目的边界已经松了。

2.2 功能组织:按功能模块切分而不是按文件类型切分

技术分层解决的是“横切”问题,接下来还要解决“纵切”问题。所谓纵切,就是同一个业务功能涉及到的 UI、业务、数据代码,怎么聚合在一起。

早期 Flutter 项目里常见的是按文件类型组织,建一个pages文件夹放所有页面,建立一个models文件夹放所有数据模型,建立一个services文件夹放所有网络服务。这种方式的优点是“找某个类型的文件容易”,缺点是一个功能要改的时候,你需要在pages、models、services三个文件夹之间来回跳,而且随着项目变大,每个文件夹里的文件数量会膨胀到不可管理的地步。

我推荐的是按功能模块组织,每个业务功能一个独立目录。比如登录模块就是一个feature/login,里面包含这个功能专属的页面、状态管理、数据模型、网络接口;用户中心就是feature/profile,订单模块就是feature/order。每个功能模块内部再按 UI 层、业务层、数据层划分子目录或者按文件职责命名。

这样做最直接的好处是“功能内聚”,我改订单模块的时候,90% 的改动都发生feature/order目录内部,不需要跨目录猜来猜去。另一个好处是支持团队并行,两个小组各负责一个 feature 目录,代码交集被压缩到最小,冲突率会大大降低。

2.3 组合方式:功能模块作为主干,公共层作为支撑

把技术分层和功能组织组合起来,就得到了我实际使用的整体结构:项目顶层分成两个大的区域,一个是core核心区,一个是features功能区。

core里放的是和具体业务无关的公共能力,比如网络请求封装、通用组件库、主题系统、路由工具、基础工具函数。features里放的是按业务拆分的功能模块,每个 feature 是“三明治”结构,UI 在顶上、业务在中间、数据在底下。

依赖方向从全局来看是单一方向的。features里的模块可以依赖core,但core永远不能反向依赖features。features之间的依赖也尽量通过抽象接口或路由跳转完成,不让两个业务模块直接 import 对方的内部实现。这套结构放到团队协作里就是“公共底座大家维护、业务模块各自演进”,项目越大,这种隔离带来的安全感和效率感就越明显。

3. 一套可直接落地的 Flutter 目录结构

3.1 顶层目录规划:从 lib 开始的完整布局

基于上面的思想,我给出一份可以直接复制到项目里使用的顶层目录结构,再逐个模块解释用途。

lib/ ├── main.dart # 入口,负责初始化与启动 ├── app.dart # MaterialApp 配置与全局主题接入 ├── core/ # 公共层,与业务无关 │ ├── config/ # 环境配置、全局常量、API地址管理 │ ├── components/ # 通用 UI 组件,如按钮、弹窗、空态页 │ ├── constants/ # 常量定义,如间距、颜色、文案key │ ├── extensions/ # Dart 扩展方法 │ ├── network/ # 网络层封装,Dio实例、拦截器 │ ├── router/ # 路由表与导航工具 │ ├── storage/ # 本地存储封装 │ ├── theme/ # 主题、文字样式、颜色体系 │ └── utils/ # 纯工具函数,如日期格式化、校验 ├── features/ # 业务功能模块 │ ├── login/ │ │ ├── data/ │ │ ├── business/ │ │ └── ui/ │ ├── profile/ │ │ ├── data/ │ │ ├── business/ │ │ └── ui/ │ └── ... # 其他业务模块 ├── shared/ # 跨功能模块的共享代码 │ ├── models/ # 通用数据模型 │ ├── widgets/ # 被多个功能复用的组件 │ └── services/ # 被多个功能复用的服务 └── generated/ # 代码生成产物,如json序列化、路由表

这个结构可能和你见过的一些模板不太一样,有几个设计点值得展开说明。

3.2 核心模块详解:core 层每个目录的职责边界

core/config主要放环境相关的配置,比如开发、测试、生产环境的 API 地址、全局的开关配置。我的习惯是定义一个AppConfig类,用编译环境区分不同配置,而不是散落在各个页面的代码里用if (isProd)判断。这样当你需要切换环境时,只需要改一处配置。

core/network是网络层封装的核心。所有网络请求都通过这里统一发的,我在里面维护一个Dio单例,统一配置超时时间、拦截器、token 刷新逻辑、错误码转换逻辑。各个 feature 里的数据源都不直接new Dio(),而是通过依赖注入拿到这个实例。这样做的好处是:当需要统一加日志、统一处理 401 时,只改core/network里的代码,而不是去每个 feature 里找散落的请求逻辑。

core/components和shared/widgets需要区分一下。core/components放的是高度通用、完全不知道业务存在的组件,比如按 AppBar 封装、底部弹窗、loading 动画、错误重试视图,这些组件可以独立成包,不依赖任何业务模型。shared/widgets放的是和业务模型有耦合、但被多个 feature 复用的组件,比如一个到处都在用的用户头像组件、一个展示多语言文本的富文本标签。这个区分在项目大了之后很有用,因为你会想把core层的东西沉淀成公司内私有库,而shared的东西只能留在应用内部。

3.3 feature 模块的内部结构:data、business、ui 的取舍

每个 feature 模块内部,我采用三段式结构:data、business、ui。

data目录是这个功能模块的数据来源实现,包含网络请求的接口定义、API 的 DTO 模型、数据源实现、数据仓库实现。这个目录里的类和core/network打交道,和core/storage打交道,但绝对不 import 任何ui目录里的代码。

business目录是这个功能模块的状态管理和业务规则,在 Flutter 里通常就是一个或者多个 Controller / Cubit / Bloc。这个目录里定义“页面处于什么状态”“用户操作触发什么状态流转”“哪些业务条件要校验”,它不知道页面怎么渲染,但知道一切业务规则。ui目录就是纯粹的页面和组件,负责把business里的状态映射成界面,把用户的点击事件派发给business处理。

这里有个非常重要的细节:ui目录里不能出现任何http、dio、SharedPreferences直接调用,不能出现数据库字段名,更不能出现对data目录里 DTO 的直接引用。UI 层只和business层进行交互,business层再和data层交互。严格遵守这条规则之后,你会发现 UI 层代码变得非常“薄”,甚至笨办法说就是一个“状态到视图的翻译器”,这会让后续改 UI 样式、换状态管理方案、接新数据源,都变成局部改动。

3.4 目录之间的依赖规则:一张图讲清楚谁可以 import 谁

依赖关系用文字来解释容易被忽略,我总结成一个明确的依赖规则表,并且在 Code Review 里严格执行。

目录允许依赖严禁依赖
core/componentscore/theme、core/constants任何features里的代码
core/network自己的依赖任何业务模块、features
features/x/datacore/network、core/storagefeatures/x/ui、features/y/*
features/x/businessfeatures/x/data、core/*features/x/ui、其他 feature 的 business/data
features/x/uifeatures/x/business、core/components、core/theme直接访问core/network、其他 feature data
shared/*core/*features/*的内部实现
app.dartcore/theme、core/router、features的入口页面各 feature 内部业务实现

这条规则表看着简单,但执行的难点在于“初期不觉得有什么用”。等到项目迭代一年以上,这个规则表会帮你挡掉大量因为依赖环而爆发的编译问题,也会让重构时的分析范围精确到一个模块内部。

4. 支撑长期迭代的关键设计决策

4.1 状态管理方案怎么选:我最终选择 Bloc/Cubit 的理由

Flutter 生态里状态管理方案多到让人选择困难,从setState到Provider、Riverpod、Bloc、GetX都有各自的拥趸。我的实际建议是:小项目随意,长期项目优先考虑Bloc家族,尤其是flutter_bloc的Cubit形态。

理由不是Bloc最先进,而是它的“约束感”最适合长期项目。Bloc强制你把状态变化建模成事件和状态,这会在代码层面给你画出一条“状态只能从哪里来、到哪里去”的轨迹,非常有利于排查问题。Cubit比完整Bloc轻量,适合大多数业务场景,它的核心就是一个emit方法,用起来接近ChangeNotifier,但是自带流式响应和统一的监听机制。

另外Bloc家族的工具链很完整,BlocProvider、BlocBuilder、BlocListener这些组件可以直接在 UI 层组合使用,测试时可以独立测试Cubit而不需要构建 Widget 树,这一点在项目后期质量保障上特别加分。

但我要强调一点,选Bloc不等于所有状态都要进Bloc。页面里某个文本框的输入法状态、某个下拉菜单的开合状态,这种纯粹属于 UI 交互的局部状态,用StatefulWidget或者一个简单的ValueNotifier就够了。如果把什么都塞进 Cubit,反而会把业务层搞得臃肿不堪。我在项目里定了一条规则:只有“跨页面共享”或“需要被测试的业务状态”才进 Cubit,其他状态就地解决。

4.2 依赖注入的取舍:手写 Service Locator 还是用 get_it

Flutter 项目的依赖注入方案不像后端那么复杂,不需要引入一大套容器框架。我实际用的是get_it+Injectable的组合,前者负责注册和获取实例,后者用代码生成来管理注册模板。

get_it的优势是简单直接,注册和获取都很显式,学习成本极低。Injectable的代码生成则保证了你不会写错注册代码,每次新增一个 Service 或者 Repository,运行一次 build_runner 就能自动生成注册逻辑,省去了手动维护注册表的痛苦。

依赖注入在项目结构里扮演的角色是“解耦粘合剂”。比如LoginRepository需要一个DioClient,它不需要自己 new,而是通过构造函数接收,这个实例在哪里创建、生命周期怎么管理,完全由外层容器决定。这样做的好处是:当你想把一个网络实现替换成 mock 实现时,只需要在注册表里换一个类,所有依赖它的业务代码一行都不用改。这对于 Flutter 项目的单元测试和 UI 测试尤其重要。

4.3 路由管理的长期价值:命名路由+模块化路由表

路由管理在 Flutter 项目里容易被低估。很多人项目初期随手用Navigator.push写页面跳转,看起来没啥问题,但等到页面数量超过三十个、需要处理跳转传参、需要做深链、需要支持权限拦截时,散落各处的Navigator.push就会变成一场灾难。

我在项目里使用命名路由 + 模块化路由表的方案。每个 feature 的ui目录下维护一个自己的路由常量表,比如login_routes.dart里声明/login,profile_routes.dart里声明/profile/settings。然后在core/router里把这些模块化路由表汇总成一份总表,交给MaterialApp的onGenerateRoute统一解析。

这样做有几个直接好处。第一,找路由跳转关系变得容易,一张总表就能看清整个 App 的导航地图。第二,模块之间的跳转不需要 import 对方的页面类,只要知道对方路由名称和参数即可,进一步降低了 feature 之间的耦合。第三,后续如果要加登录拦截、埋点统计、统一页面切换动画,只需要在onGenerateRoute这个中枢里做一次,成本极低。

4.4 网络层与数据层的边界:Repository 模式的实际用法

在网络层和数据层之间,我强烈建议引入Repository(仓库)模式。仓库模式简单说就是:业务层不关心数据到底是从网络拿的、从缓存拿的、还是从数据库拿的,它只面向一个“仓库接口”,仓库的职责是决定数据来源策略并屏蔽底层细节。

具体到目录里,就是每个 feature 的data目录下有一个xx_repository.dart,它负责调用网络接口、缓存策略、数据转换。业务层只接收这个 Repository 暴露的领域对象,完全接触不到 DTO、JSON、数据库字段。举个例子,用户中心模块要展示用户信息,UI 层拿到的是一个UserProfile领域模型,这个模型是从网络接口来的还是从本地缓存来的,UI 层完全不知道,也不需要知道。

Repository 模式给长期迭代带来的最大价值是“可替换性”。当后端接口从 v1 升级到 v2 时,你只需要改 Repository 里的数据源,页面和业务逻辑完全不动。当产品要求“先展示缓存,再刷新网络”时,你只需要在 Repository 内部调整数据流,不需要动任何其他层。很多项目改接口改到想哭,问题不是出在接口本身,而是业务代码到处都是直接的数据访问点,没有仓库兜底。

5. 代码规范与质量保障

5.1 lint 规则与代码格式化:用机制代替自觉

项目结构搭得再完美,如果代码风格五花八门,长期迭代仍然会很难受。我的经验是在项目创建初期就把analysis_options.yaml配置到位,集成严格的一组 lint 规则,并且要求所有人在提交代码前必须跑过dart format和静态分析。

这里的重点不是“开很多规则”,而是“开对规则”。我实际开的最有价值的几条规则包括:禁止print留在代码里、禁止未使用的 import、强制使用const构造函数、强制指定类型或使用final、禁止在 build 方法里做耗时操作。这些规则能在编码阶段就拦截掉大量低级问题,让 Code Review 的重心放在设计和逻辑上,而不是放在空格和命名上。

和 lint 配套的是dart format,强制统一代码格式化风格。有些团队觉得格式化工具不智能,但我建议直接让机器说了算,不再人工争论缩进风格,能大幅减少脑力消耗。

5.2 测试策略:业务层测试是长期迭代的保险丝

Flutter 项目重 UI 是常态,很多团队把测试资源全部投到 widget test 上,导致测试耗时长、不稳定、跑一次十分钟。我的建议是把测试重心放在业务层,也就是 Cubit 和 Repository 的单元测试上,UI 层跑少量关键路径的 widget test 就够了。

业务层测试成本低且极其稳定,因为不涉及真实渲染,也不需要等待动画,跑几百个用例都很快。当状态管理的逻辑都沉淀在 Cubit 里时,你完全可以脱离 Widget 树直接构造 Cubit,喂给它事件,然后断言它输出的状态序列是否符合预期。这套测试写起来体验极好,就像在写数学证明一样。

随着项目迭代,这些业务层测试就是最可靠的“重构保险丝”。每当你改了某个业务逻辑,跑一遍全量业务测试,基本就能知道有没有破坏老功能,比起靠 QA 手工回归要高效得多。我一个项目的业务测试从最初的两百多个一路涨到上千个,每次大改依赖它兜底,省下的回归成本非常可观。

5.3 文档与最小注释原则

代码注释这块,我的原则是“注释解释为什么,不解释是什么”。final userRepository = userRepository;这种注释写了等于没写,而“这里先查缓存再请求网络,避免启动时白屏”这种注释才是真正有价值的。项目结构相关的决策,我会写一份简短的docs/architecture.md,记录下分层规则、依赖规则、状态管理选型原因、目录变更历史。这份文档不需要很长,核心是让新成员少踩自己踩过的坑。

我也见过团队把架构文档写成几十页的设计蓝图,结果根本没人看,因为太长了。我自己的习惯是把架构文档控制在“一个新人半天能读完”的体量,并且和实际代码保持同步。文档一旦过期,比没有文档还害人。

6. 常见问题与避坑实录

6.1 过度设计的陷阱:结构服务于当前问题,而不是想象中的问题

项目结构设计最容易犯的毛病就是想太多,把结构搞得特别复杂。我见过一个不到一万行代码的项目,目录分了五层,每一层都有抽象的接口、工厂、泛型约束,结果业务变化一到,这些抽象反而成了阻碍,改一行代码要牵动五个文件。

真正好的结构是“比当前需求超前一步,但不超前十步”。比如项目还只有三个模块时,不用急着上微前端或者模块化动态下发;业务复杂度还没起来时,也不用强行把所有状态都改成流式架构。结构设计要能跟着业务演进,但演进不等于一开始就设计到天花板。我建议每两周审视一次项目结构,如果某个抽象已经没人用,或者某个目录变成了垃圾场,就及时调整,让结构始终为当前真实问题服务。

6.2 模块间通信的混乱:业务模块耦合的真问题

随着 feature 数量增长,最棘手的问题不是单个模块变复杂,而是模块之间开始互相依赖。比如订单模块要读取用户模块的用户信息,购物车模块要调用商品模块的详情接口,处理不好,features之间的 import 关系就会变成一个蜘蛛网。

我的解法是三点。第一,优先通过路由跳转和参数传递解决页面协作,不直接访问对方页面组件内部状态。第二,跨模块的数据协作放shared层,定义一个共享服务或者共享模型,两个 feature 都依赖它,而不是依赖对方。第三,如果两个模块的耦合特别深,我会考虑把它们合并成一个 feature,而不是硬逼它们假装独立。模块拆分是为了降低复杂度,不是为了好看的目录树,一切以实际开发体验为准。

6.3 重构的节奏:技术债不能等,但也不能硬还

项目结构不可能一次设计完美,所以重构不可避免。我的建议是“小步快走,持续重构”,每次提交都顺手改善自己遇到的坏味道,而不是攒一个大重构计划然后集中执行。

大重构最危险的点是“回归风险”,改动面大,测试覆盖不够,最终上线出问题。我踩过一次很深的坑:某次集中重构把网络层从自研封装全面切到统一框架,由于没有分阶段灰度,结果线上出现了大量超时,回滚又因为数据格式已经变了而变得非常痛苦。从那以后,我定的规矩是:重构必须按模块切分,每个模块重构完单独验证,验证通过再进下一个,绝不为了“快”把所有改动揉在一起。

6.4 新成员接入的两种极端

项目结构设计还会直接影响团队的新成员融入。两种极端都很常见:一种是没有结构,代码全平铺,新人进来完全不知道从哪看起;另一种是结构过度抽象,新人看了三天文档还是找不到“一个按钮点击之后,代码到底走了一条什么路径”。

我比较推荐的做法是:给新人一张“业务流程图”,标记清楚用户操作从 UI 到 Business 到 Data 再到后端的完整链路,然后让他顺着一条真实需求走一遍代码。走完之后,他基本就能理解结构设计里的所有约束是怎么产生、怎么起作用的。我还倾向于在项目里保留一个“新手任务”分支,内容就是实现一个很小的完整功能,要求严格按照分层规则写,提交后由老成员做一次专门的架构 Review,这是让新人最快掌握项目结构的办法。

写在最后

如果你准备开始一个新的 Flutter 项目,或者手上的项目已经开始出现“迭代费劲”的信号,我可以给你一个最简单的起步方法:先把core和features的隔离定下来,然后选一个真实的业务模块按data / business / ui三段式做一个样板,最后把 lint、格式化、测试这三件事从第一天就开启。这三步做完,你的项目就已经比大部分同类项目活得长了。

结构设计这件事没有终态,它和一些设计原则一样,是在一次次真实迭代中反复检验和修正的。只要团队愿意在每个版本里花一点时间维护边界、清理依赖、补充测试,项目就会越走越顺。希望这篇分享能给你们团队一点启发,也欢迎在实际项目中遇到有意思的问题回来交流。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进