多环境构建与 Flavors 配置

Flutter 多环境构建完整方案:Android productFlavors 与 iOS Schemes/Xcconfig 配置、dart-define 与 --flavor 参数、编译期常量注入、多环境图标与应用名、签名与密钥隔离、CI 中的 flavor 矩阵构建实践。

开发、测试、预发、生产——一个正经的 App 至少要面对两套后端地址,成熟的产品往往有四到五套。如果这些环境靠「提交前手动改常量、打包前记得切回来」来管理,出事只是时间问题:某次发版把测试环境的 API 地址带上了生产,或者测试包和正式包因为签名相同而无法共存安装。多环境构建(Build Flavors)就是把这些差异从「人的记忆」搬到「构建系统」里。

Flutter 的 flavor 机制横跨三层:Dart 层的编译期常量、Android 层的 Gradle productFlavors、iOS 层的 Xcode Scheme + xcconfig。三层必须对齐,否则会出现「Android 打的是 staging 包,iOS 打的是 prod 包」这种隐蔽事故。本文逐层拆解配置方式,讲清 --dart-define 与 --flavor 的配合、多环境图标与签名隔离,以及如何在 CI 里跑 flavor 矩阵。这类「构建配置即代码」的思路,与 特性开关(Feature Flags) 的运行时差异化可以互补使用。

一、环境差异的三种注入方式

在动手配置之前,先想清楚「环境差异」到底要注入什么。它通常分三类,对应的技术手段完全不同:

差异类型举例注入方式生效时机
编译期常量API base URL、日志级别、埋点开关--dart-define / String.fromEnvironment编译时确定,可被 tree-shaking
原生层配置applicationId 后缀、应用名、图标、签名Gradle flavor / iOS xcconfig打包时确定
运行时开关灰度功能、AB 实验远程配置 + feature flag运行时可改

关键认知:编译期常量优先。能用 --dart-define 表达的,就不要塞进运行时读取的 JSON 文件——前者在编译期就被内联,未使用的分支会被 Dart 编译器摇树优化掉,既省包体又不会泄漏。下面从 Dart 层讲起。

二、Dart 层:dart-define 与编译期常量

Flutter 通过 --dart-define 在编译期注入键值对,Dart 侧用 String.fromEnvironment 读取:

// lib/config/app_config.dart
class AppConfig {
  static const String apiBaseUrl = String.fromEnvironment(
    'API_BASE_URL',
    defaultValue: 'https://api.dev.example.com',
  );

  static const String environment = String.fromEnvironment(
    'ENVIRONMENT',
    defaultValue: 'dev',
  );

  static const bool enableLogging = bool.fromEnvironment(
    'ENABLE_LOGGING',
    defaultValue: true,
  );

  static bool get isProd => environment == 'prod';
}

构建时注入:

flutter run --dart-define=API_BASE_URL=https://api.staging.example.com \
            --dart-define=ENVIRONMENT=staging \
            --dart-define=ENABLE_LOGGING=true

flutter build apk --dart-define=ENVIRONMENT=prod \
                  --dart-define=API_BASE_URL=https://api.example.com \
                  --dart-define=ENABLE_LOGGING=false

参数一多就难维护。Flutter 3.7+ 支持从文件读取,把每个环境的变量集中管理:

// config/staging.json
{
  "API_BASE_URL": "https://api.staging.example.com",
  "ENVIRONMENT": "staging",
  "ENABLE_LOGGING": "true"
}
flutter build ipa --dart-define-from-file=config/staging.json

注意 --dart-define-from-file 的值会被统一转成字符串,数字和布尔要从字符串再解析。所有环境文件里必须保持键名完全一致,缺一个键就会回退到 defaultValue——这是最容易埋雷的地方,建议在 CI 里加一条校验脚本比对各环境文件的键集合。

# scripts/check_env_keys.py — CI 里校验各环境文件键集合一致
import json, glob, sys

keysets = {f: set(json.load(open(f, encoding='utf-8')).keys())
           for f in sorted(glob.glob('config/*.json'))}
base = next(iter(keysets.values()))
for f, ks in keysets.items():
    if ks != base:
        print(f'{f} 键集合不一致: 缺 {base - ks} / 多 {ks - base}')
        sys.exit(1)

