公開日 2026-08-14

Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)

Flutter で Android の flavor と `--dart-define` を組み合わせ、環境切替、権限確認、release build までを配布前チェックとして整理できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. 先に全体像を掴む
  6. 3. プロジェクトを作成して確認画面を用意する
  7. 3-1. Flutter の環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 3-4. lib/main.dart を作成する
  11. 3-5. flutter run で起動する
  12. 4. Android flavor を設定する
  13. 4-1. AndroidManifest.xml のアプリ名参照を確認する
  14. 4-2. android/app/build.gradle.kts を更新する
  15. コードのポイント
  16. 4-3. flavor と --dart-define を併用して起動する
  17. 5. 権限確認を配布前チェックへ組み込む
  18. 5-1. AndroidManifest.xml に必要な権限だけを残す
  19. 5-2. 確認する場所を先に固定する
  20. 6. release build を flavor ごとに確認する
  21. 6-1. 署名設定がまだなら先に済ませる
  22. 6-2. staging 用 APK を作成する
  23. 6-3. 本番候補の AAB を作成する
  24. 6-4. ここで詰まりやすい点
  25. 7. 配布前チェックリスト
  26. 8. まとめ

Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名) の次に整理しておきたいのが、環境切替と配布前チェックです。実務で止まりやすいのは、dev / stg / prod の API 接続先を変えること自体ではありません。同じ端末へ並べて入れられるか、どの build が staging 用なのか識別できるか、不要な権限が残っていないかを最後に確認できず、そのまま配布準備へ進んでしまう点にあります。この記事では Android 向けに範囲を絞り、flavor、--dart-define、release build、権限確認を 1 本の手順でつなげます。

1. ゴールと非対象

対象読者

  • Flutter プロジェクトを作成して flutter run した経験がある人
  • Android 向けに dev / stg / prod を切り替えたい人
  • --dart-define と flavor の役割差がまだ曖昧な人
  • 権限実装のあとに、配布前の点検観点を整理したい人

この記事で到達する状態

  • Android の product flavor を dev / stg / prod で分けられる
  • flutter run --flavor ... --dart-define ... でアプリ名と API 接続先を切り替えられる
  • 不要な権限を Manifest から外し、必要な権限だけを確認対象へ残せる
  • flutter build apk / flutter build appbundle を flavor ごとに実行し、出力物を見分けられる
  • 配布前の確認項目を一覧表で再利用できる

非対象

  • iOS の scheme / xcconfig 設定
  • dart-define-from-file を使ったファイル分割運用
  • Firebase や Sentry の導入
  • Widget テスト、Integration Test
  • MDM 配布や Play Console 登録

今回は Android の配布前準備に絞ります。iOS、運用監視、社内配布は後続記事へ分け、まずは「環境を切り替えたうえで release build と権限確認を通せる状態」を固めます。

2. 先に全体像を掴む

環境切替では、Android 側と Dart 側で役割が分かれます。

flowchart LR
  A[flutter run / flutter build] --> B[--flavor dev stg prod]
  A --> C[--dart-define APP_ENV API_BASE_URL]
  B --> D[Android productFlavors]
  D --> E[app_name applicationId 出力物]
  C --> F[lib/main.dart]
  F --> G[現在環境と接続先を表示]
  E --> H[APK / AAB]
  H --> I[配布前チェック]
  I --> J[権限 / release build / 表示名を確認]

--flavor--dart-define は似て見えますが、役割は同じではありません。

  • --flavor: Android 側の applicationId、アプリ名、出力物を切り替える
  • --dart-define: Dart 側の API 接続先や機能フラグを切り替える

今回の使い分けは次の通りです。

環境役割アプリ名の例applicationId の例API 接続先の例
dev手元確認Warehouse Pocket Devcom.example.warehouse_pocket.devhttps://dev-api.example.local
stg配布前検証Warehouse Pocket Stgcom.example.warehouse_pocket.stghttps://stg-api.example.local
prod本番候補Warehouse Pocketcom.example.warehouse_pockethttps://api.example.local

