302 lines
17 KiB
Markdown
302 lines
17 KiB
Markdown
# 08. 多环境构建
|
||
|
||
## 决策
|
||
|
||
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 Runner,iOS 不行。** `flutter build ipa` 必须跑在 macOS 上,这是首版发版前必须先解决的工程阻塞项,详见下文「iOS 构建链路:当前不成立,必须先解决」。
|
||
|
||
|
||
## Flavor 划分规则
|
||
|
||
| Flavor | Application ID / Bundle ID | API 目标 | 分发渠道 |
|
||
|---|---|---|---|
|
||
| `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` 区分 `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 变量(类型选 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`。
|
||
|
||
## 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 CI,iOS 手工出包** | 零成本,但有人力成本和风险 | 短期可行,作为 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://` plist(iOS)+ 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)
|
||
- [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 是什么,日常怎么用
|
||
|
||
给还没接触过多环境构建方式的同学看的入门说明。
|
||
|
||
### 要解决的问题
|
||
|
||
一个 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 {
|
||
namespace "com.conti.retail" // 源码 package,三个 flavor 都一样
|
||
|
||
defaultConfig {
|
||
applicationId "com.conti.retail"
|
||
}
|
||
|
||
flavorDimensions "env"
|
||
productFlavors {
|
||
dev {
|
||
dimension "env"
|
||
applicationIdSuffix ".dev" // → com.conti.retail.dev
|
||
versionNameSuffix "-dev"
|
||
resValue "string", "app_name", "Conti Retail(Dev)"
|
||
}
|
||
uat {
|
||
dimension "env"
|
||
applicationIdSuffix ".uat"
|
||
versionNameSuffix "-uat"
|
||
resValue "string", "app_name", "Conti Retail(UAT)"
|
||
}
|
||
prod {
|
||
dimension "env"
|
||
resValue "string", "app_name", "Conti Retail"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
环境配置文件(`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
|
||
fvm flutter build apk \
|
||
--flavor dev \
|
||
--target lib/main_dev.dart \
|
||
--dart-define-from-file=env/dev.json
|
||
```
|
||
|
||
GitLab CI 片段(衔接现有 Runner;注意 Android 和 iOS 必须落到不同的 runner 上):
|
||
|
||
```yaml
|
||
.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
|
||
artifacts:
|
||
paths: [build/app/outputs/flutter-apk/app-dev-release.apk]
|
||
rules:
|
||
- if: '$CI_COMMIT_BRANCH == "develop"'
|
||
|
||
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
|
||
--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 runner,Linux 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'
|
||
```
|