- Introduced a new document outlining SDK version locking, static analysis, formatting, generated artifacts management, branching and commit conventions, and CI gate checks. - Updated README to include the new conventions document. - Modified API design to use numeric error codes instead of strings, with a dedicated ErrorCode object for better maintainability. - Adjusted global exception handling to return numeric error codes. - Updated tests to reflect changes in error code handling.
13 KiB
14. 工程规范与 CI 门禁
为什么单独一篇
这一篇是建项目当天就要用上的东西:lint 配置、格式化、生成产物是否入库、分支和提交规范、CI 卡什么。这些规则本身不难,难的是"没有在第一天定下来"——等到有 10 个人各写各的风格再统一,成本是第一天的几十倍。
一、SDK 版本锁定
# .fvmrc(仓库根目录,入库)
{ "flutter": "3.44.9" }
所有人用 FVM 装同一个版本,命令统一走 fvm flutter ...。理由见 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"的规则,而不是"管风格"的规则。
根级共享配置
# 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 只写一行继承,不允许在包级关规则(要关就在根上关,让所有人都看得见):
# packages/feature_xxx/analysis_options.yaml
include: ../../analysis_options.yaml
strict-casts 值得单独说
它是这份配置里唯一一条会真的挡住线上 bug 的开关。没有它,jsonDecode(...) 返回的 dynamic 可以隐式赋给任何类型,类型错误要到运行时才炸;开了之后必须显式 as Map<String, dynamic>,写的人会被迫想一下"这里到底是什么类型"。
代价是接手 JSON 解析时要多写一些 as。这个代价值得付。
custom_lint 在 workspace 下的接法
riverpod_lint(见 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 装了但从来没生效过。
三、格式化
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。原生侧的 Kotlin/Swift 文件要被 Gradle/Xcode 编译,而这两条工具链不会跑 build_runner。不入库的话原生构建直接失败 |
pubspec.lock |
根目录入库,各 package 的不入库 | workspace 模式下只有根 lock 生效 |
不入库 .g.dart 的代价是:新克隆仓库后必须先跑一次生成,否则 IDE 满屏报错。所以:
# 根 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/<jira-id>-<短描述>
fix/<jira-id>-<短描述>
release/<version>
hotfix/<version>
main/develop 保护分支,禁止直接 push,只能通过 MR 合入。
提交信息
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 门禁
# .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 |
| 集成测试 | 不卡 MR,只在合入 develop/main 时跑 | 慢,见 09 |
| Android release 构建 | 只在 tag 上跑 | 见 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。
七、本地钩子(可选但推荐)
# 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/<package>.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 的 runner 问题一起解决)。
- JIRA(或其他)issue key 的格式,用于分支和提交信息里的
<jira-id>。 - 是否引入 lefthook(需要每个人本地
lefthook install一次)。