Flutter 環境分けの手法選定を考察してみた ~ --dart-define(-from-file) でネイティブ連携しない理由を添えて ~
概要
Flutterプロジェクトの環境分けの実装は様々です。
--flavorのみで環境分けしているプロジェクト--flavor+--dart-defineまたは--dart-define-from-fileを組み合わせているプロジェクトflutter_dotenvやenviedを使っているプロジェクト
それぞれの手法について、選定基準を言語化できるほど自分が理解できていなかったため、改めて調べてみました。
なお、この記事では各手法の選定基準と役割の整理を目的としており、各環境分けのセットアップ手順・実装手順は扱いません。
伝えたいこと
- 各手法(
--flavor/--dart-define/--dart-define-from-file/flutter_dotenv/envied)の役割と選定基準 --dart-define/--dart-define-from-fileでネイティブ連携するのは誤用・ハックであること
解決したい課題
「Flutterプロジェクトの環境分けはどの手法が良いの?」という疑問を持っている方に向けて、この記事で整理します。
課題の原因
Flutterの環境分けが難しい理由は主に2つです。
1. 選択肢が多い
--flavor、--dart-define、--dart-define-from-file、flutter_dotenv、envied と、環境分けを実現する手段が複数存在します。それぞれ異なる仕組みで動作するため、どれを使うべきか判断しにくいです。
2. クロスプラットフォームゆえのネイティブ連携が複雑
Flutterはクロスプラットフォームです。「Dartコードで使う値」「ネイティブ側(アプリID・アプリ名・Firebase設定)で使う値」を別々に管理する必要があり、これが混乱の元になります。
各手法の役割
まず大きく2つに分類できます。
| 分類 | 手法 |
|---|---|
| ネイティブ側(アプリID・アプリ名・Firebase設定ファイルなど) | --flavor |
| Dartコード側(APIのベースURL・フラグなど) | --flavor / --dart-define / --dart-define-from-file / flutter_dotenv / envied |
ネイティブ側とDartコード側で担当する手法が異なります。以降で各手法を詳しく解説します。
--flavor
Android の Product Flavors / iOS の Schemes を利用したネイティブレベルの環境分けです。アプリID・アプリ名・Firebase設定ファイルの切り替えなど、ネイティブ側の環境分けはこれで行います。
flutter run --flavor dev
--flavor で指定した値は Dart 側から appFlavor で参照できます。
import 'package:flutter/services.dart';
print(appFlavor); // "dev" or "prod"
--dart-define
Dartコードへのコンパイル時定数の注入 が目的です。ビルドコマンドに直接1つずつ値を渡し、Dart 側では String.fromEnvironment で受け取ります。設定値が少ない場合に適しています。
flutter run --dart-define=BASE_URL=https://dev.example.com
const baseUrl = String.fromEnvironment('BASE_URL');
--dart-define-from-file
--dart-define の糖衣構文です。複数の値をJSONファイルにまとめて管理でき、設定値が増えてもビルドコマンドがスッキリします。
flutter run --dart-define-from-file=dart_defines/dev.json
{
"BASE_URL": "https://dev.example.com",
"FEATURE_FLAG": "true"
}
flutter_dotenv
.env ファイルをアセットとしてバンドルし、実行時に読み込むパッケージです。扱う値はDartコード側のみで、ネイティブ側の環境分けは引き続き --flavor が担います。
--dart-define(-from-file) の代替として選ばれることが多く、採用理由は「.env ファイルで環境変数を管理する書き方に慣れているから」というケースが多いです。バックエンド開発などの経験がある方には馴染みやすい形式です。
envied
.env ファイルをもとにビルド時にコード生成を行うパッケージです。扱う値はDartコード側のみという点は flutter_dotenv と同じです。
flutter_dotenv との違いはセキュリティへの配慮です。envied はビルド時にコード生成するためバンドルされた .env ファイルが残らず、XOR 難読化オプションを使うと生成コード内の値も難読化されます。
注意点・留意点
--dart-define(-from-file) でネイティブ連携しない
--dart-define / --dart-define-from-file はDartコード専用で、ネイティブへの連携は設計外です。ネイティブ側の環境分けは --flavor を使いましょう。
かつて --dart-define-from-file でネイティブからも値を参照できる時期がありましたが、Flutter チームが「意図しない副作用」と判断しバグ修正として削除されています。この経緯を知らないと、「なぜネイティブから読めないのか」「なぜハックな実装をしているプロジェクトがあるのか」が理解しにくくなります。
- 削除: flutter/flutter#136865
- 復活要望(却下): flutter/flutter#138793
- ネイティブ向け専用オプション(
--native-defines-from-file等)は未実装・OPEN: flutter/flutter#139289
それでも値は Gradle のプロパティに base64 エンコードされた状態で渡るためデコードすれば読めますが、公式サポート外のハックであり、Flutterのバージョンアップで動作が変わるリスクがあります。
// ハックな読み方
def dartEnvironmentVariables = project.property('dart-defines')
.split(',')
.collectEntries { entry ->
def pair = new String(entry.decodeBase64(), 'UTF-8').split('=')
[(pair.first()): pair.last()]
}
秘匿情報の取り扱い
クライアントアプリに秘匿情報を持たせることは、どの手法を使っても完全には安全ではありません。秘匿情報はサーバーサイドで管理するのが原則です。
各手法のリスクは以下の通りです。
| 手法 | リスク |
|---|---|
--dart-define |
バイナリに平文で埋め込まれる。逆コンパイルで取り出せる |
flutter_dotenv |
アセットにそのまま残る。バイナリ解析で見える |
envied |
XOR難読化でカジュアルな抽出への耐性はあるが、静的・動的解析で復元可能 |
やむを得ずクライアントに秘匿情報を持たせる場合は、最も耐性の高い envied を使いましょう。
考察
環境分けは --flavor だけで十分?
--flavor を使えば appFlavor でDart側からflavor名を参照できます。APIのベースURLなどの切り替えも appFlavor を使ったシンプルな実装で対応できるため、--dart-define や flutter_dotenv を追加しなくても事足ります。
参考:Use flavors in Flutter code
// Flavor: 環境の種類を表すだけ
enum Flavor {
dev,
stg,
prod;
static Flavor get current {
final f = appFlavor ?? (throw UnimplementedError());
return Flavor.values.byName(f);
}
}
// AppEnvironment: 環境別の設定を一箇所に集約
class AppEnvironment {
static String get apiBaseUrl => switch (Flavor.current) {
Flavor.dev => 'https://example.dev.com',
Flavor.stg => 'https://example.stg.com',
Flavor.prod => 'https://example.prod.com',
};
}
# ビルドコマンドはシンプル
flutter run --flavor dev
--flavor と --dart-define(-from-file) を組み合わせる理由を考えてみた
--flavor 単体でも環境分けは十分に機能します。しかし 「dev専用のコードを prod バイナリから完全に除去したい」 場合は、--flavor だけでは対応できません。これが --dart-define(-from-file) を --flavor と組み合わせて使う主な理由だと思います。
appFlavor は実行時に評価されるため、コンパイラがビルド時に条件の真偽を判断できず、dead code を除去(tree-shaking)できません。一方 --dart-define で渡した値を const として使うと、コンパイラがビルド時に条件を確定し、不要なコードをバイナリから取り除けます。
dev専用の大きなコードブロックを prod バイナリから除去したい場合、ポイントは const でビルド時に値を確定させること です。
// ❌ tree-shaking 効かない(final は実行時評価)
final flavor = String.fromEnvironment('FLAVOR');
if (flavor == 'dev') { devOnlyCode(); }
// ✅ tree-shaking 有効(const はコンパイル時定数)
const flavor = String.fromEnvironment('FLAVOR');
if (flavor == 'dev') { devOnlyCode(); }
getter 経由では const 性が失われますが、@pragma でインライン展開すれば解決できます。
// ❌ getter 経由では const 性が呼び出し元に見えない
bool get isDevMode => const String.fromEnvironment('FLAVOR') == 'dev';
// ✅ @pragma でインライン展開すれば const 性が届く
@pragma('vm:prefer-inline')
bool get isDevMode => const String.fromEnvironment('FLAVOR') == 'dev';
appFlavor は実行時の値のため、@pragma を使っても tree-shaking は効きません。
実装例:
const bool isDevMode = String.fromEnvironment('FLAVOR') == 'dev';
// prod バイナリからこの Widget ごと除去される
if (isDevMode) ...[
const DebugOverlay(),
],
flutter run --flavor dev --dart-define=FLAVOR=dev
環境変数の切り替え程度であれば tree-shaking は不要ですが、dev 専用の重いコードを prod バイナリから除去したい場面で活用できます。
まとめ
調べた結果、自分が思う用途と手段の対応をまとめます。
| 用途 | 推奨手段 |
|---|---|
| ネイティブの環境分け(アプリID・アプリ名・Firebase設定ファイルなど) | --flavor |
| Dartコードの環境分け(APIのベースURLなど) | --flavor(appFlavor 経由)or --dart-define(-from-file) |
| dev専用コードのtree-shaking | --dart-define(-from-file) |
| 秘匿情報 | サーバーサイド管理が原則。やむを得ずクライアントに持たせる場合は envied |
個人的な推奨は以下の2パターンです。
--flavor単独 — シンプルで十分なケースはほとんどこれで対応できます。--flavor+--dart-define-from-file— dev専用のコードをprodバイナリから完全に除去したい場合(tree-shaking)に採用します。--dart-defineではなく--dart-define-from-fileを使うのは、設定値が複数あるときに1ファイルで管理でき、ビルドコマンドもスッキリするためです。
おまけ
個人開発やプロジェクト新規作成時は flutter_flavorizr を使うと、flavorのセットアップをコマンド一発で済ませられておすすめです。
dart run flutter_flavorizr







