2026-08-12 17:49:57 +08:00
# 08. 多环境构建
## 决策
2026-08-13 19:28:36 +08:00
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 不同。
2026-08-21 13:46:56 +08:00
> ⚠️ **Android 复用与后端共用的 Linux Runner, iOS 走单独的 Mac 机器。** `flutter build ipa` 必须跑在 macOS 上,本项目通过一台**远程 Mac** 出 iOS 包,详见下文「iOS 构建:远程 Mac」。
2026-08-12 17:49:57 +08:00
## Flavor 划分规则
| Flavor | Application ID / Bundle ID | API 目标 | 分发渠道 |
|---|---|---|---|
2026-08-21 13:46:56 +08:00
| `dev` | `com.conti.retail.dev` | Dev 环境(对应后端 Dev) | 内部测试分发(托管平台,见下文) |
| `uat` | `com.conti.retail.uat` | UAT 环境(对应后端 UAT) | 内部测试分发(托管平台 / TestFlight,见下文) |
2026-08-13 19:28:36 +08:00
| `prod` | `com.conti.retail` | Prod 环境(对应后端 Prod) | App Store Connect / 各安卓应用市场 |
2026-08-12 17:49:57 +08:00
## 使用规则
- 每个 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 变量在构建时注入,不提交进仓库。
2026-08-13 19:28:36 +08:00
- Android 侧用 Gradle `productFlavors` 区分 `applicationIdSuffix` /图标/`versionNameSuffix` ; iOS 侧用对应的 xcconfig + Scheme 区分 `Bundle Identifier` /图标;两端 flavor 名称必须完全一致(`dev` /`uat` /`prod` ),不允许两端用不同命名。
2026-08-12 17:49:57 +08:00
- 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 都触发(避免误发生产包)。
2026-08-13 19:28:36 +08:00
## 用 `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` / 空。
2026-08-21 13:46:56 +08:00
## Release 构建:剥离符号,但不混淆
2026-08-13 19:28:36 +08:00
```bash
fvm flutter build appbundle \
--flavor prod --target lib/main_prod.dart \
--dart-define-from-file= env/prod.json \
2026-08-21 13:46:56 +08:00
--split-debug-info= build/symbols/$CI_COMMIT_TAG
2026-08-13 19:28:36 +08:00
```
2026-08-21 13:46:56 +08:00
- `--split-debug-info` 把调试符号剥离到单独目录(同时显著减小包体)。
- **`--obfuscate` 是刻意不加的。** 崩溃上报走 Bugly + 神策,两者都没有还原 Dart 混淆堆栈的能力(见 [13-observability-analytics.md ](./13-observability-analytics.md ))。加上混淆的结果是**线上占比最大的那一半崩溃在后台是一串 `_x12` **,每条都要人工 `flutter symbolize` 。不混淆时上报回来的堆栈类名方法名直接可读(`OrderRepository.submit` ),代价是 Dart 符号留在产物里、逆向门槛降一档——**这个取舍要和安全侧确认,见待确认项**。
- 若安全侧要求改回混淆,**两个参数必须一起用**:只写 `--obfuscate` 不写 `--split-debug-info` 会被工具链拒绝;同时 13 篇里的排查流程要改成"人工 symbolize"。
- **符号表必须归档**:按 `版本号+构建号` 存成 CI artifact 保留至少 1 年。不混淆之后它不再是日常排查的必需品,但仍是拿到精确行号的唯一手段——`--split-debug-info` 把行号剥离出去了,堆栈里只剩类名和方法名。**丢了符号表 = 那个版本再也拿不到行号**,不可逆。
2026-08-13 19:28:36 +08:00
- 符号表目录按版本号区分(用 tag 或 `versionName+versionCode` ),不能所有版本堆一个目录。
2026-08-21 13:46:56 +08:00
- **Android mapping( R8)和 iOS dSYM 照旧归档,并在 build 之后上传 Bugly**(Bugly 提供符号表上传的命令行工具)。原生侧的自动符号化是 Bugly 的强项,这一条不受上面那个 Dart 决策影响。
2026-08-13 19:28:36 +08:00
## 版本号规则
| 字段 | 来源 | 示例 |
|---|---|---|
| `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 变量(类型选 File, masked)
# 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` 。
2026-08-21 13:46:56 +08:00
## iOS 构建:远程 Mac
2026-08-13 19:28:36 +08:00
2026-08-21 13:46:56 +08:00
`flutter build ipa` 依赖 Xcode, **必须在 macOS 上跑**,而现有 GitLab Runner 与后端共用、是 Linux runner。**本项目的方案是用一台远程 Mac 出 iOS 包**,不为此改造现有 Linux runner,也不引入云端 mac 构建服务(省掉把签名证书上传第三方带来的安全评审)。
2026-08-13 19:28:36 +08:00
2026-08-21 13:46:56 +08:00
两个阶段:
2026-08-13 19:28:36 +08:00
2026-08-21 13:46:56 +08:00
| 阶段 | 做法 |
|---|---|
| **当前** | 远程连上 Mac 手工执行构建脚本出 ipa。脚本进仓库(`scripts/build_ios.sh` ),保证每次构建参数一致,不靠人记命令 |
| **后续** | 同一台 Mac 注册成 GitLab Runner(打 `macos` tag),iOS job 落到它上面,与 Android job 并行 |
2026-08-13 19:28:36 +08:00
2026-08-21 13:46:56 +08:00
**当前阶段的两条纪律** ,它们是"手工出包"唯一的真实风险来源:
2026-08-13 19:28:36 +08:00
2026-08-21 13:46:56 +08:00
- **构建命令必须来自仓库里的脚本**,flavor、`--dart-define-from-file` 、`--split-debug-info` 路径都在脚本里写死。手敲命令漏一个参数,出来的包看起来正常,实际连的是错的环境或者没有归档符号表。
- **符号表和 dSYM 要从 Mac 上带回来归档**(见上文 Release 构建)。这是手工出包最容易漏的一步——Linux 上有 CI artifact 自动兜着,Mac 上没有。
2026-08-13 19:28:36 +08:00
2026-08-21 13:46:56 +08:00
证书和描述文件(Provisioning Profile)无论哪个阶段都用 [fastlane match ](https://docs.fastlane.tools/actions/match/ ) 管理,存在一个私有 git 仓库里,**不靠人肉在钥匙串之间导来导去**。这一条在只有一台 Mac 的情况下更重要:机器坏了、人换了,签名能力不能跟着丢。
注册成 runner 之后,iOS job 打 tag 落到 mac runner:
2026-08-13 19:28:36 +08:00
```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
```
2026-08-21 13:46:56 +08:00
## 内测分发:托管平台 + 后台发布管理
2026-08-13 19:28:36 +08:00
2026-08-21 13:46:56 +08:00
**结论:用托管平台做内测分发,不引入 Firebase App Distribution。** 使用者是中国境内门店的一线员工,Firebase 的下载域名在国内可达性和速度都不稳定,"链接点开一直转圈装不上"会直接拖垮 UAT 验收效率。
2026-08-13 19:28:36 +08:00
2026-08-21 13:46:56 +08:00
| 平台 | Android | iOS |
2026-08-13 19:28:36 +08:00
|---|---|---|
2026-08-21 13:46:56 +08:00
| **自建 / 公司托管的 OTA 分发页** | apk 直链下载 | `itms-services://` + plist(需企业签名或把设备 UDID 加进 ad-hoc 描述文件) |
| **TestFlight** | — | 上架前必经的验证路径,国内可达性没问题,**推荐 iOS 走这条** |
2026-08-13 19:28:36 +08:00
2026-08-21 13:46:56 +08:00
**下一步很可能是把分发收进后台管理端** :后台已经规划了「APP 配置」类功能(见 PRD 的后台模块),再加一个「APP 发布管理」是顺理成章的——版本列表、上传包、灰度范围、**强制升级开关**。做了它就同时解决三件事:内测分发、版本更新检查接口、强制升级,而不是各做各的。**这一条尚未定案**,见待确认项。
2026-08-13 19:28:36 +08:00
2026-08-21 13:46:56 +08:00
无论最终托管在哪,两条不变:
- **包要按 flavor 和版本号归档**,不能只留"最新一个"。回归验证经常要装回上一版。
- **分发入口要有访问控制**。apk 直链裸放在公网上,等于把内测包(含 uat 环境地址)交给任何人。
2026-08-13 19:28:36 +08:00
## Firebase 配置文件按 flavor 放置
2026-08-21 13:46:56 +08:00
本项目**不引入 Firebase**(崩溃上报走 Bugly + 神策,见 [13-observability-analytics.md ](./13-observability-analytics.md );内测分发见上文)。以下写法仅在将来确实要引入某个 Firebase 服务时适用,留作参考——**配置文件必须按 flavor 分开放**,否则三个环境的数据会混进同一个项目:
2026-08-13 19:28:36 +08:00
```
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` 。
## 待确认项
2026-08-21 13:46:56 +08:00
- **release 到底混不混淆**——本文的决策是**不混淆**(理由见上文 Release 构建一节),需要安全侧确认能否接受 Dart 符号暴露在产物里。改回混淆的话,[13-observability-analytics.md ](./13-observability-analytics.md ) 的崩溃排查流程要一并改成"人工 symbolize"。
- **内测分发的托管位置与访问控制**——自建 OTA 页放在哪、谁维护、怎么鉴权,需要和运维确认。
- **「APP 发布管理」是否进后台管理端**——做了它就一并解决版本更新检查与强制升级(对应 PRD 的 `REQ-NFR-036` / `REQ-NFR-037` ),需要产品和后端一起裁决。
- 远程 Mac 何时注册成 GitLab Runner(当前是手工出包,长期不宜停在这一步——"能出 iOS 包的只有某一台机器 + 某一个人"是典型的单点依赖)。
2026-08-13 19:28:36 +08:00
- Android 上架渠道清单(华为/小米/OPPO/vivo 各家应用市场是否都要上,各家的加固/隐私合规要求不同)。
2026-08-12 17:49:57 +08:00
## 参考链接
- [Flutter 官方 Flavors 文档 ](https://docs.flutter.dev/deployment/flavors )
- [--dart-define-from-file 官方说明 ](https://docs.flutter.dev/deployment/flavors#configuration-approaches )
2026-08-13 19:28:36 +08:00
- [Flutter: 混淆 Dart 代码 ](https://docs.flutter.dev/deployment/obfuscate )
- [Android: 从命令行构建并签名 ](https://developer.android.com/build/building-cmdline )
- [fastlane match(证书管理) ](https://docs.fastlane.tools/actions/match/ )
2026-08-12 17:49:57 +08:00
## 附录:Flavor 是什么,日常怎么用
给还没接触过多环境构建方式的同学看的入门说明。
### 要解决的问题
一个 App 通常需要同时存在"开发中还没上线的版本"和"已经上线的正式版本",测试期间还需要一个给验收测试用的版本——这三个版本理想情况下要能**同时装在同一台测试手机上**,方便对比测试,而不是每次切换环境都要卸载重装。同时它们各自要打到不同的后端地址(Dev/UAT/Prod API),不能一个 apk 通过运行时开关切换后端就完事——因为如果只用运行时环境变量,三个环境包的 `applicationId` /`Bundle ID` 完全一样,装第二个会直接覆盖第一个。
**Flavor** 是 Android( Gradle `productFlavors` ,历史悠久的原生概念)和 iOS( Xcode Build Configuration/Scheme)本来就有的机制:在同一份代码基础上,用不同的编译配置产出`applicationId` /图标/名称都不同的多个安装包。Flutter 从工具链层面(`flutter build --flavor xxx` )把两端的 flavor 机制包装成统一的命令行接口。
### 核心概念
1. **Android `productFlavors`** :在 `android/app/build.gradle` 里声明多套 `applicationId` /`versionNameSuffix` /资源目录,编译时用 `--flavor` 选择其中一套。
2. **iOS Scheme + xcconfig** : iOS 没有 Gradle 那样的单文件配置,而是通过 Xcode 里多个 Build Configuration(对应不同的 `.xcconfig` 文件设置 Bundle ID 等)+ 多个 Scheme 组合实现同样的效果,`flutter build ipa --flavor xxx` 背后就是选中同名 Scheme。
3. **Dart 入口文件(`main_xxx.dart`) ** : flavor 决定的是"编译出什么样的原生外壳",Dart 代码本身默认只有一个 `main.dart` 入口——项目约定用多个入口文件对应各 flavor,让每个 flavor 能设置不同的启动参数(比如传给 `bootstrap()` 一个环境枚举)。
4. ** `--dart-define-from-file` **:flavor 解决的是原生层面的差异(图标、包名),但 API 地址这类 Dart 侧读取的配置,用编译期注入的 JSON 文件解决,避免打包进一个写死 `http://dev-api...` 的字符串常量。
### 使用示例
Android 侧 flavor 声明(`android/app/build.gradle` ):
```gradle
android {
2026-08-13 19:28:36 +08:00
namespace "com.conti.retail" // 源码 package,三个 flavor 都一样
defaultConfig {
applicationId "com.conti.retail"
}
2026-08-12 17:49:57 +08:00
flavorDimensions "env"
productFlavors {
dev {
dimension "env"
2026-08-13 19:28:36 +08:00
applicationIdSuffix ".dev" // → com.conti.retail.dev
2026-08-12 17:49:57 +08:00
versionNameSuffix "-dev"
2026-08-13 19:28:36 +08:00
resValue "string" , "app_name" , "Conti Retail(Dev)"
2026-08-12 17:49:57 +08:00
}
uat {
dimension "env"
2026-08-13 19:28:36 +08:00
applicationIdSuffix ".uat"
2026-08-12 17:49:57 +08:00
versionNameSuffix "-uat"
2026-08-13 19:28:36 +08:00
resValue "string" , "app_name" , "Conti Retail(UAT)"
2026-08-12 17:49:57 +08:00
}
prod {
dimension "env"
2026-08-13 19:28:36 +08:00
resValue "string" , "app_name" , "Conti Retail"
2026-08-12 17:49:57 +08:00
}
}
}
```
环境配置文件(`env/dev.json` ,非敏感部分):
```json
{
"API_BASE_URL" : "https://dev-api.conti-retail.com" ,
"ENABLE_LOG" : true
}
```
共享启动入口 + 各 flavor 的 Dart 入口文件:
```dart
// lib/bootstrap.dart —— 三个 flavor 共用的启动逻辑
Future < void > bootstrap ( AppEnv env ) async {
runApp ( ProviderScope (
overrides: [ appEnvProvider . overrideWithValue ( env )],
child: const App (),
));
}
```
```dart
// lib/main_dev.dart
void main () => bootstrap ( AppEnv . fromDartDefine ( name: 'dev' ));
```
构建命令:
```bash
2026-08-13 19:28:36 +08:00
fvm flutter build apk \
2026-08-12 17:49:57 +08:00
--flavor dev \
--target lib/main_dev.dart \
--dart-define-from-file= env/dev.json
```
2026-08-13 19:28:36 +08:00
GitLab CI 片段(衔接现有 Runner;注意 Android 和 iOS 必须落到不同的 runner 上):
2026-08-12 17:49:57 +08:00
```yaml
2026-08-13 19:28:36 +08:00
.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
2026-08-12 17:49:57 +08:00
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
2026-08-13 19:28:36 +08:00
artifacts :
paths : [ build/app/outputs/flutter-apk/app-dev-release.apk]
2026-08-12 17:49:57 +08:00
rules :
- if : '$CI_COMMIT_BRANCH == "develop"'
2026-08-13 19:28:36 +08:00
build_android_prod :
<< : *flutter_base
2026-08-12 17:49:57 +08:00
stage : build
script :
2026-08-13 19:28:36 +08:00
- 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
2026-08-21 13:46:56 +08:00
--split-debug-info=build/symbols/$CI_COMMIT_TAG
2026-08-13 19:28:36 +08:00
artifacts :
paths :
- build/app/outputs/bundle/prodRelease/
2026-08-21 13:46:56 +08:00
- build/symbols/ # 符号表必须归档,丢了就拿不到崩溃堆栈的行号
2026-08-13 19:28:36 +08:00
expire_in : 1 year
2026-08-12 17:49:57 +08:00
rules :
- if : '$CI_COMMIT_TAG' # 只在打 tag 时触发,避免误发生产包
2026-08-13 19:28:36 +08:00
build_ios_prod :
stage : build
2026-08-21 13:46:56 +08:00
tags : [ macos] # 必须是 mac runner, Linux runner 跑不了,见上文「iOS 构建:远程 Mac」
2026-08-13 19:28:36 +08:00
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
2026-08-21 13:46:56 +08:00
--split-debug-info=build/symbols/$CI_COMMIT_TAG
2026-08-13 19:28:36 +08:00
rules :
- if : '$CI_COMMIT_TAG'
2026-08-12 17:49:57 +08:00
```