🙄

【Flutter×Firebase】環境分け実装ガイド(dart-define)

に公開

はじめに

FlutterアプリケーションでFirebaseを使用する際の環境分け(dev、stg、prod)の実装方法を備忘録として残します
今回は Firebaseのプロジェクト(dev、stg、prod)をそれぞれ用意し、dart-defineを使ってコマンドから違う環境を立ち上げられるようにしていきます。

前提条件

  • Flutterプロジェクト作成済み
  • Firebaseアカウントの作成済み

全体像

project_root/
├── flavor/                         # 環境設定ファイル
│   ├── dev.json                    # 開発環境設定
│   ├── stg.json                    # ステージング環境設定
│   └── prod.json                   # 本番環境設定
│
├── lib/
│   ├── firebase_options/           # Firebase設定
│   │   ├── firebase_options_dev.dart
│   │   ├── firebase_options_stg.dart
│   │   └── firebase_options_prod.dart
│   │
│   ├── core/
│   │   └── constants/
│   │       └── flavor_config.dart  # Flavor設定クラス
│   │
│   └── main.dart                   # メイン処理(環境分岐を含む)
│
├── ios/
│   ├── config/                     # iOS用Firebase設定
│   │   ├── dev/GoogleService-Info.plist
│   │   ├── stg/GoogleService-Info.plist
│   │   └── prod/GoogleService-Info.plist
│   │
│   └── scripts/
│       └── extract_dart_defines.sh # dart-defineをiOSに渡すスクリプト
│
├── android/
│   └── app/
│       └── src/
│           └── config/             # Android用Firebase設定
│               ├── dev/google-services.json
│               ├── stg/google-services.json
│               └── prod/google-services.json
│
└── .vscode/
    └── launch.json                 # VS Code実行設定

Firebase CLI

最初にFirebase CLIのインストール。その後アカウントにログイン

npm install -g firebase-tools
firebase login

FlutterFire CLIのインストール

dart pub global activate flutterfire_cli

環境設定ファイルの作成

プロジェクト配下にflavorディレクトリを作成し、各環境用の設定ファイルを作成します:

flavor/dev.json:

{
  "FLAVOR": "dev",
  "APP_NAME": "Example Dev",
  "BUNDLE_ID": "com.example.app.dev"
}

flavor/stg.json:

{
  "FLAVOR": "stg",
  "APP_NAME": "Example Stg",
  "BUNDLE_ID": "com.example.app.stg"
}

flavor/prod.json:

{
  "FLAVOR": "prod",
  "APP_NAME": "Example",
  "BUNDLE_ID": "com.example.app"
}

Firebase設定

以下をターミナルでプロジェクト直下ルートで実行

# 開発環境の設定
flutterfire configure \
  --project=womadhub-dev \
  --out=lib/firebase_options/firebase_options_dev.dart \
  --ios-bundle-id=com.womadhub.app.dev \
  --android-package-name=com.womadhub.app.dev \
  --ios-out=ios/config/dev/GoogleService-Info.plist \
  --android-out=android/app/src/config/dev/google-services.json

# ステージング環境の設定
flutterfire configure \
  --project=womadhub-stg \
  --out=lib/firebase_options/firebase_options_stg.dart \
  --ios-bundle-id=com.womadhub.app.stg \
  --android-package-name=com.womadhub.app.stg \
  --ios-out=ios/config/stg/GoogleService-Info.plist \
  --android-out=android/app/src/config/stg/google-services.json

# 本番環境の設定
flutterfire configure \
  --project=womadhub \
  --out=lib/firebase_options/firebase_options_prod.dart \
  --ios-bundle-id=com.womadhub.app \
  --android-package-name=com.womadhub.app \
  --ios-out=ios/config/prod/GoogleService-Info.plist \
  --android-out=android/app/src/config/prod/google-services.json

注意点

Analytics、Crashlytics、Performance Monitoringを使用する場合は、GoogleService-Info.plistとgoogle-services.jsonがそれぞれios・androidディレクトリ内に設定が必要
https://github.com/invertase/flutterfire_cli/issues/14#issuecomment-1103073137
これらのサービスを使用しない場合は、firebase_optionsの設定のみで十分(--ios-out=...,--android-out=...のコマンドが必要ないです)

