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.
This commit is contained in:
Guangfei.Zhao
2026-08-13 19:28:36 +08:00
parent be009ac15e
commit 444db49818
17 changed files with 3362 additions and 239 deletions
+190 -15
View File
@@ -2,29 +2,168 @@
## 决策
App 侧维护 **3 个 flavor`dev` / `uat` / `prod`**,环境划分与现有后端 CI/CD(见 `gitlab-cicd-azure-deployment-diagram.drawio` 里的 Dev/UAT/Prod Azure 环境)保持一致命名,三个 flavor 各自对应不同的 API 地址、应用图标/名称、包名后缀,可在同一台设备上同时安装、互不覆盖。CI 沿用现有 GitLab CIRunner 与后端共用),但产物是 App 二进制(apk/ipa),分发渠道与后端的 ACR/Azure App Hosting 不同。
App 侧维护 **3 个 flavor`dev` / `uat` / `prod`**,环境划分与现有后端 CI/CD(见 `gitlab-cicd-azure-deployment-diagram.drawio` 里的 Dev/UAT/Prod Azure 环境)保持一致命名,三个 flavor 各自对应不同的 API 地址、应用图标/名称、包名后缀,可在同一台设备上同时安装、互不覆盖。CI 沿用现有 GitLab CI,但产物是 App 二进制(apk/ipa),分发渠道与后端的 ACR/Azure App Hosting 不同。
> ⚠️ **Android 可以复用与后端共用的 Linux RunneriOS 不行。** `flutter build ipa` 必须跑在 macOS 上,这是首版发版前必须先解决的工程阻塞项,详见下文「iOS 构建链路:当前不成立,必须先解决」。
## Flavor 划分规则
| Flavor | Application ID / Bundle ID | API 目标 | 分发渠道 |
|---|---|---|---|
| `dev` | `com.conti.retail.dev` | Dev 环境(对应后端 Dev) | Firebase App Distribution内部测试) |
| `uat` | `com.conti.retail.uat` | UAT 环境(对应后端 UAT) | Firebase App Distribution(验收测试 |
| `prod` | `com.conti.retail` | Prod 环境(对应后端 Prod | App Store Connect / Google Play(生产发布) |
| `dev` | `com.conti.retail.dev` | Dev 环境(对应后端 Dev) | 内部测试分发(渠道待定,见下文 |
| `uat` | `com.conti.retail.uat` | UAT 环境(对应后端 UAT) | 内部测试分发(渠道待定,见下文 |
| `prod` | `com.conti.retail` | Prod 环境(对应后端 Prod | App Store Connect / 各安卓应用市场 |
## 使用规则
- 每个 flavor 对应一个独立的 Dart 入口文件(`main_dev.dart`/`main_uat.dart`/`main_prod.dart`),三者都只是设置好环境标识后调用同一个共享的 `bootstrap()` 启动函数,不允许在入口文件里写业务逻辑分支。
- 环境相关的可变配置(API base URL、是否开启日志等,见 [05-networking.md](./05-networking.md) 的 `appEnvProvider`)通过 `--dart-define-from-file=env/{flavor}.json` 注入,不写死在代码里、也不用 `if (flavor == 'dev')` 这种运行时字符串判断来分支配置。
- `env/*.json` 只包含非敏感配置(API 地址等);密钥类配置(如第三方 SDK App Key)通过 CI 变量在构建时注入,不提交进仓库。
- Android 侧用 Gradle `productFlavors` 区分 `applicationId`/图标/`versionNameSuffix`iOS 侧用对应的 xcconfig + Scheme 区分 `Bundle Identifier`/图标;两端 flavor 名称必须完全一致(`dev`/`uat`/`prod`),不允许两端用不同命名。
- Android 侧用 Gradle `productFlavors` 区分 `applicationIdSuffix`/图标/`versionNameSuffix`iOS 侧用对应的 xcconfig + Scheme 区分 `Bundle Identifier`/图标;两端 flavor 名称必须完全一致(`dev`/`uat`/`prod`),不允许两端用不同命名。
- CI 流水线阶段固定为:`melos run analyze``melos run test` → 按 flavor `flutter build apk/ipa --flavor {flavor} --dart-define-from-file=env/{flavor}.json` → 上传对应分发渠道。`prod` flavor 的构建触发条件是打 tag,不是每次 push 都触发(避免误发生产包)。
## 用 `applicationIdSuffix` 而不是覆盖 `applicationId`
```gradle
productFlavors {
dev { dimension "env"; applicationIdSuffix ".dev"; versionNameSuffix "-dev" }
uat { dimension "env"; applicationIdSuffix ".uat"; versionNameSuffix "-uat" }
prod { dimension "env" } // 用 defaultConfig 的 applicationId,不加后缀
}
```
理由:直接覆盖 `applicationId` 会让 `applicationId`**Kotlin 源码的 package 名脱钩**。Android 的 `R` 类、`BuildConfig` 类是按 `namespace`(源码 package)生成的,而 `applicationId` 只影响安装标识——两者写成不同的值本身合法,但很多三方 SDK(推送、地图、统计)的初始化会隐式假设它们一致,配错的表现是"dev 包能跑,uat 包某个 SDK 静默失效"。用 `applicationIdSuffix` 只在末尾加后缀,`namespace` 保持不变,从结构上避免这类问题。
对应地 iOS 侧 xcconfig 里也用 `PRODUCT_BUNDLE_IDENTIFIER = com.conti.retail$(BUNDLE_ID_SUFFIX)``BUNDLE_ID_SUFFIX` 按 Build Configuration 取 `.dev` / `.uat` / 空。
## Release 构建必须开混淆和符号剥离
```bash
fvm flutter build appbundle \
--flavor prod --target lib/main_prod.dart \
--dart-define-from-file=env/prod.json \
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
```
- `--obfuscate` 混淆 Dart 符号名,`--split-debug-info` 把调试符号剥离到单独目录(同时显著减小包体)。
- **两个参数必须一起用**,只写 `--obfuscate` 不写 `--split-debug-info` 会被工具链拒绝。
- **符号表必须归档,并且构建完立刻上传到 Sentry**:混淆后崩溃堆栈是不可读的乱码。CI 在 build 之后紧跟一条 `fvm dart run sentry_dart_plugin`,把 Dart 符号表、Android mapping、iOS dSYM 一起传上去(见 [13-observability-analytics.md](./13-observability-analytics.md))。**上传时的 `release` 必须和 App 里 `options.release` 严格一致**,对不上的表现是"传了但堆栈还是混淆的",且后台不报错。
- 同时把 `build/symbols/``版本号+构建号` 归档为 CI artifact 保留至少 1 年,作为 Sentry 侧数据过期或服务不可用时的兜底。**丢了符号表 = 那个版本的所有线上崩溃永远无法定位**,这是个不可逆的失误。
- 符号表目录按版本号区分(用 tag 或 `versionName+versionCode`),不能所有版本堆一个目录。
## 版本号规则
| 字段 | 来源 | 示例 |
|---|---|---|
| `versionName` | git tag(去掉 `v` 前缀) | tag `v1.4.0``1.4.0` |
| `versionCode` / `CFBundleVersion` | CI pipeline ID(单调递增) | `$CI_PIPELINE_ID``48213` |
要点:
- `versionCode` **必须单调递增且永不重复**——Google Play 和 App Store Connect 都会拒绝重复或回退的版本号,而这个错误只在上传那一刻才暴露,很容易卡在发版当天。用 `CI_PIPELINE_ID` 天然满足递增,比手工维护数字可靠。
- `pubspec.yaml` 里的 `version:` 在 CI 构建时被 `--build-name` / `--build-number` 覆盖,仓库里的值只作为本地开发的占位,不作为发版依据。
- dev/uat 包的 `versionName``-dev`/`-uat` 后缀,测试反馈时一眼能看出装的是哪个环境的包。
## Android 签名与 keystore 注入
keystore **不入 git**(包括 dev 的)。CI 里通过变量注入:
```yaml
# GitLab CI 变量(类型选 Filemasked
# ANDROID_KEYSTORE_BASE64 - keystore 文件的 base64
# ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD
before_script:
- echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/release.keystore
- |
cat > android/key.properties <<EOF
storeFile=release.keystore
storePassword=$ANDROID_KEYSTORE_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
keyPassword=$ANDROID_KEY_PASSWORD
EOF
after_script:
- rm -f android/app/release.keystore android/key.properties
```
- **`prod` 的 keystore 一旦丢失,就再也无法给已上架的 App 发更新**Google Play 的 Play App Signing 有救回机制,但前提是当初开启了;App Store 走的是苹果的证书体系,另说)。除了 CI 变量,必须在公司密钥管理系统里另存一份,并且有至少两个人能拿到。
- dev/uat 可以共用一个非正式 keystore,prod 单独一个。
- `android/key.properties` 加进 `.gitignore`
## iOS 构建链路:当前不成立,必须先解决
**这是首版发布前最大的工程阻塞项,不是可以边做边说的事情。**
现状:GitLab Runner 与后端共用,是 **Linux** runner。而 `flutter build ipa` **必须在 macOS 上跑**(依赖 Xcode),Linux runner 上这条流水线根本无法存在。除此之外 iOS 还需要证书和描述文件(Provisioning Profile)的管理,这在 CI 上是另一套工作量。
三个可选路径:
| 方案 | 成本 | 说明 |
|---|---|---|
| **A. 自建 mac mini runner**(推荐) | 一次性硬件采购 + 机房/网络接入 | 一台 M 系列 mac mini 就够跑 iOS 构建。长期成本最低,网络在内网也方便访问私有仓库。缺点是要有人维护(Xcode 升级、磁盘清理、注册成 GitLab Runner |
| **B. 云端 mac runner** | 按分钟计费,持续支出 | GitLab 的 macOS runner / Codemagic / Bitrise。省运维,但涉及把签名证书上传到第三方,需要走安全评审;且国内访问速度和稳定性要实测 |
| **C. 先只做 Android CIiOS 手工出包** | 零成本,但有人力成本和风险 | 短期可行,作为 A/B 落地前的过渡。风险是"能出 iOS 包的只有某一台开发机 + 某一个人",属于典型的单点依赖 |
**建议**:立项时就按 **A** 走,把 mac mini 的采购提前提出来(采购周期通常比想象长),过渡期用 **C**。无论选哪个,证书和描述文件都用 [fastlane match](https://docs.fastlane.tools/actions/match/) 管理,存在一个私有 git 仓库里,不靠人肉在钥匙串之间导来导去。
CI 上 iOS job 需要单独打 tag 到 mac runner
```yaml
build_ios_uat:
stage: build
tags: [macos] # 只有 mac runner 有这个 tag
script:
- fvm flutter build ipa --flavor uat --target lib/main_uat.dart \
--dart-define-from-file=env/uat.json --export-options-plist=ios/ExportOptions-uat.plist
```
## 内测分发渠道:Firebase 在国内可达性存疑
原方案写的是 Firebase App Distribution。**问题**:本项目的使用者是**中国境内门店的一线员工**,Firebase 的下载域名在国内的可达性和速度都不稳定,很可能出现"链接点开一直转圈装不上"。这会直接影响 UAT 验收效率。
候选方案对比:
| 渠道 | 国内可达 | 说明 |
|---|---|---|
| Firebase App Distribution | ❌ 不稳定 | 与 Crashlytics 集成好,但国内下载体验是硬伤 |
| **蒲公英 / fir.im** | ✅ | 国内主流内测分发,支持 iOS/Android,扫码安装。需要企业账号,注意上传的包属于放在第三方服务器 |
| **自建 OTA 分发页** | ✅ | 一个静态页 + `itms-services://` plistiOS)+ apk 直链。完全可控、无第三方依赖,但要自己做鉴权、版本管理 |
| GitLab Package Registry | ✅(走公司网络) | 已有基础设施、无额外采购。缺点是 iOS 装包体验差(不支持 OTA 直装),Android 也要用户手动下载 apk |
**建议**Android 用**自建 OTA 页或 GitLab Package Registry**(内网可控),iOS 用 **TestFlight**(苹果官方,国内可达性没问题,且是上架前必经的验证路径)。这个组合避免了引入新的第三方服务商和相应的安全评审。
**列为待确认项**:需要和运维确认自建 OTA 页的托管位置与访问控制。
## Firebase 配置文件按 flavor 放置
如果最终引入 Firebase(如 Crashlytics),配置文件要按 flavor 分开放,否则三个环境的崩溃数据会混进同一个项目:
```
android/app/src/dev/google-services.json
android/app/src/uat/google-services.json
android/app/src/prod/google-services.json
ios/Runner/Firebase/dev/GoogleService-Info.plist # 通过 Xcode Run Script 按 Configuration 复制
ios/Runner/Firebase/uat/GoogleService-Info.plist
ios/Runner/Firebase/prod/GoogleService-Info.plist
```
Android 的 flavor 源集目录(`src/{flavor}/`)会自动生效;iOS 没有等价机制,需要在 Build Phases 加一个 Run Script,按 `$CONFIGURATION` 把对应文件复制到 `Runner/GoogleService-Info.plist`
## 待确认项
- **iOS 构建 runner 方案(A/B/C 选哪个)**——阻塞 iOS 发版,优先级最高。
- 内测分发渠道的最终选型与托管位置。
- 是否引入 Firebase(影响崩溃上报选型,见 [13-observability-analytics.md](./13-observability-analytics.md))。
- Android 上架渠道清单(华为/小米/OPPO/vivo 各家应用市场是否都要上,各家的加固/隐私合规要求不同)。
## 参考链接
- [Flutter 官方 Flavors 文档](https://docs.flutter.dev/deployment/flavors)
- [--dart-define-from-file 官方说明](https://docs.flutter.dev/deployment/flavors#configuration-approaches)
- [Firebase App Distribution](https://firebase.google.com/docs/app-distribution)
- [Flutter: 混淆 Dart 代码](https://docs.flutter.dev/deployment/obfuscate)
- [Android: 从命令行构建并签名](https://developer.android.com/build/building-cmdline)
- [fastlane match(证书管理)](https://docs.fastlane.tools/actions/match/)
## 附录:Flavor 是什么,日常怎么用
@@ -49,21 +188,29 @@ Android 侧 flavor 声明(`android/app/build.gradle`):
```gradle
android {
namespace "com.conti.retail" // 源码 package,三个 flavor 都一样
defaultConfig {
applicationId "com.conti.retail"
}
flavorDimensions "env"
productFlavors {
dev {
dimension "env"
applicationId "com.conti.retail.dev"
applicationIdSuffix ".dev" // → com.conti.retail.dev
versionNameSuffix "-dev"
resValue "string", "app_name", "Conti Retail(Dev)"
}
uat {
dimension "env"
applicationId "com.conti.retail.uat"
applicationIdSuffix ".uat"
versionNameSuffix "-uat"
resValue "string", "app_name", "Conti Retail(UAT)"
}
prod {
dimension "env"
applicationId "com.conti.retail"
resValue "string", "app_name", "Conti Retail"
}
}
}
@@ -98,29 +245,57 @@ void main() => bootstrap(AppEnv.fromDartDefine(name: 'dev'));
构建命令:
```bash
flutter build apk \
fvm flutter build apk \
--flavor dev \
--target lib/main_dev.dart \
--dart-define-from-file=env/dev.json
```
GitLab CI 片段(衔接现有 Runner,产物走 Firebase App Distribution 而非后端用的 ACR):
GitLab CI 片段(衔接现有 Runner;注意 Android 和 iOS 必须落到不同的 runner 上):
```yaml
build_dev:
.flutter_base: &flutter_base
image: ghcr.io/cirruslabs/flutter:3.44.9 # 与 .fvmrc 保持一致
before_script:
- dart pub global activate melos
- melos bootstrap
build_android_dev:
<<: *flutter_base
stage: build
script:
- melos run analyze
- melos run test
- flutter build apk --flavor dev --target lib/main_dev.dart --dart-define-from-file=env/dev.json
- firebase appdistribution:distribute build/app/outputs/.../dev/release/app-dev-release.apk
artifacts:
paths: [build/app/outputs/flutter-apk/app-dev-release.apk]
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
build_prod:
build_android_prod:
<<: *flutter_base
stage: build
script:
- flutter build appbundle --flavor prod --target lib/main_prod.dart --dart-define-from-file=env/prod.json
- flutter build appbundle --flavor prod --target lib/main_prod.dart
--dart-define-from-file=env/prod.json
--build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
artifacts:
paths:
- build/app/outputs/bundle/prodRelease/
- build/symbols/ # 符号表必须归档,丢了就没法解混淆崩溃堆栈
expire_in: 1 year
rules:
- if: '$CI_COMMIT_TAG' # 只在打 tag 时触发,避免误发生产包
build_ios_prod:
stage: build
tags: [macos] # 必须是 mac runnerLinux runner 跑不了,见上文「iOS 构建链路」
script:
- fvm flutter build ipa --flavor prod --target lib/main_prod.dart
--dart-define-from-file=env/prod.json
--build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
rules:
- if: '$CI_COMMIT_TAG'
```