适用场景:从单一包名走向可发布的多环境 Android Flutter 项目

很多 Flutter 项目早期只有一个 main.dart、一个接口地址和一个 Android 包名。开发联调时临时改 API,测试要包时再改回来,发布前再手工检查一次。这种方式在原型阶段可以凑合,但只要项目进入持续迭代,就会出现三个稳定问题:测试包覆盖正式包、日志级别打开后忘记关闭、CI 构建命令和本机构建命令不一致。本文面向已经能运行 Flutter Android 项目、已经写过网络请求和本地存储的团队,继续补上“环境分层”这一层工程能力。

本文选择的方案是:Android 原生 productFlavors 负责包名、应用名、图标和签名边界;Flutter 的 --dart-define-from-file 负责 API 地址、日志开关、埋点开关和功能开关;CI 用固定命令构建 dev、staging、prod 三套包。这个主题和站内已有的 Android/Kotlin 离线优先同步:Room、WorkManager 与冲突处理清单 可以衔接:离线同步解决数据一致性,多环境配置解决“同一套代码如何稳定进入不同验证阶段”。

官方 Flutter 文档把 Android flavor 对应到 Android 的 product flavor,并给出 flutter run --flavorflutter build apk --flavor 等命令;Dart 编译环境变量则通过 String.fromEnvironment 在编译期读取。实际项目不要把两者混成一个概念:flavor 更适合原生层差异,dart-define 更适合 Dart 层配置。分清边界,后续排查日志和构建问题会轻很多。

技术取舍:哪些放 flavor,哪些放 dart-define

先定规则,再写配置。放进 Android flavor 的内容通常有四类:applicationIdSuffixresValue 应用名、不同环境图标、签名配置。它们属于 Android 包、安装、商店和系统展示层面的差异,改错会直接影响安装和发布。放进 --dart-define-from-file 的内容也有四类:API_BASE_URLLOG_LEVELENABLE_MOCKSENTRY_DSN 或其他监控开关。它们属于 Dart 运行逻辑,适合用 JSON 文件随构建命令注入。

不要把敏感密钥当成安全秘密塞进 dart-define。编译进客户端的内容都可能被逆向看到,适合放“环境选择”和“公开配置”,不适合放后台管理 token、支付私钥、云服务主密钥。真正的秘密应该由服务端保管,客户端只拿短期会话、公开 key 或受限 token。

目录建议保持克制:

app/
  lib/
    main.dart
    bootstrap.dart
    core/config/app_config.dart
    core/logging/app_logger.dart
  env/
    dev.json
    staging.json
    prod.json
  android/app/build.gradle
  .github/workflows/android.yml

