Files
conti-docs/flutter-app/14-conventions-and-ci-gates.md

290 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 # 配合 formatterdiff 更干净
- 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<String, dynamic>`,写的人会被迫想一下"这里到底是什么类型"。
代价是接手 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/<jira-id>-<短描述>
fix/<jira-id>-<短描述>
release/<version>
hotfix/<version>
```
`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.ymlApp 部分,与 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_lintanalyze 不含它
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/<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](./08-build-flavors.md) 的 runner 问题一起解决)。
- JIRA(或其他)issue key 的格式,用于分支和提交信息里的 `<jira-id>`
- 是否引入 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/)