Android設定

android/app/build.gradleファイルを以下のように修正して、Flutter定義変数を読み込めるようにします。
android/app/build.gradle:

def dartDefines = [:];
if (project.hasProperty('dart-defines')) {
    dartDefines = dartDefines + project.property('dart-defines')
        .split(',')
        .collectEntries { entry ->
            def pair = new String(entry.decodeBase64(), 'UTF-8').split('=')
            pair.length == 2 ? [(pair.first()): pair.last()] : [:]
        }
}

// google-services.jsonをコピーするタスク
task copyGoogleServices {
    def flavor = dartDefines.FLAVOR ?: 'dev'
    doLast {
        copy {
            from "src/config/${flavor}/google-services.json"
            into '.'
        }
    }
}

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

    // 他の設定...

    defaultConfig {
        applicationId = dartDefines.BUNDLE_ID ?: 'com.example.app'
        minSdk = flutter.minSdkVersion
        targetSdk = flutter.targetSdkVersion
        versionCode = flutter.versionCode
        versionName = flutter.versionName
        resValue "string", "app_name", dartDefines.APP_NAME ?: 'ExampleApp'
    }
}

// Google Servicesタスクの前にコピータスクを実行
tasks.whenTaskAdded { task ->
    if (task.name.startsWith('process') && task.name.endsWith('GoogleServices')) {
        task.dependsOn copyGoogleServices
    }
}

iOS設定

  1. スクリプトファイルの作成(ios/scripts/extract_dart_defines.sh):
#!/bin/sh
OUTPUT_FILE="${SRCROOT}/Flutter/Dart-Defines.xcconfig"
: > $OUTPUT_FILE

function decode_url() { echo "${*}" | base64 --decode; }

IFS=',' read -r -a define_items <<<"$DART_DEFINES"

for index in "${!define_items[@]}"
do
    item=$(decode_url "${define_items[$index]}")
    lowercase_item=$(echo "$item" | tr '[:upper:]' '[:lower:]')
    if [[ $lowercase_item != flutter* ]]; then
        echo "$item" >> "$OUTPUT_FILE"
    fi
done
  1. スクリプトに実行権限を付与:
chmod 755 ios/scripts/extract_dart_defines.sh
  1. Dart-Defines.xcconfig ファイルを作成:
touch ios/Flutter/Dart-Defines.xcconfig
  1. 環境別のxcconfigファイルを作成
    ios/Flutter/ディレクトリに以下のファイルを作成します:

dev.xcconfig:

#include "Generated.xcconfig"

PRODUCT_BUNDLE_IDENTIFIER=com.example.app.dev
GOOGLE_CLIENT_ID=*****************************************
DISPLAY_NAME=example Dev

stg.xcconfig:

#include "Generated.xcconfig"

PRODUCT_BUNDLE_IDENTIFIER=com.example.app.stg
GOOGLE_CLIENT_ID=*****************************************
DISPLAY_NAME=example Stg

prod.xcconfig:

#include "Generated.xcconfig"

PRODUCT_BUNDLE_IDENTIFIER=com.example.app
GOOGLE_CLIENT_ID=*****************************************
DISPLAY_NAME=example
  1. .gitignoreに追加:
**/ios/Flutter/Dart-Defines.xcconfig

Dartコード(本体コード)の設定

Flavorの設定

アプリケーションでFlavorを利用するための設定クラスを作成します

enum Flavor { dev, stg, prod }

class FlavorConfig {
  final Flavor flavor;
  final String appName;
  final String bundleId;

  static FlavorConfig? _instance;

  factory FlavorConfig({
    required Flavor flavor,
    required String appName,
    required String bundleId,
  }) {
    _instance ??= FlavorConfig._internal(flavor, appName, bundleId);
    return _instance!;
  }

  FlavorConfig._internal(this.flavor, this.appName, this.bundleId);

  static FlavorConfig get instance {
    return _instance!;
  }

  static bool get isDevelopment => _instance!.flavor == Flavor.dev;
  static bool get isStaging => _instance!.flavor == Flavor.stg;
  static bool get isProduction => _instance!.flavor == Flavor.prod;

