18 KiB
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 走单独的 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 的
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→ 按 flavorflutter build apk/ipa --flavor {flavor} --dart-define-from-file=env/{flavor}.json→ 上传对应分发渠道。prodflavor 的构建触发条件是打 tag,不是每次 push 都触发(避免误发生产包)。
用 applicationIdSuffix 而不是覆盖 applicationId
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 构建:剥离符号,但不混淆
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)。加上混淆的结果是线上占比最大的那一半崩溃在后台是一串_x12,每条都要人工flutter symbolize。不混淆时上报回来的堆栈类名方法名直接可读(OrderRepository.submit),代价是 Dart 符号留在产物里、逆向门槛降一档——这个取舍要和安全侧确认,见待确认项。- 若安全侧要求改回混淆,两个参数必须一起用:只写
--obfuscate不写--split-debug-info会被工具链拒绝;同时 13 篇里的排查流程要改成"人工 symbolize"。 - 符号表必须归档:按
版本号+构建号存成 CI artifact 保留至少 1 年。不混淆之后它不再是日常排查的必需品,但仍是拿到精确行号的唯一手段——--split-debug-info把行号剥离出去了,堆栈里只剩类名和方法名。丢了符号表 = 那个版本再也拿不到行号,不可逆。 - 符号表目录按版本号区分(用 tag 或
versionName+versionCode),不能所有版本堆一个目录。 - Android mapping(R8)和 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 里通过变量注入:
# 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 构建:远程 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 管理,存在一个私有 git 仓库里,不靠人肉在钥匙串之间导来导去。这一条在只有一台 Mac 的情况下更重要:机器坏了、人换了,签名能力不能跟着丢。
注册成 runner 之后,iOS job 打 tag 落到 mac runner:
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;内测分发见上文)。以下写法仅在将来确实要引入某个 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 的崩溃排查流程要一并改成"人工 symbolize"。
- 内测分发的托管位置与访问控制——自建 OTA 页放在哪、谁维护、怎么鉴权,需要和运维确认。
- 「APP 发布管理」是否进后台管理端——做了它就一并解决版本更新检查与强制升级(对应 PRD 的
REQ-NFR-036/REQ-NFR-037),需要产品和后端一起裁决。 - 远程 Mac 何时注册成 GitLab Runner(当前是手工出包,长期不宜停在这一步——"能出 iOS 包的只有某一台机器 + 某一个人"是典型的单点依赖)。
- Android 上架渠道清单(华为/小米/OPPO/vivo 各家应用市场是否都要上,各家的加固/隐私合规要求不同)。
参考链接
- Flutter 官方 Flavors 文档
- --dart-define-from-file 官方说明
- Flutter: 混淆 Dart 代码
- Android: 从命令行构建并签名
- fastlane 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 机制包装成统一的命令行接口。
核心概念
- Android
productFlavors:在android/app/build.gradle里声明多套applicationId/versionNameSuffix/资源目录,编译时用--flavor选择其中一套。 - iOS Scheme + xcconfig:iOS 没有 Gradle 那样的单文件配置,而是通过 Xcode 里多个 Build Configuration(对应不同的
.xcconfig文件设置 Bundle ID 等)+ 多个 Scheme 组合实现同样的效果,flutter build ipa --flavor xxx背后就是选中同名 Scheme。 - Dart 入口文件(
main_xxx.dart):flavor 决定的是"编译出什么样的原生外壳",Dart 代码本身默认只有一个main.dart入口——项目约定用多个入口文件对应各 flavor,让每个 flavor 能设置不同的启动参数(比如传给bootstrap()一个环境枚举)。 --dart-define-from-file:flavor 解决的是原生层面的差异(图标、包名),但 API 地址这类 Dart 侧读取的配置,用编译期注入的 JSON 文件解决,避免打包进一个写死http://dev-api...的字符串常量。
使用示例
Android 侧 flavor 声明(android/app/build.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,非敏感部分):
{
"API_BASE_URL": "https://dev-api.conti-retail.com",
"ENABLE_LOG": true
}
共享启动入口 + 各 flavor 的 Dart 入口文件:
// lib/bootstrap.dart —— 三个 flavor 共用的启动逻辑
Future<void> bootstrap(AppEnv env) async {
runApp(ProviderScope(
overrides: [appEnvProvider.overrideWithValue(env)],
child: const App(),
));
}
// lib/main_dev.dart
void main() => bootstrap(AppEnv.fromDartDefine(name: 'dev'));
构建命令:
fvm flutter build apk \
--flavor dev \
--target lib/main_dev.dart \
--dart-define-from-file=env/dev.json
GitLab CI 片段(衔接现有 Runner;注意 Android 和 iOS 必须落到不同的 runner 上):
.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 runner,Linux 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'