Flavor flutter run --dart-define
やはりボタンを押してビルドしたい
前回の記事では、flutter runしてアプリの開発環境を切り替えてビルドしていました。しかしボタンを押してビルドしたり、pull downで切り替えて楽できないかと考える人もいるでしょうから仕事で使っていた設定を再現してみようとやってみました。
こちらのドキュメント読んでみましょう。参考になるかな👀
Configuring apps with compilation environment declarations
コンパイル環境宣言によるアプリの設定
You can specify compilation environment declarations when building or running a Dart application. Compilation environment declarations specify configuration options as key-value pairs that are accessed and evaluated at compile time.
Dart アプリケーションのビルドまたは実行時に、コンパイル環境宣言を指定できます。コンパイル環境宣言は、コンパイル時にアクセスおよび評価されるキーと値のペアとして、設定オプションを指定します。
Your app can use the values of environment declarations to change its functionality or behavior. Dart compilers can eliminate the code made unreachable due to control flow using the environment declaration values.
You might define and use environment declarations to:
- Add functionality during debugging, such as enabling logging.
- Create separate flavors of your application.
- Configure application behavior, such as the port of an HTTP server.
- Enable an experimental mode of your application for testing.
- Switch between testing and production backends.
アプリは、環境宣言の値を使用して機能や動作を変更することができます。Dartコンパイラーは、環境宣言の値を使用した制御フローによって到達できなくなったコードを排除することができます。
環境宣言を定義し、使用する目的は次のとおりです:
- ロギングを有効にするなど、デバッグ時の機能を追加する。
- アプリケーションのフレーバーを個別に作成する。
- HTTPサーバーのポートなど、アプリケーションの動作を設定する。
- テスト用のアプリケーションの実験モードを有効にする。
- テスト用と本番用のバックエンドを切り替える。
To specify an environment declaration when running or compiling a Dart application, use the --define option or its abbreviation, -D. Specify the declaration key-value pair using a <NAME>=<VALUE> format:
Dart アプリケーションの実行またはコンパイル時に環境宣言を指定するには、--define オプションまたはその省略形である -D を使用します。宣言のキーと値のペアを <NAME>=<VALUE> 形式で指定する:
dart run --define=DEBUG=true -DFLAVOR=free
設定ファイルの作成
スクリーンショットのようにフォルダとファイルの作成をする。
dart_defines
ディレクトリを作成する。この中に、prod, stagin, devで使用する.env
環境変数ですねこちらを作成します。
flavor="prod"
appName="FlavorFirebase"
appId="com.example.flavor_tutorial.prod"
primaryColor="#4CAF50" # Green for prod
flavor="staging"
appName="FlavorFirebase Staging"
appId="com.example.flavor_tutorial.staging"
primaryColor="#FF9800" # Orange for staging
flavor="dev"
appName="FlavorFirebase Dev"
appId="com.example.flavor_tutorial.dev"
primaryColor="#2196F3" # Blue for dev
.vscode/launch.json
を作成する。
pull downで切り替える設定ファイル
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug dev",
"request": "launch",
"type": "dart",
"flutterMode": "debug",
"args": [
"--flavor", "dev",
"-t", "lib/main_dev.dart",
"--dart-define-from-file=dart_defines/dev.env"
]
},
{
"name": "Debug staging",
"request": "launch",
"type": "dart",
"flutterMode": "debug",
"args": [
"--flavor", "staging",
"-t", "lib/main_staging.dart",
"--dart-define-from-file=dart_defines/staging.env"
]
},
{
"name": "Debug prod",
"request": "launch",
"type": "dart",
"flutterMode": "debug",
"args": [
"--flavor", "prod",
"-t", "lib/main_prod.dart",
"--dart-define-from-file=dart_defines/prod.env"
]
},
{
"name": "Profile dev",
"request": "launch",
"type": "dart",
"flutterMode": "profile",
"args": [
"--flavor", "dev",
"-t", "lib/main_dev.dart",
"--dart-define-from-file=dart_defines/dev.env"
]
},
{
"name": "Profile staging",
"request": "launch",
"type": "dart",
"flutterMode": "profile",
"args": [
"--flavor", "staging",
"-t", "lib/main_staging.dart",
"--dart-define-from-file=dart_defines/stagin.env"
]
},
{
"name": "Profile prod",
"request": "launch",
"type": "dart",
"flutterMode": "profile",
"args": [
"--flavor", "prod",
"-t", "lib/main_prod.dart",
"--dart-define-from-file=dart_defines/prod.env"
]
},
{
"name": "Release dev",
"request": "launch",
"type": "dart",
"flutterMode": "release",
"args": [
"--flavor", "dev",
"-t", "lib/main_dev.dart",
"--dart-define-from-file=dart_defines/dev.env"
]
},
{
"name": "Release staging",
"request": "launch",
"type": "dart",
"flutterMode": "release",
"args": [
"--flavor", "staging",
"-t", "lib/main_staging.dart",
"--dart-define-from-file=dart_defines/stagin.env"
]
},
{
"name": "Release prod",
"request": "launch",
"type": "dart",
"flutterMode": "release",
"args": [
"--flavor", "prod",
"-t", "lib/main_prod.dart",
"--dart-define-from-file=dart_defines/prod.env"
]
}
]
}
main.dart
を修正する。
dart --defineの設定追加
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
// Global variable to identify the current flavor
String currentFlavor = 'unknown';
// Firestore instance
final FirebaseFirestore firestore = FirebaseFirestore.instance;
// Dart-define constants
final String appName = const String.fromEnvironment('appName', defaultValue: 'Flavor Tutorial');
final String appId = const String.fromEnvironment('appId', defaultValue: 'com.example.flavor_tutorial');
final Color primaryColor = HexColor.fromHex(
const String.fromEnvironment('primaryColor', defaultValue: '#2196F3')
);
// HexColor extension for parsing color hex strings
extension HexColor on Color {
static Color fromHex(String hexString) {
final buffer = StringBuffer();
if (hexString.length == 6 || hexString.length == 7) buffer.write('ff');
buffer.write(hexString.replaceFirst('#', ''));
return Color(int.parse(buffer.toString(), radix: 16));
}
}
// Main function that takes FirebaseOptions and runs the app
Future<void> mainCommon(FirebaseOptions options, {required String flavor}) async {
WidgetsFlutterBinding.ensureInitialized();
// Set the current flavor
currentFlavor = flavor;
// Debug info to help diagnose flavor issues
debugPrint('✨✨✨ FLAVOR SET TO: $currentFlavor ✨✨✨');
debugPrint('✨✨✨ APP NAME: $appName ✨✨✨');
debugPrint('✨✨✨ APP ID: $appId ✨✨✨');
debugPrint('✨✨✨ PRIMARY COLOR: ${primaryColor.toString()} ✨✨✨');
// Initialize Firebase with the provided options
try {
if (Firebase.apps.isEmpty) {
await Firebase.initializeApp(options: options);
} else {
Firebase.app(); // If already initialized, use the default app
}
} catch (e) {
debugPrint('Firebase initialization error: $e');
}
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
// Get app name based on which entry point file was used
String appName = 'Flutter Demo';
// Set different colors for each flavor to make it visually distinctive
Color seedColor;
String flavorLabel;
// Use the currentFlavor global variable to determine the UI styling
switch (currentFlavor) {
case 'dev':
seedColor = Colors.blue;
flavorLabel = '[DEV]';
break;
case 'staging':
seedColor = Colors.amber;
flavorLabel = '[STAGING]';
break;
case 'prod':
seedColor = Colors.green;
flavorLabel = '[PROD]';
break;
default:
seedColor = Colors.deepPurple;
flavorLabel = '';
}
return MaterialApp(
title: '$appName $flavorLabel',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: seedColor),
),
home: MyHomePage(title: '$appName $flavorLabel'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
// Reference to the users collection
final CollectionReference usersCollection = FirebaseFirestore.instance.collection('users');
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// Display the current flavor
Text(
'Current Flavor: ${currentFlavor.toUpperCase()}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 30),
// Show UI based on the current flavor
...switch (currentFlavor) {
'dev' || 'staging' => [
Text(
'Users from Firestore (${currentFlavor.toUpperCase()}):',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
// StreamBuilder to display users from Firestore
Expanded(
child: StreamBuilder<QuerySnapshot>(
stream: usersCollection.snapshots(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
return const Center(child: Text('No users found'));
}
// Display the list of users
return ListView.builder(
itemCount: snapshot.data!.docs.length,
itemBuilder: (context, index) {
final doc = snapshot.data!.docs[index];
final data = doc.data() as Map<String, dynamic>;
final name = data['name'] ?? 'No name';
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(name.substring(0, 1).toUpperCase()),
),
title: Text(name),
subtitle: Text('User ID: ${doc.id}'),
);
},
);
},
),
),
],
'prod' => [
const SizedBox(height: 10),
const Text(
'This is the production environment.',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
const SizedBox(height: 20),
const Icon(
Icons.verified,
color: Colors.green,
size: 48,
),
const SizedBox(height: 20),
const Text(
'Firestore access is restricted in production.',
style: TextStyle(color: Colors.redAccent),
),
Expanded(
child: StreamBuilder<QuerySnapshot>(
stream: usersCollection.snapshots(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
return const Center(child: Text('No users found'));
}
// Display the list of users
return ListView.builder(
itemCount: snapshot.data!.docs.length,
itemBuilder: (context, index) {
final doc = snapshot.data!.docs[index];
final data = doc.data() as Map<String, dynamic>;
final name = data['name'] ?? 'No name';
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(name.substring(0, 1).toUpperCase()),
),
title: Text(name),
subtitle: Text('User ID: ${doc.id}'),
);
},
);
},
),
),
],
_ => [] // Default case for any other flavor
},
],
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Show different buttons based on the flavor
...switch (currentFlavor) {
'dev' || 'staging' => [
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: FloatingActionButton(
onPressed: () async {
// Add a new user to Firestore with a random name
try {
final timestamp = DateTime.now().millisecondsSinceEpoch;
await usersCollection.add({
'name': 'Test User $timestamp ${currentFlavor.toUpperCase()}',
'createdAt': FieldValue.serverTimestamp(),
'environment': currentFlavor,
});
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('User added successfully')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error adding user: $e')),
);
}
}
},
heroTag: 'addUser',
backgroundColor: currentFlavor == 'dev' ? Colors.green : Colors.orange,
child: const Icon(Icons.person_add),
),
),
],
_ => [] // Empty list for prod and other flavors
},
// This button is always shown regardless of flavor
FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
heroTag: 'increment',
child: const Icon(Icons.add),
),
],
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}
🍏iOSで使ってみる
pull downで切り替えをして、本番環境、本番に近いテスト環境、テスト環境でビルドしてみましょう。
iOS Prod環境
iOS Staging環境
iOS Dev環境
🤖Androidで使ってみる
iOSと同じようにビルドすれば動くはず。
Android Prod環境
Android Staging環境
Android Dev環境
Android Studioの場合
VSCodeとやり方が異なる部分がある。指定するファイルは同じだったりする。
画面右上あたりのpull downから設定をする。
設定ファイルを作成したら右下あたりの
Apply
を押して変更を適用する。
Prod
iOS
Andriod
Staging
iOS
Andriod
Dev
iOS
Andriod
最後に
前回作成したFlavorを使用した環境分けしたデモアプリに設定を追加して、ボタンを押すだけで開発環境を切り替えてビルドできるようにしました。
やはりボタンを押して切り替える方が楽ですね😅
こちらが完成品になります。launch.jsonブランチです。
Discussion