Files

304 lines
18 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 走单独的 Mac 机器。** `flutter build ipa` 必须跑在 macOS 上,本项目通过一台**远程 Mac** 出 iOS 包,详见下文「iOS 构建:远程 Mac」。
## Flavor 划分规则
| Flavor | Application ID / Bundle ID | API 目标 | 分发渠道 |
|---|---|---|---|
| `dev` | `com.conti.retail.dev` | Dev 环境(对应后端 Dev) | 内部测试分发(托管平台,见下文) |
| `uat` | `com.conti.retail.uat` | UAT 环境(对应后端 UAT) | 内部测试分发(托管平台 / TestFlight,见下文) |
| `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 \
--split-debug-info=build/symbols/$CI_COMMIT_TAG
```
- `--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` 把行号剥离出去了,堆栈里只剩类名和方法名。**丢了符号表 = 那个版本再也拿不到行号**,不可逆。
- 符号表目录按版本号区分(用 tag 或 `versionName+versionCode`),不能所有版本堆一个目录。
- **Android mappingR8)和 iOS dSYM 照旧归档,并在 build 之后上传 Bugly**(Bugly 提供符号表上传的命令行工具)。原生侧的自动符号化是 Bugly 的强项,这一条不受上面那个 Dart 决策影响。
## 版本号规则
| 字段 | 来源 | 示例 |
|---|---|---|
| `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 构建:远程 Mac
`flutter build ipa` 依赖 Xcode**必须在 macOS 上跑**,而现有 GitLab Runner 与后端共用、是 Linux runner。**本项目的方案是用一台远程 Mac 出 iOS 包**,不为此改造现有 Linux runner,也不引入云端 mac 构建服务(省掉把签名证书上传第三方带来的安全评审)。
两个阶段:
| 阶段 | 做法 |
|---|---|
| **当前** | 远程连上 Mac 手工执行构建脚本出 ipa。脚本进仓库(`scripts/build_ios.sh`),保证每次构建参数一致,不靠人记命令 |
| **后续** | 同一台 Mac 注册成 GitLab Runner(打 `macos` tag),iOS job 落到它上面,与 Android job 并行 |
**当前阶段的两条纪律**,它们是"手工出包"唯一的真实风险来源:
- **构建命令必须来自仓库里的脚本**,flavor、`--dart-define-from-file``--split-debug-info` 路径都在脚本里写死。手敲命令漏一个参数,出来的包看起来正常,实际连的是错的环境或者没有归档符号表。
- **符号表和 dSYM 要从 Mac 上带回来归档**(见上文 Release 构建)。这是手工出包最容易漏的一步——Linux 上有 CI artifact 自动兜着,Mac 上没有。
证书和描述文件(Provisioning Profile)无论哪个阶段都用 [fastlane match](https://docs.fastlane.tools/actions/match/) 管理,存在一个私有 git 仓库里,**不靠人肉在钥匙串之间导来导去**。这一条在只有一台 Mac 的情况下更重要:机器坏了、人换了,签名能力不能跟着丢。
注册成 runner 之后,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 App Distribution。** 使用者是中国境内门店的一线员工,Firebase 的下载域名在国内可达性和速度都不稳定,"链接点开一直转圈装不上"会直接拖垮 UAT 验收效率。
| 平台 | Android | iOS |
|---|---|---|
| **自建 / 公司托管的 OTA 分发页** | apk 直链下载 | `itms-services://` + plist(需企业签名或把设备 UDID 加进 ad-hoc 描述文件) |
| **TestFlight** | — | 上架前必经的验证路径,国内可达性没问题,**推荐 iOS 走这条** |
**下一步很可能是把分发收进后台管理端**:后台已经规划了「APP 配置」类功能(见 PRD 的后台模块),再加一个「APP 发布管理」是顺理成章的——版本列表、上传包、灰度范围、**强制升级开关**。做了它就同时解决三件事:内测分发、版本更新检查接口、强制升级,而不是各做各的。**这一条尚未定案**,见待确认项。
无论最终托管在哪,两条不变:
- **包要按 flavor 和版本号归档**,不能只留"最新一个"。回归验证经常要装回上一版。
- **分发入口要有访问控制**。apk 直链裸放在公网上,等于把内测包(含 uat 环境地址)交给任何人。
## Firebase 配置文件按 flavor 放置
本项目**不引入 Firebase**(崩溃上报走 Bugly + 神策,见 [13-observability-analytics.md](./13-observability-analytics.md);内测分发见上文)。以下写法仅在将来确实要引入某个 Firebase 服务时适用,留作参考——**配置文件必须按 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`
## 待确认项
- **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 包的只有某一台机器 + 某一个人"是典型的单点依赖)。
- 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
--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 构建:远程 Mac」
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
--split-debug-info=build/symbols/$CI_COMMIT_TAG
rules:
- if: '$CI_COMMIT_TAG'
```