# 14. 工程规范与 CI 门禁 ## 为什么单独一篇 这一篇是**建项目当天就要用上**的东西:lint 配置、格式化、生成产物是否入库、分支和提交规范、CI 卡什么。这些规则本身不难,难的是"没有在第一天定下来"——等到有 10 个人各写各的风格再统一,成本是第一天的几十倍。 ## 一、SDK 版本锁定 ``` # .fvmrc(仓库根目录,入库) { "flutter": "3.44.9" } ``` 所有人用 [FVM](https://fvm.app/) 装同一个版本,命令统一走 `fvm flutter ...`。理由见 [01-project-structure.md](./01-project-structure.md):monorepo 里 SDK 版本不一致会导致 `.dart_tool` 反复重建、生成代码差异、以及"我这跑得好好的"这类无法复现的问题。CI 也用 FVM 装同一版本,保证本地和 CI 完全一致。 `flutter --version` 的实测 Dart 版本要写进 README,因为 `environment.sdk` 的约束以它为准。 ## 二、静态分析 ### 选 `flutter_lints`,不选 `very_good_analysis` | | `flutter_lints` 6.0.0 | `very_good_analysis` 10.3.0 | |---|---|---| | 维护方 | **Flutter 官方** | Very Good Ventures | | 规则数量 | 适中,只收官方认为普遍适用的 | 非常多,包含大量风格约束 | | 跟随 SDK | 随 Flutter 版本同步更新 | 独立节奏 | `very_good_analysis` 更严格,但它在一个新项目上开箱会产生**成百上千条 warning**,其中很大一部分是纯风格问题(比如强制所有 public API 写文档注释、强制 `final` 局部变量)。团队的第一反应必然是批量 `// ignore:` 或者在 `analysis_options.yaml` 里关掉一半规则——最后既没享受到严格的好处,还多了一层配置负担。 **结论:以 `flutter_lints` 为底,手动加一小组"能抓真 bug"的规则,而不是"管风格"的规则。** ### 根级共享配置 ```yaml # analysis_options.yaml(仓库根目录) include: package:flutter_lints/flutter.yaml analyzer: language: strict-casts: true # 禁止 dynamic 隐式转型——最容易藏 bug 的一条 strict-raw-types: true # 禁止裸 List/Map,逼着写类型参数 strict-inference: true errors: invalid_annotation_target: ignore # json_serializable + 注解组合会误报 # 下面几条从 warning 提到 error,即 CI 直接失败 unused_import: error dead_code: error unawaited_futures: error exclude: - "**/*.g.dart" - "**/*.freezed.dart" - "**/generated/**" # pigeon 生成产物,见 07 plugins: - custom_lint # riverpod_lint,见 03 formatter: page_width: 100 linter: rules: # —— 能抓真 bug 的 —— - always_declare_return_types - avoid_dynamic_calls - avoid_slow_async_io - cancel_subscriptions # StreamSubscription 忘了 cancel 是常见内存泄漏 - close_sinks - discarded_futures # 忘了 await 的异步调用 - unawaited_futures - no_adjacent_strings_in_list # 少写一个逗号导致字符串被拼接 - test_types_in_equals - throw_in_finally - unnecessary_statements # —— 团队约定 —— - prefer_single_quotes - require_trailing_commas # 配合 formatter,diff 更干净 - directives_ordering - sort_pub_dependencies ``` 各包的 `analysis_options.yaml` 只写一行继承,不允许在包级关规则(要关就在根上关,让所有人都看得见): ```yaml # packages/feature_xxx/analysis_options.yaml include: ../../analysis_options.yaml ``` ### `strict-casts` 值得单独说 它是这份配置里**唯一一条会真的挡住线上 bug** 的开关。没有它,`jsonDecode(...)` 返回的 `dynamic` 可以隐式赋给任何类型,类型错误要到运行时才炸;开了之后必须显式 `as Map`,写的人会被迫想一下"这里到底是什么类型"。 代价是接手 JSON 解析时要多写一些 `as`。这个代价值得付。 ### `custom_lint` 在 workspace 下的接法 `riverpod_lint`(见 [03-state-management.md](./03-state-management.md))通过 `custom_lint` 插件运行。在 pub workspace 下: - `custom_lint` 和 `riverpod_lint` 加在**根 `pubspec.yaml` 的 `dev_dependencies`**(workspace 共享)。 - 检查命令是 `dart run custom_lint`,**它不包含在 `flutter analyze` 里**——两条命令都要跑,CI 里是两个独立步骤。这一点很多人不知道,结果 riverpod_lint 装了但从来没生效过。 ## 三、格式化 ```bash dart format --set-exit-if-changed --line-length 100 . ``` - **行宽 100,不是默认的 80。** Dart 3.9 起可以写在 `analysis_options.yaml` 的 `formatter: page_width:` 里(上面已配),命令行参数是给 CI 用的双保险。80 在 Flutter 的 widget 嵌套下换行过于频繁,一个三层嵌套的 `Column` 就能占满整屏。100 是一个在宽屏和可读性之间比较平衡的值。 - **不允许手动排版**。`dart format` 的结果就是唯一正确的结果,不接受"我觉得这样更好看"。省下的是每次 review 里关于换行的争论。 - CI 用 `--set-exit-if-changed` 卡死。 ## 四、生成产物是否入库 **这是一个必须明确的二选一,模糊处理会导致仓库里一半入库一半不入库。** | 类型 | 结论 | 理由 | |---|---|---| | `*.g.dart`(riverpod / json_serializable / drift) | **不入库** | 这类文件改动频繁且巨大,几乎每个 PR 都会产生冲突,而冲突的正确解法永远是"重新生成"——那入库就没有意义。加进 `.gitignore` | | pigeon 生成产物(Dart + Kotlin + Swift) | **入库** | 见 [07-native-integration.md](./07-native-integration.md)。原生侧的 Kotlin/Swift 文件要被 Gradle/Xcode 编译,而**这两条工具链不会跑 `build_runner`**。不入库的话原生构建直接失败 | | `pubspec.lock` | 根目录**入库**,各 package 的**不入库** | workspace 模式下只有根 lock 生效 | 不入库 `.g.dart` 的代价是:**新克隆仓库后必须先跑一次生成,否则 IDE 满屏报错**。所以: ```yaml # 根 pubspec.yaml 的 melos scripts gen: run: melos exec --depends-on=build_runner -- dart run build_runner build --delete-conflicting-outputs gen:watch: run: melos exec --depends-on=build_runner -- dart run build_runner watch --delete-conflicting-outputs ``` README 的"第一次跑起来"步骤必须是:`fvm flutter pub get` → `melos run gen` → `fvm flutter run`。**少写这一步,每个新人入职第一天都会卡住。** CI 在 analyze 之前必须先 `melos run gen`。 **pigeon 产物入库需要一道防腐**:CI 里重新生成后 `git diff --exit-code`,确保有人改了 schema 但忘了提交生成结果时流水线会红(见 07)。 ## 五、分支与提交 ### 分支 ``` main ← 生产,只接受来自 release/* 和 hotfix/* 的合并,打 tag 出包 develop ← 集成,日常合并目标 feature/-<短描述> fix/-<短描述> release/ hotfix/ ``` `main`/`develop` **保护分支,禁止直接 push**,只能通过 MR 合入。 ### 提交信息 用 [Conventional Commits](https://www.conventionalcommits.org/): ``` feat(feature_purchase): 支持采购单批量提交 fix(core_network): 修复 401 并发刷新导致全端登出 docs(05): 补充上传失败重传约定 chore(deps): 升级 drift 到 2.34.5 ``` `scope` 用**包名**(`feature_purchase`、`core_network`)或文档编号。monorepo 里没有 scope 的提交信息基本等于没有信息——`fix: 修复崩溃` 在半年后完全无法定位。 不引入自动化的 changelog 生成(首版没这个需求),但格式先立住,将来要加成本为零。 ### MR 规范 - MR 标题同 commit 规范。 - 描述里必须有:**改了什么**、**为什么**、**怎么验证的**。 - **一个 MR 只做一件事。** 顺手格式化半个仓库的 MR 直接打回——它会让 review 变成不可能。 - 至少 1 人 approve。涉及 `core_*` 的改动需要 2 人(这些包被所有 feature 依赖,改错影响面最大)。 ## 六、CI 门禁 ```yaml # .gitlab-ci.yml(App 部分,与 08-build-flavors.md 的构建 job 拼在一起) stages: [setup, verify, test, build] .flutter_base: &flutter_base image: <内部 flutter 镜像,预装 FVM 3.44.9> before_script: - fvm flutter --version - dart pub global activate melos 8.2.2 - melos bootstrap - melos run gen # 生成产物不入库,必须先生成 cache: key: "$CI_COMMIT_REF_SLUG" paths: [.dart_tool/, .pub-cache/] format: <<: *flutter_base stage: verify script: dart format --set-exit-if-changed --line-length 100 . analyze: <<: *flutter_base stage: verify script: - melos exec -- fvm flutter analyze --fatal-infos - dart run custom_lint # riverpod_lint,analyze 不含它 pigeon_check: <<: *flutter_base stage: verify script: - melos run gen:pigeon - git diff --exit-code || (echo "pigeon 生成产物未提交" && exit 1) test: <<: *flutter_base stage: test script: - melos run test - melos run coverage # 阈值 60%,见 09 coverage: '/lines\.*: \d+\.\d+\%/' artifacts: paths: [coverage/] reports: { coverage_report: { coverage_format: cobertura, path: coverage/cobertura.xml } } ``` ### 门禁清单 | 检查 | 卡点 | 说明 | |---|---|---| | `dart format` | **阻断** | | | `flutter analyze --fatal-infos` | **阻断** | `--fatal-infos` 让 info 级别也算失败,否则 lint 规则形同虚设 | | `dart run custom_lint` | **阻断** | | | pigeon 产物一致性 | **阻断** | | | 单元测试 + Widget 测试 | **阻断** | | | 覆盖率 ≥ 60% | **阻断** | 见 [09-testing.md](./09-testing.md) | | 集成测试 | **不卡 MR**,只在合入 develop/main 时跑 | 慢,见 09 | | Android release 构建 | 只在 tag 上跑 | 见 [08-build-flavors.md](./08-build-flavors.md) | `--fatal-infos` 值得强调:不加这个参数,`flutter analyze` 对 info 级别的问题只是打印一下就返回 0,CI 永远绿。半年后仓库里会积累几百条 info,然后没人再看 analyze 的输出。 ### 关于 `melos bootstrap` 的缓存 `.pub-cache` 必须缓存,否则每次 CI 都要重新下载所有依赖,一个 monorepo 下来是几分钟。缓存 key 用 `$CI_COMMIT_REF_SLUG`(按分支),并配一个按 `pubspec.yaml` 哈希的 fallback key。 ## 七、本地钩子(可选但推荐) ```yaml # lefthook.yml pre-commit: parallel: true commands: format: glob: "*.dart" run: dart format --line-length 100 {staged_files} && git add {staged_files} analyze: glob: "*.dart" run: fvm flutter analyze --fatal-infos {staged_files} ``` **只跑 format 和 analyze,不跑测试。** pre-commit 跑测试会让每次提交等几十秒,人的第一反应是 `--no-verify`,钩子就废了。测试留给 CI。 钩子是**建议不是强制**——CI 才是真正的门禁。钩子的价值只是让人少推一次红色流水线。 ## 八、目录与命名速查 | 项 | 约定 | |---|---| | 包名 / 目录 / 文件 | `snake_case` | | 类 / enum | `UpperCamelCase` | | 变量 / 方法 | `lowerCamelCase`,私有加 `_` | | 常量 | `lowerCamelCase`(Dart 惯例,不是 `SCREAMING_CASE`) | | 文件名 | 与主类名对应:`OrderListPage` → `order_list_page.dart` | | provider | `xxxProvider`,由 `@riverpod` 生成,不手写 | | 测试文件 | `<被测文件>_test.dart`,目录镜像 `lib/src/` | | 包的公共 API | 只从 `lib/.dart` 导出,`lib/src/` 下的一律视为私有(见 01) | import 顺序由 `directives_ordering` 强制:`dart:` → `package:`(外部)→ `package:`(本仓库)→ 相对路径。 **包内用相对路径 import,跨包用 `package:`。** 混用会导致同一个类被 Dart 认为是两个不同的类型(典型症状:`type 'X' is not a subtype of type 'X'`),这个错误看起来完全不可理喻,实际就是 import 路径不一致。 ## 待确认项 - GitLab Runner 上是否已有可用的 Flutter 镜像,还是需要自建(与 [08-build-flavors.md](./08-build-flavors.md) 的 runner 问题一起解决)。 - JIRA(或其他)issue key 的格式,用于分支和提交信息里的 ``。 - 是否引入 lefthook(需要每个人本地 `lefthook install` 一次)。 ## 参考链接 - [flutter_lints | Dart package](https://pub.dev/packages/flutter_lints) - [Dart: Customizing static analysis](https://dart.dev/tools/analysis) - [Dart linter rules 全量列表](https://dart.dev/tools/linter-rules) - [FVM 官方文档](https://fvm.app/) - [Conventional Commits](https://www.conventionalcommits.org/) - [Melos 官方文档](https://melos.invertase.dev/)