同じ端末へ devstg を並べて入れたいなら、--dart-define だけでは足りません。アプリ名や applicationId まで分ける必要があるため、Android の flavor が必要になります。

3. プロジェクトを作成して確認画面を用意する

3-1. Flutter の環境構築がまだなら先に済ませる

環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。

3-2. Flutter プロジェクトを作成する

次のコマンドでプロジェクトを作成します。

flutter create warehouse_pocket
cd warehouse_pocket

3-3. エミュレーターを起動する

利用可能なエミュレーター一覧を確認します。

flutter emulators

表示された ID を指定して起動します。

flutter emulators --launch <emulator_id>

3-4. lib/main.dart を作成する

lib/main.dart は次の内容で作成します。環境名、API 接続先、配布前の確認項目を 1 画面へまとめたサンプルで、--dart-define で渡した値が画面へどう反映されるかを確認しやすい構成です。

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

enum AppEnvironment {
  dev,
  stg,
  prod,
}

class AppConfig {
  const AppConfig({
    required this.environment,
    required this.apiBaseUrl,
    required this.requireCamera,
    required this.requireBluetooth,
  });

  factory AppConfig.fromDefines() {
    const rawEnv = String.fromEnvironment('APP_ENV', defaultValue: 'dev');

    return AppConfig(
      environment: AppEnvironment.values.firstWhere(
        (value) => value.name == rawEnv,
        orElse: () => AppEnvironment.dev,
      ),
      apiBaseUrl: const String.fromEnvironment(
        'API_BASE_URL',
        defaultValue: 'https://dev-api.example.local',
      ),
      requireCamera: const bool.fromEnvironment(
        'REQUIRE_CAMERA_PERMISSION',
        defaultValue: true,
      ),
      requireBluetooth: const bool.fromEnvironment(
        'REQUIRE_BLUETOOTH_PERMISSION',
        defaultValue: false,
      ),
    );
  }

  final AppEnvironment environment;
  final String apiBaseUrl;
  final bool requireCamera;
  final bool requireBluetooth;

  String get title {
    switch (environment) {
      case AppEnvironment.dev:
        return 'Warehouse Pocket Dev';
      case AppEnvironment.stg:
        return 'Warehouse Pocket Stg';
      case AppEnvironment.prod:
        return 'Warehouse Pocket';
    }
  }

  Color get accentColor {
    switch (environment) {
      case AppEnvironment.dev:
        return const Color(0xFF0F766E);
      case AppEnvironment.stg:
        return const Color(0xFFB45309);
      case AppEnvironment.prod:
        return const Color(0xFF1D4ED8);
    }
  }

  List<String> get permissionChecklist {
    return <String>[
      if (requireCamera) 'CAMERA を宣言し、読み取り開始時にだけ要求する',
      if (requireBluetooth) 'BLUETOOTH_SCAN / CONNECT の用途を説明できる',
      '不要な権限宣言が残っていない',
      '設定画面で拒否時の導線を確認済み',
    ];
  }
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    final config = AppConfig.fromDefines();

    return MaterialApp(
      title: config.title,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: config.accentColor),
      ),
      home: PreflightPage(config: config),
    );
  }
}

class PreflightPage extends StatelessWidget {
  const PreflightPage({
    super.key,
    required this.config,
  });

