Files
2026-08-17 15:29:55 +08:00

302 lines
17 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.
# 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 RunneriOS 不行。** `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 变量(类型选 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)
- [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** 是 AndroidGradle `productFlavors`,历史悠久的原生概念)和 iOSXcode 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 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'
```