Files
conti-docs/flutter-app/08-build-flavors.md
T

18 KiB
Raw 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 走单独的 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.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 \
  --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 mappingR8)和 iOS dSYM 照旧归档,并在 build 之后上传 Bugly(Bugly 提供符号表上传的命令行工具)。原生侧的自动符号化是 Bugly 的强项,这一条不受上面那个 Dart 决策影响。

版本号规则

字段 来源 示例
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 构建:远程 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 各家应用市场是否都要上,各家的加固/隐私合规要求不同)。

参考链接

附录: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
        --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'