  final AppConfig config;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Scaffold(
      appBar: AppBar(
        title: Text(config.title),
      ),
      body: ListView(
        padding: const EdgeInsets.all(24),
        children: [
          Container(
            padding: const EdgeInsets.all(20),
            decoration: BoxDecoration(
              color: config.accentColor.withAlpha(31),
              borderRadius: BorderRadius.circular(20),
              border: Border.all(color: config.accentColor),
            ),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  config.environment.name.toUpperCase(),
                  style: theme.textTheme.labelLarge?.copyWith(
                    color: config.accentColor,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                const SizedBox(height: 12),
                Text(
                  '配布前チェックボード',
                  style: theme.textTheme.headlineSmall,
                ),
                const SizedBox(height: 8),
                Text('API 接続先: ${config.apiBaseUrl}'),
              ],
            ),
          ),
          const SizedBox(height: 24),
          _InfoCard(
            title: '今回の確認範囲',
            lines: <String>[
              'アプリ名と環境表示が合っているか',
              '接続先 URL が想定環境か',
              '必要な権限だけが残っているか',
              'release build が通るか',
            ],
          ),
          const SizedBox(height: 16),
          _InfoCard(
            title: '権限の確認メモ',
            lines: config.permissionChecklist,
          ),
        ],
      ),
    );
  }
}

class _InfoCard extends StatelessWidget {
  const _InfoCard({
    required this.title,
    required this.lines,
  });

  final String title;
  final List<String> lines;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              title,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 12),
            for (final line in lines)
              Padding(
                padding: const EdgeInsets.only(bottom: 8),
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    const Text('・'),
                    const SizedBox(width: 8),
                    Expanded(child: Text(line)),
                  ],
                ),
              ),
          ],
        ),
      ),
    );
  }
}

3-5. flutter run で起動する

まずはデフォルト値のまま起動します。

flutter run

画面では次の 2 点を確認します。

  • DEV バッジが表示されているか
  • API 接続先が https://dev-api.example.local になっているか

この時点では flavor をまだ設定していないため、Dart 側の値だけが切り替わる状態です。次の章で Android 側のアプリ名と applicationId も分けます。

flutter run 直後のデフォルト画面で DEV 表示と API 接続先を確認している画面

4. Android flavor を設定する

4-1. AndroidManifest.xml のアプリ名参照を確認する

Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名) を先に進めているなら、android/app/src/main/AndroidManifest.xmlandroid:label はすでに @string/app_name になっているはずです。まだなら、まずここをそろえます。

<application
    android:label="@string/app_name"
    android:name="${applicationName}"
    android:icon="@mipmap/ic_launcher">

android:label を文字列リソースへ寄せておくと、flavor ごとに resValue で名前を変えられます。Manifest に直接 Warehouse Pocket Dev のような固定値を書く必要はありません。

4-2. android/app/build.gradle.kts を更新する

android/app/build.gradle.ktsandroid { ... }flavorDimensionsproductFlavors を追加します。既存の defaultConfigbuildTypes、署名設定はそのまま残し、その下へ追記してください。

android {
    namespace = "com.example.warehouse_pocket"
    compileSdk = flutter.compileSdkVersion
    ndkVersion = flutter.ndkVersion

    defaultConfig {
        applicationId = "com.example.warehouse_pocket"
        minSdk = flutter.minSdkVersion
        targetSdk = flutter.targetSdkVersion
        versionCode = flutter.versionCode
        versionName = flutter.versionName
    }

    flavorDimensions += "env"

    productFlavors {
        create("dev") {
            dimension = "env"
            applicationIdSuffix = ".dev"
            versionNameSuffix = "-dev"
            resValue("string", "app_name", "Warehouse Pocket Dev")
        }

        create("stg") {
            dimension = "env"
            applicationIdSuffix = ".stg"
            versionNameSuffix = "-stg"
            resValue("string", "app_name", "Warehouse Pocket Stg")
        }

        create("prod") {
            dimension = "env"
            resValue("string", "app_name", "Warehouse Pocket")
        }
    }
}

コードのポイント

applicationIdSuffix を分けると同一端末へ並べて入れやすい

        create("dev") {
            dimension = "env"
            applicationIdSuffix = ".dev"
            versionNameSuffix = "-dev"
        }

        create("stg") {
            dimension = "env"
            applicationIdSuffix = ".stg"
            versionNameSuffix = "-stg"
        }

devstg に suffix を付けておくと、同じ端末へ両方をインストールできます。prod は本番候補なので suffix を付けず、最終的な package 名をそこへ寄せる構成が扱いやすくなります。