--dart-define 只影响 Dart 层,无法改变原生层行为(比如 applicationId),所以它必须与 flavor 配合使用,而不是替代 flavor。

三、Android 层:productFlavors 配置

Android 侧的环境差异由 Gradle 的 productFlavors 定义。打开 android/app/build.gradle(或 .kts):

// android/app/build.gradle
android {
    namespace "com.example.myapp"
    compileSdk 34

    defaultConfig {
        applicationId "com.example.myapp"
        minSdk 21
        targetSdk 34
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
    }

    flavorDimensions "environment"
    productFlavors {
        dev {
            dimension "environment"
            applicationIdSuffix ".dev"      // 与正式包共存
            versionNameSuffix "-dev"
            resValue "string", "app_name", "MyApp Dev"
        }
        staging {
            dimension "environment"
            applicationIdSuffix ".staging"
            versionNameSuffix "-staging"
            resValue "string", "app_name", "MyApp Staging"
        }
        prod {
            dimension "environment"
            // 正式包不加后缀,保持干净
            resValue "string", "app_name", "MyApp"
        }
    }

    buildTypes {
        release {
            signingConfig signingConfigs.release
        }
    }
}

几个必须理解的点:

  • flavorDimensions 是 flavor 的分类维度。你可能有「环境」(dev/staging/prod)和「渠道」(googlePlay/appStore)两个维度,组合后就是 devGooglePlay、prodAppStore 等。维度顺序影响构建变体命名,务必固定。
  • applicationIdSuffix 让不同环境的应用能同时安装在一台设备上——测试同事装 .dev 包和 .staging 包互不覆盖。代价是每个环境的推送证书、OAuth 回调、深链域名都要分别注册。
  • resValue 把应用名写成资源,Dart 侧无法直接读,但原生启动器图标下的名字会跟着变。

Flutter 构建时用 --flavor 指定:

flutter build apk --flavor dev --dart-define-from-file=config/dev.json
flutter build appbundle --flavor prod --dart-define-from-file=config/prod.json
flutter run --flavor staging --dart-define-from-file=config/staging.json

--flavor 的名字必须与 Gradle 里的 flavor 名完全一致(大小写敏感)。写错时 Flutter 会报 Could not find flavor。

当「环境」与「渠道」两个维度组合时,变体数量是乘积。3 个环境 × 2 个渠道 = 6 个变体,每个变体都要单独配置签名与资源。因此维度不是越多越好——只对真正需要差异化的维度建 flavor,其余用 --dart-define 或远程配置表达。

四、iOS 层:Scheme、xcconfig 与多目标

iOS 没有 Gradle 那样的 flavor 概念,它的等价物是「多个 Xcode Scheme + 多个 xcconfig 文件 + 多个 Build Configuration」。手工配置步骤繁琐,用 Xcode 打开 ios/Runner.xcworkspace:

  1. 在 Project → Info → Configurations 里复制 Debug/Release 为 Debug-dev/Release-dev、Debug-prod/Release-prod。
  2. 在 ios/Flutter/ 下创建 Debug-dev.xcconfig、Release-prod.xcconfig 等文件。
  3. 在 Build Settings 里为每个 Configuration 指定对应的 xcconfig。
  4. 在 Product → Scheme → Manage Schemes 里复制 Scheme,为每个 Scheme 绑定 Build Configuration。

xcconfig 文件里可以定义构建变量,再通过 Info.plist 引用:

// ios/Flutter/Debug-dev.xcconfig
#include "Debug.xcconfig"
BUNDLE_ID_SUFFIX = .dev
APP_DISPLAY_NAME = MyApp Dev
API_BASE_URL = https:/$()/api.dev.example.com
// ios/Flutter/Release-prod.xcconfig
#include "Release.xcconfig"
BUNDLE_ID_SUFFIX =
APP_DISPLAY_NAME = MyApp
API_BASE_URL = https:/$()/api.example.com

注意 https:/$()/ 这个写法——xcconfig 里 // 会被当成注释,必须用 $() 隔开。这是 iOS 多环境配置最经典的坑。