  static FirebaseOptions get firebaseOptions => switch (_instance!.flavor) {
        Flavor.dev => dev.DefaultFirebaseOptions.currentPlatform,
        Flavor.stg => stg.DefaultFirebaseOptions.currentPlatform,
        Flavor.prod => prod.DefaultFirebaseOptions.currentPlatform,
      };
}

main.dartの設定

main.dartファイルで環境設定を読み込み、初期化する処理を追加します

main.dart
// 環境変数から取得(dart-define-from-fileで渡される)
const flavor = String.fromEnvironment('FLAVOR', defaultValue: 'dev');
const appName = String.fromEnvironment('APP_NAME', defaultValue: 'WomadHub Dev');
const bundleId = String.fromEnvironment('BUNDLE_ID', defaultValue: 'com.womadhub.app.dev');

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Flavor設定の初期化
  FlavorConfig(
    flavor: _mapStringToFlavor(flavor),
    appName: appName,
    bundleId: bundleId,
  );

  // Firebase初期化
  await Firebase.initializeApp(
    options: FlavorConfig.firebaseOptions,
  );

  // .envファイル読み込み
  await EnvConstants().init();

  // 多言語設定の初期化
  final savedLocale = await LocalePreferences.getSavedLocale();
  if (savedLocale != null) {
    LocaleSettings.setLocaleRaw(savedLocale.languageTag);
  } else {
    LocaleSettings.useDeviceLocale();
  }

  runApp(
    ProviderScope(
      child: TranslationProvider(
        child: const MyApp(),
      ),
    ),
  );
}

Flavor _mapStringToFlavor(String flavorString) {
  switch (flavorString) {
    case 'dev':
      return Flavor.dev;
    case 'stg':
      return Flavor.stg;
    case 'prod':
      return Flavor.prod;
    default:
      return Flavor.dev;
  }
}

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    
    return ScreenUtilInit(
      designSize: const Size(390, 844),
      minTextAdapt: true,
      splitScreenMode: true,
      builder: (context, child) {
        return MaterialApp.router(
          title: FlavorConfig.instance.appName,
          debugShowCheckedModeBanner: !FlavorConfig.isProduction,
          ...
        );
      },
    );
  }
}

VS Code設定

.vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Development",
      "request": "launch",
      "type": "dart",
      "args": [
        "--dart-define-from-file=flavor/dev.json"
      ]
    },
    {
      "name": "Staging",
      "request": "launch",
      "type": "dart",
      "args": [
        "--dart-define-from-file=flavor/stg.json"
      ]
    },
    {
      "name": "Production",
      "request": "launch",
      "type": "dart",
      "args": [
        "--dart-define-from-file=flavor/prod.json"
      ]
    }
  ]
}

アプリの実行方法

# 開発環境
fvm flutter run --dart-define-from-file=flavor/dev.json

# ステージング環境
fvm flutter run --dart-define-from-file=flavor/stg.json

# 本番環境
fvm flutter run --dart-define-from-file=flavor/prod.json

注意点

環境設定ファイルの管理:

環境設定ファイル(flavor/*.json)はバージョン管理に含めることで、チーム全体で同じ設定を使用できます。ただしAPIキーなどの機密情報は.envファイルで管理し、バージョン管理から除外しましょう。

Firebase設定ファイルの扱い:

Analytics、Crashlytics、Performance Monitoringを使用する場合は、GoogleService-Info.plistとgoogle-services.jsonが必要です。
これらのサービスを使用しない場合は、firebase_options_*.dartの設定だけで十分です。

gitignoreの設定:

以下のファイルはバージョン管理から除外しましょう。

**/ios/Flutter/Dart-Defines.xcconfig
**/android/app/google-services.json

コード内での環境判定:

FlavorConfigクラスを使って環境によって処理を分岐させることができます。

if (FlavorConfig.isDevelopment) {
  // 開発環境のみの処理
} else if (FlavorConfig.isProduction) {
  // 本番環境のみの処理
}

最後に

Flutterアプリケーションでの環境分け(dev、stg、prod)の実装方法について解説しました。環境ごとに異なる設定(アプリ名、バンドルID、Firebase設定など)を管理することで、開発からリリースまでのワークフローをスムーズに進めることができます。
効率的なFlutterアプリケーション開発に役立てられれば幸いです!

Discussion