resValue で flavor ごとのアプリ名を分ける

        create("dev") {
            dimension = "env"
            applicationIdSuffix = ".dev"
            versionNameSuffix = "-dev"
            resValue("string", "app_name", "Warehouse Pocket Dev")
        }

        create("stg") {
            dimension = "env"
            applicationIdSuffix = ".stg"
            versionNameSuffix = "-stg"
            resValue("string", "app_name", "Warehouse Pocket Stg")
        }

        create("prod") {
            dimension = "env"
            resValue("string", "app_name", "Warehouse Pocket")
        }

ここで変わるのは Android 側の表示名です。Dart 側の AppConfig.title と合わせておくと、ホーム画面とアプリ内部の両方で環境を見分けやすくなります。

4-3. flavor と --dart-define を併用して起動する

devstgprod は次のように起動します。

flutter run --flavor dev --dart-define=APP_ENV=dev --dart-define=API_BASE_URL=https://dev-api.example.local
flutter run --flavor stg --dart-define=APP_ENV=stg --dart-define=API_BASE_URL=https://stg-api.example.local
flutter run --flavor prod --dart-define=APP_ENV=prod --dart-define=API_BASE_URL=https://api.example.local

この 3 行で切り替わる対象は次の通りです。

  • --flavor: Android 側のアプリ名、applicationId、build variant
  • --dart-define: lib/main.dart 内の環境名、API 接続先、機能フラグ

--dart-define だけで進めると、画面内の文字は切り替わってもホーム画面のアプリ名やインストール先が同じまま残ります。逆に --flavor だけだと、API 接続先を Dart 側で出し分けにくくなります。実務では両方を合わせて使うほうが切り分けしやすくなります。

stg と prod を起動し、アプリ名と環境表示、API 接続先の違いを比較している画面

5. 権限確認を配布前チェックへ組み込む

環境切替ができても、不要な権限が残っていると配布前確認で止まりやすくなります。ここでは、Manifest の宣言、対象 build の取り違え防止、実行時権限の確認先を 1 セットで整理します。

5-1. AndroidManifest.xml に必要な権限だけを残す

android/app/src/main/AndroidManifest.xml を開き、<application> ... </application> の外側に uses-permission を追加します。配置場所は </application> の後ろ、</manifest> の前です。バーコード読み取りと Bluetooth 機器接続を使う例なら、追加する宣言は次のようになります。

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission
    android:name="android.permission.BLUETOOTH"
    android:maxSdkVersion="30" />
<uses-permission
    android:name="android.permission.BLUETOOTH_ADMIN"
    android:maxSdkVersion="30" />
<uses-permission
    android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

画像選択やファイル書き出しを使わない build なのに、以前の検証で入れた READ_MEDIA_IMAGESREAD_EXTERNAL_STORAGE が残っていることがあります。機能を外したなら、権限宣言も一緒に見直します。

5-2. 確認する場所を先に固定する

permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth) では、要求タイミングや拒否時導線を実装しました。このサンプルでは実行時権限要求そのものは実装せず、Manifest に不要な宣言が残っていないかと、配布前にどの観点を見直すかを整理します。

観点どこで確認するか合格条件
Manifest 宣言AndroidManifest.xml今回使う機能に必要な権限だけが残っている
対象 build の確認Android の設定アプリ -> アプリ情報stg など対象のアプリを取り違えず確認できる
実行時権限の実装権限実装側の画面要求タイミング、拒否時導線、設定画面誘導を確認できる
不要権限コードと Manifest の両方過去の検証用権限が残っていない

アプリ情報画面は、いま確認している build が stg なのか prod なのかを端末側でも見分ける場所です。Manifest だけ見て終えるより、設定アプリ側でも対象アプリを取り違えていないか先に固定しておくと、配布前確認の手戻りを減らせます。

設定アプリの対象画面へ飛びたいときは、次のコマンドで App info を開けます。

