# 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](./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 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 里通过变量注入: ```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 < 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 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' ```