- Introduced a new document outlining SDK version locking, static analysis, formatting, generated artifacts management, branching and commit conventions, and CI gate checks. - Updated README to include the new conventions document. - Modified API design to use numeric error codes instead of strings, with a dedicated ErrorCode object for better maintainability. - Adjusted global exception handling to return numeric error codes. - Updated tests to reflect changes in error code handling.
17 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 不行。
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 的
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 \
--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)。上传时的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 里通过变量注入:
# 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 管理,存在一个私有 git 仓库里,不靠人肉在钥匙串之间导来导去。
CI 上 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 在国内可达性存疑
原方案写的是 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)。
- 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
--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'