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 Dev | com.example.warehouse_pocket.dev | https://dev-api.example.local |
stg | 配布前検証 | Warehouse Pocket Stg | com.example.warehouse_pocket.stg | https://stg-api.example.local |
prod | 本番候補 | Warehouse Pocket | com.example.warehouse_pocket | https://api.example.local |
同じ端末へ dev と stg を並べて入れたいなら、--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 も分けます。
4. Android flavor を設定する
4-1. AndroidManifest.xml のアプリ名参照を確認する
Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名) を先に進めているなら、android/app/src/main/AndroidManifest.xml の android: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.kts の android { ... } に flavorDimensions と productFlavors を追加します。既存の defaultConfig、buildTypes、署名設定はそのまま残し、その下へ追記してください。
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"
}
dev と stg に 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 を併用して起動する
dev、stg、prod は次のように起動します。
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 側で出し分けにくくなります。実務では両方を合わせて使うほうが切り分けしやすくなります。
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_IMAGES や READ_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 が生成されていることまで確認しておくと、配布前確認の漏れを減らせます。
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 が生成されていることまで確認しておくと、本番候補の出力物を取り違えにくくなります。
6-4. ここで詰まりやすい点
The flavor "stg" is not configured:productFlavorsの作成名とコマンドの--flavorが一致していないSigningConfig "release" is missing required property "storeFile": 署名設定が未完了、またはkey.propertiesの値が誤っている- dev の URL へつながってしまう:
--dart-defineを省略したか、APP_ENVとAPI_BASE_URLの組み合わせがずれている
ビルド自体が通っても、stg の APK に dev の接続先が入っていることはあります。出力物の有無だけで終えず、起動後の画面表示も合わせて確認してください。
7. 配布前チェックリスト
最後に、確認項目を 1 枚へまとめます。人に渡す前、または自分で最終確認するときはこの表を上から埋めると漏れを減らせます。
| 項目 | 何を確認するか | OK の状態 |
|---|---|---|
| flavor | dev / stg / prod のどれを作るか | 実行コマンドと配布先が一致している |
| アプリ名 | ホーム画面の表示名 | Dev / Stg / 本番名が見分けられる |
applicationId | 端末へ共存できるか | dev / stg に suffix が付き、prod は本番名 |
| API 接続先 | アプリ内の表示やログ | 対象環境の URL を指している |
| 権限宣言 | AndroidManifest.xml | 今回使う権限だけが残っている |
| 対象 build | Android 設定アプリ | stg / prod など対象アプリを取り違えず確認できる |
| 実行時権限 | アプリの権限要求フロー | 設定画面誘導または理由説明がある |
| release build | flutter build apk / appbundle | エラーなしで出力物が生成される |
| 出力物 | build/app/outputs/... | flavor 名を含むファイルが生成されている |
この表で見ているのは「コードが動くか」だけではありません。配布先の人が何を触る build なのか、端末上で誤認しないか、不要権限を含んでいないかまでを含めて確認します。
8. まとめ
flavor は Android 側の見た目と出力物を分けるために使い、--dart-define は Dart 側の設定値を分けるために使います。両方を合わせると、同じ端末へ dev / stg を並べて入れつつ、接続先 URL も安全に切り替えられます。あとは権限確認と release build を配布前チェックリストへ落とし込み、毎回同じ順序で見直せる状態にしておくと、社内配布や最終確認へ進めやすくなります。作成した APK を実際にどう配るかまで続けて整理したい場合は、Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要) を参照してください。