Files
conti-docs/14-conventions-and-ci-gates.md
T
Guangfei.Zhao 444db49818 feat: add engineering conventions and CI gates documentation
- 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.
2026-08-13 19:28:36 +08:00

13 KiB
Raw Blame History

14. 工程规范与 CI 门禁

为什么单独一篇

这一篇是建项目当天就要用上的东西:lint 配置、格式化、生成产物是否入库、分支和提交规范、CI 卡什么。这些规则本身不难,难的是"没有在第一天定下来"——等到有 10 个人各写各的风格再统一,成本是第一天的几十倍。

一、SDK 版本锁定

# .fvmrc(仓库根目录,入库)
{ "flutter": "3.44.9" }

所有人用 FVM 装同一个版本,命令统一走 fvm flutter ...。理由见 01-project-structure.mdmonorepo 里 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       # 配合 formatterdiff 更干净
    - 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_lintriverpod_lint 加在pubspec.yamldev_dependenciesworkspace 共享)。
  • 检查命令是 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.yamlformatter: page_width: 里(上面已配),命令行参数是给 CI 用的双保险。80 在 Flutter 的 widget 嵌套下换行过于频繁,一个三层嵌套的 Column 就能占满整屏。100 是一个在宽屏和可读性之间比较平衡的值。
  • 不允许手动排版dart format 的结果就是唯一正确的结果,不接受"我觉得这样更好看"。省下的是每次 review 里关于换行的争论。
  • CI 用 --set-exit-if-changed 卡死。

四、生成产物是否入库

这是一个必须明确的二选一,模糊处理会导致仓库里一半入库一半不入库。

类型 结论 理由
*.g.dartriverpod / 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 getmelos run genfvm 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

feat(feature_purchase): 支持采购单批量提交
fix(core_network): 修复 401 并发刷新导致全端登出
docs(05): 补充上传失败重传约定
chore(deps): 升级 drift 到 2.34.5

scope包名feature_purchasecore_network)或文档编号。monorepo 里没有 scope 的提交信息基本等于没有信息——fix: 修复崩溃 在半年后完全无法定位。

不引入自动化的 changelog 生成(首版没这个需求),但格式先立住,将来要加成本为零。

MR 规范

  • MR 标题同 commit 规范。
  • 描述里必须有:改了什么为什么怎么验证的
  • 一个 MR 只做一件事。 顺手格式化半个仓库的 MR 直接打回——它会让 review 变成不可能。
  • 至少 1 人 approve。涉及 core_* 的改动需要 2 人(这些包被所有 feature 依赖,改错影响面最大)。

六、CI 门禁

# .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
集成测试 不卡 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,私有加 _
常量 lowerCamelCaseDart 惯例,不是 SCREAMING_CASE
文件名 与主类名对应:OrderListPageorder_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 一次)。

参考链接