Chuyển đến nội dung chính

Flutter flavors: one codebase, three apps, zero copy-pasted config

The bad version of environment handling looks like this:

const bool isProd = false;
final apiBase = isProd
    ? 'https://api.example.com'
    : 'https://staging.api.example.com';

It works until someone ships with the flag flipped the wrong way, or until QA needs staging and production installed side by side and discovers both have the same bundle id. Flavors fix both problems at the build layer: three separate installable apps, each compiled with its own configuration baked in.

The Dart side, first

Start here because it is the part that determines everything else.

// lib/config/app_config.dart
enum Flavor { dev, staging, prod }

final class AppConfig {
  const AppConfig._({
    required this.flavor,
    required this.apiBase,
    required this.appName,
  });

  final Flavor flavor;
  final String apiBase;
  final String appName;

  static const _flavorName = String.fromEnvironment(
    'FLAVOR',
    defaultValue: 'dev',
  );

  static final AppConfig current = AppConfig._(
    flavor: Flavor.values.byName(_flavorName),
    apiBase: const String.fromEnvironment('API_BASE'),
    appName: const String.fromEnvironment('APP_NAME', defaultValue: 'App Dev'),
  );

  bool get isProduction => flavor == Flavor.prod;
}

String.fromEnvironment must be const and must be read from a const context — that is what lets the compiler tree-shake branches away. Writing String.fromEnvironment(name) with a runtime name variable silently returns the default value, which is a genuinely nasty bug because it looks correct.

Values come from --dart-define, and for anything more than two of them, from a file:

// config/dev.json
{
  "FLAVOR": "dev",
  "API_BASE": "https://dev.api.example.com",
  "APP_NAME": "MyApp Dev"
}
flutter run --flavor dev --dart-define-from-file=config/dev.json

These JSON files are build configuration, not a secret store. Everything in them is embedded in the binary and extractable. API base URLs, feature flags and app names are fine; signing keys and API secrets are not — that is a separate problem with a separate answer.

Android

android/app/build.gradle.kts:

android {
    flavorDimensions += "env"

    productFlavors {
        create("dev") {
            dimension = "env"
            applicationIdSuffix = ".dev"
            resValue("string", "app_name", "MyApp Dev")
        }
        create("staging") {
            dimension = "env"
            applicationIdSuffix = ".staging"
            resValue("string", "app_name", "MyApp Staging")
        }
        create("prod") {
            dimension = "env"
            resValue("string", "app_name", "MyApp")
        }
    }
}

Then make the manifest use it, in android/app/src/main/AndroidManifest.xml:

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

applicationIdSuffix is what makes side-by-side installation work — com.example.myapp.dev and com.example.myapp are different apps to Android. Note that prod has no suffix; the production id must stay exactly what the Play Store already knows.

Per-flavor icons and Firebase config go in flavor-specific source sets, which Gradle merges automatically:

android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png
android/app/src/dev/google-services.json
android/app/src/prod/google-services.json

The google-services.json placement catches people out: a single file at android/app/ applies to all flavors and will have the wrong package name for the suffixed ones, producing a Firebase initialisation failure that reads as a networking error.

iOS

iOS is the fiddlier half because Xcode’s model is schemes plus build configurations, and Flutter expects a specific naming pattern.

For each flavor you need three build configurationsDebug-dev, Release-dev, Profile-dev, and the same for staging and prod. Flutter’s tooling looks for exactly <Mode>-<flavor>; a configuration named dev-Debug will not be found, and the error message is not obvious about why.

In Xcode:

  1. Duplicate the existing Debug/Release/Profile configurations, renaming with the -dev, -staging, -prod suffixes.
  2. Create a scheme per flavor, pointing each at its matching configurations.
  3. Set PRODUCT_BUNDLE_IDENTIFIER per configuration — for example com.example.myapp.dev.
  4. Set PRODUCT_NAME or the Info.plist CFBundleDisplayName per configuration for the home-screen label.

A user-defined build setting keeps the Firebase file selection tidy. Add FIREBASE_CONFIG_DIR per configuration, then a run-script build phase:

cp "${SRCROOT}/config/${FIREBASE_CONFIG_DIR}/GoogleService-Info.plist" \
   "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/GoogleService-Info.plist"

Everything above is Xcode project state, which lives in project.pbxproj — a file that merges badly. Do flavor setup in one commit, by one person, and review the diff rather than trusting it.

Tying it together

A .vscode/launch.json so nobody has to remember the flags:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "dev",
      "request": "launch",
      "type": "dart",
      "args": [
        "--flavor", "dev",
        "--dart-define-from-file", "config/dev.json"
      ]
    },
    {
      "name": "prod",
      "request": "launch",
      "type": "dart",
      "flutterMode": "release",
      "args": [
        "--flavor", "prod",
        "--dart-define-from-file", "config/prod.json"
      ]
    }
  ]
}

And in CI, the flavor becomes a matrix axis:

      - run: |
          flutter build appbundle \
            --flavor ${{ matrix.flavor }} \
            --dart-define-from-file=config/${{ matrix.flavor }}.json

The guardrail worth adding

The failure mode flavors do not prevent by themselves is shipping a dev build to production users. Add an assertion that runs at startup:

void main() {
  final config = AppConfig.current;

  assert(() {
    // Only runs in debug/profile; in release this whole block is removed.
    debugPrint('Running flavor: ${config.flavor.name}${config.apiBase}');
    return true;
  }());

  if (config.isProduction && config.apiBase.contains('staging')) {
    throw StateError('Production flavor pointed at a staging endpoint');
  }

  runApp(MyApp(config: config));
}

