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

17 KiB
Raw Permalink Blame History

08. 多环境构建

决策

App 侧维护 3 个 flavordev / 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.mdappEnvProvider)通过 --dart-define-from-file=env/{flavor}.json 注入,不写死在代码里、也不用 if (flavor == 'dev') 这种运行时字符串判断来分支配置。
  • env/*.json 只包含非敏感配置(API 地址等);密钥类配置(如第三方 SDK App Key)通过 CI 变量在构建时注入,不提交进仓库。
  • Android 侧用 Gradle productFlavors 区分 applicationIdSuffix/图标/versionNameSuffixiOS 侧用对应的 xcconfig + Scheme 区分 Bundle Identifier/图标;两端 flavor 名称必须完全一致(dev/uat/prod),不允许两端用不同命名。
  • CI 流水线阶段固定为:melos run analyzemelos run test → 按 flavor flutter build apk/ipa --flavor {flavor} --dart-define-from-file=env/{flavor}.json → 上传对应分发渠道。prod flavor 的构建触发条件是打 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 会让 applicationIdKotlin 源码的 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.01.4.0
versionCode / CFBundleVersion CI pipeline ID(单调递增) $CI_PIPELINE_ID48213

要点:

  • 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 变量(类型选 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 管理,存在一个私有 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:// 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)。
  • Android 上架渠道清单(华为/小米/OPPO/vivo 各家应用市场是否都要上,各家的加固/隐私合规要求不同)。

参考链接

附录: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 + xcconfigiOS 没有 Gradle 那样的单文件配置,而是通过 Xcode 里多个 Build Configuration(对应不同的 .xcconfig 文件设置 Bundle ID 等)+ 多个 Scheme 组合实现同样的效果,flutter build ipa --flavor xxx 背后就是选中同名 Scheme。
  3. Dart 入口文件(main_xxx.dartflavor 决定的是"编译出什么样的原生外壳",Dart 代码本身默认只有一个 main.dart 入口——项目约定用多个入口文件对应各 flavor,让每个 flavor 能设置不同的启动参数(比如传给 bootstrap() 一个环境枚举)。
  4. --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 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'