Bundle Identifier 的后缀通过 PRODUCT_BUNDLE_IDENTIFIER 引用 $(BUNDLE_ID_SUFFIX),应用名通过 CFBundleDisplayName 引用 $(APP_DISPLAY_NAME)。构建时:

flutter build ipa --flavor prod --dart-define-from-file=config/prod.json

这里 --flavor 对应的是 Scheme 名(小写)。Scheme 名与 Android flavor 名建议保持同名(dev/staging/prod),减少心智负担。

iOS 侧的常见坑清单:

现象原因解决
flutter build 找不到 SchemeScheme 未共享(Shared)Manage Schemes 勾选 Shared
URL 被截断xcconfig 里 // 当注释用 $() 转义
应用名不生效Info.plist 未引用变量改用 $(APP_DISPLAY_NAME)
打包后仍连测试环境xcconfig 未绑定到对应 Configuration检查 Build Settings 的 xcconfig 引用

五、多环境图标与启动图

不同环境用不同图标,能让测试同事一眼区分装的是哪个包。Flutter 生态用 flutter_launcher_icons 按 flavor 生成:

# pubspec.yaml
flutter_launcher_icons:
  android: true
  ios: true
  image_path: "assets/icon/prod.png"
  # 每个 flavor 单独配置
  flavors:
    dev:
      image_path: "assets/icon/dev.png"
      android: true
      ios: true
    staging:
      image_path: "assets/icon/staging.png"
dart run flutter_launcher_icons

Android 侧的 flavor 图标会自动生成到 android/app/src/dev/res/mipmap-*/ 下;iOS 侧则需要手动在 Asset Catalog 里为每个 Build Configuration 指定不同的 AppIcon Set——这一步工具无法完全代劳,因为 iOS 的 AppIcon 是按 target 而非 configuration 区分的,多环境通常要建多个 Target 或依赖 Build Settings 切换。

一个更省事的替代方案:只改应用名后缀,不改图标。用 Gradle 的 resValue "string", "app_name" 和 iOS 的 CFBundleDisplayName 让名字带 [Dev] 标记,图标保持一致。对内部测试包足够了,还省去维护多套图标的成本。

启动图(Splash)同理,用 flutter_native_splash 按 flavor 生成:

flutter_native_splash:
  color: "#FFFFFF"
  image: assets/splash/splash.png
  android_12:
    image: assets/splash/splash_android12.png
    color: "#FFFFFF"

六、签名与密钥隔离

安全上最重要的一条:测试包和生产包必须用不同的签名密钥。如果共用同一个 keystore,测试包可以被覆盖安装到生产包上(只要 applicationId 相同),而如果 applicationId 不同,共用密钥又会带来密钥泄漏面扩大的风险。

Android 侧按 flavor 配置签名:

android {
    signingConfigs {
        dev {
            storeFile file("../keystore/dev.jks")
            storePassword System.getenv("DEV_STORE_PASSWORD")
            keyAlias "dev"
            keyPassword System.getenv("DEV_KEY_PASSWORD")
        }
        prod {
            storeFile file("../keystore/prod.jks")
            storePassword System.getenv("PROD_STORE_PASSWORD")
            keyAlias "prod"
            keyPassword System.getenv("PROD_KEY_PASSWORD")
        }
    }
    buildTypes {
        release {
            // 按 flavor 选择签名,而不是写死
            productFlavors.dev.signingConfig signingConfigs.dev
            productFlavors.prod.signingConfig signingConfigs.prod
        }
    }
}

密码一律从环境变量读取,绝不写进 build.gradle 或提交进仓库。CI 里通过 secrets 注入。keystore 文件本身也应放在仓库外,或加密后由 CI 临时解密。

# 用 openssl 加密 keystore 后随仓库保存(解密密钥放 CI secrets)
openssl enc -aes-256-cbc -salt -in prod.jks -out prod.jks.enc -k "$KEYSTORE_KEY"
# CI 中解密
openssl enc -d -aes-256-cbc -in prod.jks.enc -out prod.jks -k "$KEYSTORE_KEY"