adb shell am start -a android.settings.APPLICATION_DETAILS_SETTINGS -d package:com.example.warehouse_pocket.stg

package: の値は flavor ごとの applicationId に合わせて変更してください。実行時権限の要求と拒否時導線は、このサンプルではなく permission_handler 側の実装画面で確認します。

6. release build を flavor ごとに確認する

6-1. 署名設定がまだなら先に済ませる

release build を署名付きで出す前提がまだなら、先に Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名) の署名設定を済ませてください。この記事では flavor を付けた build を通し、出力物を見分けるところまでを扱います。

6-2. staging 用 APK を作成する

配布前に現場確認や社内確認へ回しやすいのは APK です。まずは staging 用を作成します。

flutter build apk --flavor stg --release --dart-define=APP_ENV=stg --dart-define=API_BASE_URL=https://stg-api.example.local

成功すると、出力物は次の場所に作成されます。

build/app/outputs/flutter-apk/app-stg-release.apk

Explorer や terminal で app-stg-release.apk が生成されていることまで確認しておくと、配布前確認の漏れを減らせます。

staging 用 APK の出力を Explorer で確認している画面

6-3. 本番候補の AAB を作成する

本番候補の確認では AAB も作っておくと、最終配布形に近い形で確認できます。

flutter build appbundle --flavor prod --release --dart-define=APP_ENV=prod --dart-define=API_BASE_URL=https://api.example.local

出力先は次の通りです。

build/app/outputs/bundle/prodRelease/app-prod-release.aab

Explorer や terminal で app-prod-release.aab が生成されていることまで確認しておくと、本番候補の出力物を取り違えにくくなります。

本番候補の AAB 出力を Explorer で確認している画面

6-4. ここで詰まりやすい点

  • The flavor "stg" is not configured: productFlavors の作成名とコマンドの --flavor が一致していない
  • SigningConfig "release" is missing required property "storeFile": 署名設定が未完了、または key.properties の値が誤っている
  • dev の URL へつながってしまう: --dart-define を省略したか、APP_ENVAPI_BASE_URL の組み合わせがずれている

ビルド自体が通っても、stg の APK に dev の接続先が入っていることはあります。出力物の有無だけで終えず、起動後の画面表示も合わせて確認してください。

7. 配布前チェックリスト

最後に、確認項目を 1 枚へまとめます。人に渡す前、または自分で最終確認するときはこの表を上から埋めると漏れを減らせます。

項目何を確認するかOK の状態
flavordev / stg / prod のどれを作るか実行コマンドと配布先が一致している
アプリ名ホーム画面の表示名Dev / Stg / 本番名が見分けられる
applicationId端末へ共存できるかdev / stg に suffix が付き、prod は本番名
API 接続先アプリ内の表示やログ対象環境の URL を指している
権限宣言AndroidManifest.xml今回使う権限だけが残っている
対象 buildAndroid 設定アプリstg / prod など対象アプリを取り違えず確認できる
実行時権限アプリの権限要求フロー設定画面誘導または理由説明がある
release buildflutter build apk / appbundleエラーなしで出力物が生成される
出力物build/app/outputs/...flavor 名を含むファイルが生成されている

この表で見ているのは「コードが動くか」だけではありません。配布先の人が何を触る build なのか、端末上で誤認しないか、不要権限を含んでいないかまでを含めて確認します。

8. まとめ

flavor は Android 側の見た目と出力物を分けるために使い、--dart-define は Dart 側の設定値を分けるために使います。両方を合わせると、同じ端末へ dev / stg を並べて入れつつ、接続先 URL も安全に切り替えられます。あとは権限確認と release build を配布前チェックリストへ落とし込み、毎回同じ順序で見直せる状態にしておくと、社内配布や最終確認へ進めやすくなります。作成した APK を実際にどう配るかまで続けて整理したい場合は、Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要) を参照してください。

シリーズ 34/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認) 現在の記事
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)