The assert(() { ... }()) idiom is worth knowing generally: the closure runs only when assertions are enabled, so debug-only logging costs nothing in release. The second check is a real runtime guard, deliberately not an assert, because it is the one that must fire in a release build.

Making the flavor visible in the UI helps too — a coloured banner in non-production builds is a one-line Banner widget and eliminates a whole category of “wait, which environment am I testing?” confusion.

FAQ

Do I need flavors if I only have dev and prod?

If the two ever need to be installed at once, or need different Firebase projects, yes. If dev is only ever flutter run on your machine, --dart-define alone is enough.

Why does --flavor fail with “no flavor named X”?

Android and iOS have separate flavor definitions and both must know the name. Missing productFlavors on Android or missing schemes on iOS each produce this, from different halves of the build.

Can I put secrets in the dart-define file?

No. Anything passed via --dart-define is in the compiled binary. Use platform secure storage for user-scoped secrets, and a backend for anything that must remain server-side.

How do I handle per-flavor app icons on iOS?

Multiple asset catalogs, with ASSETCATALOG_COMPILER_APPICON_NAME set per build configuration. This is cleaner than a script that swaps files at build time.

Does the flavor name reach native code?

Not automatically. On Android, BuildConfig.FLAVOR is available; on iOS, read the bundle identifier or a per-configuration Info.plist key. Do not assume the Dart-side value is visible to platform code.


The flavor mechanism, Gradle productFlavors, Xcode configuration naming requirement, and String.fromEnvironment semantics described here are documented in the references linked above. The Dart config class shape, the production-endpoint guard, the assert(() {}()) logging idiom, and the warning about project.pbxproj merges are my own judgement from setting this up on several projects. Gradle and Xcode syntax shift between versions — verify against your current templates.


Originally published on FlutterCook. Read the latest version there — that copy is the one kept up to date.

Nhận xét

Bài đăng phổ biến từ blog này

5 concepts every Flutter dev should know

  Phụ lục: State management architecture Testing IDE Shortcuts Platform channel Maintaining a project Tôi đã làm việc với Flagship trong một thời gian dài, và đây là những điều mà tôi phát hiện ra là điều cần phải có đối với bất kỳ nhà phát triển Flagship nào, về tổng thể nó sẽ khiến bạn trở thành một nhà phát triển Flagship giỏi trong thời gian dài. 1. State management architecture Đây là một trong những chủ đề quan trọng nhất trong cộng đồng thiết bị rung, nó khá quan trọng nếu bạn muốn duy trì một dự án rung kích thước trung bình hoặc lớn. Nó sẽ giúp tạo một dự án suôn sẻ và thêm các tính năng mới một cách hoàn hảo.  2. Testing Đây là một chủ đề duy nhất mà tôi không hiểu tại sao nó lại quan trọng trước đó trong sự nghiệp của tôi, nhưng khi tôi tiến lên trong sự nghiệp của mình và có kinh nghiệm với nhiều dự án và vấn đề xảy ra trong môi trường sản xuất. Tôi đã nhận ra một cách khó khăn, tại sao điều này lại quan trọng như vậy. Nếu bạn vẫn muốn có thêm lý do để cân nhắc thử...

Thiết kế giao diện với DotNetBar (Phần 1)

Đây là phiên bản DotNetBar hỗ trợ C# và Visual Basic https://www.dropbox.com/s/wx80jpvgnlrmtux/DotNetBar.rar  , phiên bản này hỗ trợ giao diện Metro cực kỳ “dễ thương” Các bạn load về và cài đặt, khi cài đặt xong sẽ có source code mẫu của tất cả các control. Để sử dụng được các control của DotNetBar các bạn nhớ add item vào controls box. Thiết kế giao diện với DotNetBar, giao diện sẽ rất đẹp. Link các video hướng dẫn chi tiết cách sử dụng và coding: http://www.devcomponents.com/dotnetbar/movies.aspx Hiện tại DotNetBar có rất nhiều công cụ cực mạnh, trong đó có 3 công cụ dưới đây: DotNetBar for Windows Forms Requires with Visual Studio 2003, 2005, 2008, 2010 or 2012.   DotNetBar for WPF Requires with Visual Studio 2010 or 2012 and Windows Presentation Foundation.   DotNetBar for Silverlight Requires with Visual Studio 2010 or 2012 and Silverlight. Dưới đây là một số hình ảnh về các control trong DotnetBar.   Metro User Interface  controls with Metro Tiles, toolba...

Announcing Flutter 2

  Phụ lục: Flutter on the web Flutter 2 on desktops, foldables, and embedded devices The growing Flutter ecosystem Dart: The secret sauce behind Flutter Flutter 2: Available now Hôm nay, chúng tôi sẽ công bố Flutter 2: một bản nâng cấp lớn cho Flutter cho phép các nhà phát triển tạo các ứng dụng đẹp, nhanh chóng và di động cho bất kỳ nền tảng nào. Với Flutter 2, bạn có thể sử dụng cùng một cơ sở mã để gửi các ứng dụng gốc cho năm hệ điều hành: IOS, Android, Windows, macOS và Linux; cũng như trải nghiệm web nhắm mục tiêu các trình duyệt như Chrome, Firefox, Safari hoặc Edge. Flutter thậm chí có thể được nhúng vào ô tô, TV và thiết bị gia dụng thông minh, mang đến trải nghiệm di động và lan tỏa nhất cho thế giới điện toán xung quanh. Mục tiêu của chúng tôi là thay đổi cơ bản cách các nhà phát triển nghĩ về việc xây dựng ứng dụng, bắt đầu không phải với nền tảng bạn đang nhắm mục tiêu mà là với trải nghiệm bạn muốn tạo. Flutter cho phép bạn tạo ra những trải nghiệm tuyệt đẹp trong đó ...