iOS 侧同理:为每个环境创建独立的 Provisioning Profile 与 Distribution Certificate,生产证书只授予发布流水线。这些凭据与应用安全加固的原则一致——测试凭据可以宽松,生产凭据必须严格隔离,密钥轮换要有流程。

七、CI 中的 flavor 矩阵构建

CI 上构建多环境,用矩阵(matrix)一次跑完所有组合最高效:

# .github/workflows/build.yml(节选)
jobs:
  build:
    strategy:
      matrix:
        flavor: [dev, staging, prod]
        platform: [android, ios]
    runs-on: ${{ matrix.platform == 'ios' && 'macos-latest' || 'ubuntu-latest' }}
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: |
          flutter build ${{ matrix.platform == 'ios' && 'ipa' || 'appbundle' }} \
            --flavor ${{ matrix.flavor }} \
            --dart-define-from-file=config/${{ matrix.flavor }}.json

矩阵构建的几个实践要点:

  • flavor 与 config 文件同名,config/${flavor}.json 自动对应,减少映射错误。
  • dev/staging 可以只跑 Android,节省昂贵的 macOS runner 时间;prod 才跑 iOS。
  • 产物命名带 flavor 前缀,如 app-prod-release.aab,避免上传时混淆。
  • 构建前加一步校验各环境 config 文件的键集合一致,防止漏键回退到默认值。
  • fail-fast 关闭:fail-fast: false 让一个 flavor 失败不影响其他 flavor 继续构建,一次拿到完整结果。

产物分发可以配合 Android 发布流程 的轨道(track)机制,把 staging 包推到 internal testing 轨道、prod 包推到 production 轨道;iOS 侧则对应 App Store 发布 的 TestFlight 与正式审核。整套流程接入 https://plumephp.com/flutter-ci-cd/ 后,一次提交可以自动产出所有环境的可分发包。

一个完整的构建脚本,把「校验 → 构建 → 上传符号 → 分发」串成一条命令:

#!/usr/bin/env bash
set -euo pipefail

FLAVOR="${1:?用法: ./build.sh <dev|staging|prod>}"
CONFIG="config/${FLAVOR}.json"

# 1. 校验环境文件存在且键集合一致
python3 scripts/check_env_keys.py

# 2. 构建(带符号剥离,便于崩溃还原)
flutter build appbundle \
  --flavor "${FLAVOR}" \
  --dart-define-from-file="${CONFIG}" \
  --obfuscate \
  --split-debug-info=build/symbols/${FLAVOR}

# 3. 归档符号(崩溃还原必需)
mkdir -p build/symbols-archive && \
  cp -r build/symbols/${FLAVOR} build/symbols-archive/

# 4. 重命名产物,带 flavor 前缀
mv build/app/outputs/bundle/${FLAVOR}Release/app-${FLAVOR}-release.aab \
   build/app/outputs/bundle/app-${FLAVOR}-release.aab

echo "构建完成: app-${FLAVOR}-release.aab"

这套脚本把 flavor 差异收敛到「一个参数 + 一个 config 文件」,任何人执行 ./build.sh prod 都能得到一致的产物,杜绝了「凭记忆改常量」的事故。

常见故障排查表:

报错原因解决
Could not find flavorflavor 名拼写/大小写不符与 Gradle/Scheme 名逐字核对
No matching variantGradle 变体名组合错误检查 flavorDimensions 顺序
iOS 打包后连测试环境xcconfig 未绑定检查 Build Settings
配置回退到默认值config 文件缺键跑 check_env_keys 脚本

小结

多环境构建的复杂度来自「三层对齐」:Dart 层管编译期常量(--dart-define / --dart-define-from-file),Android 层管 flavor 与签名(productFlavors + applicationIdSuffix),iOS 层管 Scheme 与 xcconfig。三条实用准则:能用编译期常量就不要用运行时读取(可摇树、不泄漏);测试与生产必须用不同签名密钥(凭据走环境变量、keystore 加密保存);flavor 名在 Dart、Gradle、Scheme 三处保持同名。先把 dev/staging/prod 三套跑通,再考虑加「渠道」维度,否则 flavor 组合爆炸会让 CI 时间失控。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. 崩溃监控与线上可观测性
  2. 蓝牙 BLE 与外设集成
  3. 包体积与启动优化