这套结构的目标不是“看起来专业”,而是让新成员看到文件名就知道:原生包名在 android/app/build.gradle,Dart 配置在 env/*.json,启动组装在 bootstrap.dart,日志收口在 app_logger.dart

步骤一:在 Android Gradle 中配置 flavor

下面以 Groovy 版 build.gradle 为例。如果项目已经迁移到 Kotlin DSL,字段名字基本一致,只是写法不同。重点是 flavorDimensionsproductFlavorsapplicationIdSuffixresValue

android {
    namespace "com.stepnex.shop"
    compileSdk flutter.compileSdkVersion

    defaultConfig {
        applicationId "com.stepnex.shop"
        minSdk flutter.minSdkVersion
        targetSdk flutter.targetSdkVersion
        versionCode flutter.versionCode
        versionName flutter.versionName
    }

    flavorDimensions "env"

    productFlavors {
        dev {
            dimension "env"
            applicationIdSuffix ".dev"
            resValue "string", "app_name", "Stepnex Dev"
        }
        staging {
            dimension "env"
            applicationIdSuffix ".staging"
            resValue "string", "app_name", "Stepnex Staging"
        }
        prod {
            dimension "env"
            resValue "string", "app_name", "Stepnex"
        }
    }
}

同时把 android/app/src/main/AndroidManifest.xml 的应用名改成资源引用:

<application
    android:label="@string/app_name"
    android:icon="@mipmap/ic_launcher">
</application>

执行命令验证三种 flavor 是否能独立安装:

flutter clean
flutter pub get
flutter run --flavor dev --dart-define-from-file=env/dev.json
flutter build apk --flavor staging --dart-define-from-file=env/staging.json
flutter build appbundle --flavor prod --dart-define-from-file=env/prod.json

这里的验证方式很具体:Android 手机上 dev 包和 staging 包应该能与正式包并存;应用列表里名称应能区分;adb shell pm list packages | grep stepnex 能看到不同包名;如果 prod 包名没有后缀,说明正式包边界正确。

步骤二:用 JSON 文件管理 Dart 层配置

env/dev.jsonenv/staging.jsonenv/prod.json 中只放构建期公开配置。示例:

{
  "APP_ENV": "staging",
  "API_BASE_URL": "https://staging-api.example.com",
  "LOG_LEVEL": "debug",
  "ENABLE_MOCK": "false",
  "ENABLE_VERBOSE_HTTP_LOG": "true"
}

然后用 String.fromEnvironment 建立单一入口。注意必须是 const,否则它就不是编译期环境读取。

enum AppEnv { dev, staging, prod }

class AppConfig {
  const AppConfig({
    required this.env,
    required this.apiBaseUrl,
    required this.logLevel,
    required this.enableMock,
    required this.enableVerboseHttpLog,
  });

  final AppEnv env;
  final Uri apiBaseUrl;
  final String logLevel;
  final bool enableMock;
  final bool enableVerboseHttpLog;

  static const _envName = String.fromEnvironment('APP_ENV', defaultValue: 'dev');
  static const _apiBaseUrl = String.fromEnvironment('API_BASE_URL');
  static const _logLevel = String.fromEnvironment('LOG_LEVEL', defaultValue: 'info');
  static const _enableMock = bool.fromEnvironment('ENABLE_MOCK');
  static const _verboseHttp = bool.fromEnvironment('ENABLE_VERBOSE_HTTP_LOG');

  factory AppConfig.fromBuildEnv() {
    if (_apiBaseUrl.isEmpty) {
      throw StateError('Missing API_BASE_URL. Check --dart-define-from-file.');
    }
    final env = switch (_envName) {
      'prod' => AppEnv.prod,
      'staging' => AppEnv.staging,
      _ => AppEnv.dev,
    };
    return AppConfig(
      env: env,
      apiBaseUrl: Uri.parse(_apiBaseUrl),
      logLevel: _logLevel,
      enableMock: _enableMock,
      enableVerboseHttpLog: _verboseHttp,
    );
  }
}

bootstrap.dart 负责把配置传给网络层、日志层和状态管理容器:

Future<void> bootstrap() async {
  WidgetsFlutterBinding.ensureInitialized();
  final config = AppConfig.fromBuildEnv();
  final logger = AppLogger(level: config.logLevel);
  logger.info('app_env=${config.env.name} api_host=${config.apiBaseUrl.host}');
  runApp(AppRoot(config: config, logger: logger));
}

日志只打印环境名和 host,不打印完整 token、用户信息或请求体。生产包建议只保留必要错误日志,调试包可以打开更详细的网络日志。

步骤三:把本机命令收口成脚本

团队里最常见的坑不是 Gradle 配错,而是每个人手里的命令不同。建议增加 tool/build_android.ps1 或 Makefile,把命令固化。Windows 开发者可以这样写 PowerShell:

param(
  [ValidateSet('dev','staging','prod')]
  [string]$Env = 'dev',
  [ValidateSet('apk','appbundle')]
  [string]$Target = 'apk'
)

$defineFile = "env/$Env.json"
if (-not (Test-Path $defineFile)) {
  throw "Missing define file: $defineFile"
}

flutter pub get
flutter test
flutter build $Target --flavor $Env --dart-define-from-file=$defineFile

这样测试同学要 staging 包时,开发只需要执行:

./tool/build_android.ps1 -Env staging -Target apk

发布正式包时执行:

./tool/build_android.ps1 -Env prod -Target appbundle

验证方式包括三项:命令能在干净工作区运行;构建产物路径可预测;脚本失败时能明确指出缺失的是 JSON 文件、测试失败还是 Flutter 构建失败。

步骤四:CI 中固定产物和日志

CI 不要重新发明一套命令,应该调用同一套脚本。以 GitHub Actions 为例:

name: android-build
on:
  workflow_dispatch:
    inputs:
      env:
        type: choice
        options: [dev, staging, prod]
        default: staging

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          channel: stable
      - run: flutter --version
      - run: flutter pub get
      - run: flutter analyze
      - run: flutter test
      - run: flutter build apk --flavor ${{ inputs.env }} --dart-define-from-file=env/${{ inputs.env }}.json
      - uses: actions/upload-artifact@v4
        with:
          name: android-${{ inputs.env }}-apk
          path: build/app/outputs/flutter-apk/*.apk

如果正式包需要签名,签名文件和密码必须走 CI secret,不要提交到仓库。CI 日志里需要保留 flutter --version、构建 flavor、产物名称和测试结果,方便回溯“某个包到底用哪个环境构建”。

避坑点:多环境配置最容易错在边界

第一,不要让 dev、staging、prod 共用同一个包名,否则测试包可能覆盖正式包。第二,不要把正式环境的 LOG_LEVEL 留成 debug,尤其是网络拦截器会打印请求头时。第三,不要让 mock 开关进入正式包,ENABLE_MOCK 应该在启动时和构建日志里都可见。第四,不要把 flavor 当成权限绕过工具,登录态、权限和服务端数据隔离仍然要靠后端环境与账号体系。

第五,不要在正文或日志里输出完整配置。排查时打印 envhostbuild number 足够,多余字段会变成泄漏风险。第六,不要只测 flutter run,还要测 flutter build appbundle,因为发布包的混淆、资源收缩、签名和 target SDK 检查都只会在发布链路里暴露。第七,Android Studio 的 Run Configuration 也要同步配置 --flavor--dart-define-from-file,否则 IDE 启动和命令行启动会表现不一致。

验证清单:发布前看这些信号

  • flutter analyzeflutter test 通过。
  • flutter run --flavor dev --dart-define-from-file=env/dev.json 能启动,并显示 dev 应用名。
  • staging 包与 prod 包能同时安装,包名不同。
  • prod 的应用名、图标、包名、签名和版本号符合发布预期。
  • 启动日志只出现环境、host 和构建号,不出现 token、用户隐私或完整请求头。
  • API 请求实际打到对应环境,可以用代理、服务端 access log 或测试账号验证。
  • CI 产物名称包含环境,构建日志包含 Flutter 版本和 flavor。
  • 缺失 API_BASE_URL 时应用启动失败并给出明确错误,而不是默默打到默认地址。

复盘清单:每次环境事故后怎么收口

如果出现“测试包连到正式环境”或“正式包打开了调试日志”,不要只修当前 JSON。复盘时按四层记录:触发命令是什么,使用的 flavor 是什么,加载的 env/*.json 是什么,启动日志里打印的环境是什么。四层里任何一层缺证据,下一次还会靠猜。

复盘后至少做三件事:把命令收口到脚本,把启动日志固定为一行可搜索格式,把 CI 产物命名改成包含环境和提交号。对于正式包,再加一条发布前检查:从安装包启动一次,截图应用名和关于页版本号,服务端日志确认请求来自正式域名。多环境配置不是为了复杂,而是为了让每一次测试、验收和发布都能被验证、被回滚、被复盘。

落地顺序:先让团队少犯错,再追求自动化

如果项目已经在迭代中,不建议一次性把 flavor、签名、CI、日志和监控全部改完。更稳妥的步骤是分三次落地。第一天只做 dev 和 prod 包名拆分,确认测试包不会覆盖正式包;第二天接入 env/*.json,让 API 地址和日志级别从代码里移出去;第三天才把 CI 构建和产物上传补齐。每一步结束都要保留命令输出、安装截图和一段服务端 access log,这些证据比口头确认更可靠。

多人协作时还要给代码评审增加两条检查:凡是修改 env/prod.json,必须说明为什么影响正式包;凡是修改 android/app/build.gradle 的 flavor 配置,必须附带本机或 CI 的构建命令。这样评审者不用猜改动意图,也能及时发现“staging 指向正式 API”“prod 打开详细日志”这类问题。等流程稳定后,再考虑把应用图标、渠道号、灰度开关和崩溃上报环境继续纳入同一套配置体系。

发布前人工检查也要固定成表格,而不是靠记忆。建议记录构建人、提交号、构建命令、产物文件名、安装设备、登录账号类型、首页接口 host、关键业务接口 host、日志级别和回滚包位置。正式包只要有一项说不清,就先不要进入商店上传或灰度分发。这个表格不需要复杂系统,最初放在发布单、Issue 或团队文档里就可以,关键是让每个环境差异都有可追溯证据。

责任也要拆清:开发负责构建命令和配置文件,测试负责安装与接口验证,运维或后端负责服务端日志确认,产品负责确认展示名称和版本信息。边界清楚,问题出现时才能快速定位,减少返工。