FlutterでFirebaseを含むビルド環境切り分けをdart-define-from-fileとflutterfire_cliを駆使して行う

# はじめに

Flutter 3.7より`dart-define-from-file`が使えるようになり複数のビルド環境の切り替えがネイティブの`Flavor`を使う方式に比べ簡単になったので、Firebaseを含むFlutterアプリのビルド環境ごとの出し分け方法をまとめてみます。

### この記事で説明しないこと

Flutter自体のプロジェクトセットアップや[flutterfire_cli](https://pub.dev/packages/flutterfire_cli)の基本的な使い方は説明しません。

# 方針

- 出し分けの内容はできる限り`dart-define-from-file`に集約していきます。
- プラットフォーム側で用意されているAndroidのFlavorやiOSのConfigurationsは使わず、`dart-define-from-file`から値を注入して対応します。
- flutterfire_cliもできる限り活用していきます。
- **Android, iOS, Web**をターゲットとします。
    - が、たぶんMacアプリもiOSとだいたい似たような感じじゃないかな？

### おことわり

Flavorを作らない手法はFlutter側から見た限りではとてもシンプルですが、本来のiOS/Androidプラットフォームの環境切り分けのお作法からするとややトリッキーに映ることがあります。
ゆえにこの方法を採用することで将来的にプラットフォーム側で想定した仕組みと合わず、つまずく可能性も少なからずあることはご承知おきください :bow: 

# 前提

今回は本番環境(prod)と開発環境(dev)の2種類に出し分ける想定で考えます。
3つ,4つと増えたとしても手順は変わらず作業の回数が増えるだけなのでご自身に適した環境に置き換えて読んでください。

出し分ける対象
- **ApplicationId/BundleIdentifier**
- **アプリ名**
- **アプリアイコン**
- **Dartコード内における条件分岐**
- **Firebaseプロジェクト**

| 環境 | Flavor名 | ApplicationId/Bundle Identifier | アプリ名 |
|---|---|---|---|
| 本番環境 | prod | com.yourdomain.yourapp | YourApp |
| 開発環境 | dev | com.yourdomain.yourapp.dev | YourApp Dev |

:::note info
今回のやり方はすべてのアプリアイコン画像がバイナリ内に含まれてしまいますが、ユーザーから直接見えるわけではなくセキュリティ的にも大きな問題はないと思われるのでその部分は許容してシンプルさを重視しています。
:::

# AppId,アプリ名,アプリアイコンの出し分け

まず、比較的簡単なFirebase以外の部分の出し分けについて解説していきます。

## `dart-define-from-file`のjsonの定義

まず、`dart-define-from-file`用のjsonファイルを用意します。
場所はどこでも構わないですがこの場では`dart_defines/[FLAVOR].json`とします。
要素として下記の３つを定義します。

- **flavor**: 環境名
- **appIdSuffix**: applicationId/BundleIdentifierのsuffix名
- **appName**: アプリ名

環境ごとに用意するので今回のケースでは下記の２つのjsonを作成します。

```json: dart_defines/prod.json
{
  "flavor": "prod",
  "appIdSuffix": "",
  "appName": "YourApp",
}
```
```json: dart_defines/dev.json
{
  "flavor": "dev",
  "appIdSuffix": ".dev",
  "appName": "YourApp dev",
}
```

## Android側の設定

### 1. 環境別のアイコンを用意してflavor別に配置する

Android側のiconを環境別に用意してデフォルトの`android/app/src/main/res/mipmap-*/ic_launcher.png`と置き換えます。
flavor名を使って区別できるようにリネームしておきます。

この場では`ic_launcher_[FLAVOR].png`とします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129872/8faff3f8-b2a1-7051-98df-4f2f985fb9dd.png" width=320 />

:::note warn
実際にアプリリリースする際は[AdaptiveIcon](https://developer.android.com/guide/practices/ui_guidelines/icon_design_adaptive?hl=ja)に対応することをオススメしますが今回は割愛します。
:::

### 2. `dart-define-from-file`で定義した要素をビルド時に反映させる

`dart-define-from-file`で定義した内容はbuild.gradle内で直接参照できます。
今回はアプリ名やアプリアイコンを変えたいので、`android/app/build.gradle`内でmanifestPlaceholdersを使ってAndroidManifest.xmlに情報を渡します。

```diff_groovy:android/app/build.gradle
android {
    defaultConfig {
        ...

+       applicationIdSuffix appIdSuffix
+       manifestPlaceholders["label"] = appName
+       manifestPlaceholders["icon"] = "@mipmap/ic_launcher_$flavor"
    }
}
```

```diff_groovy:android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application
-       android:label="yourapp"
+       android:label="${label}"
        android:name="${applicationName}"
-       android:icon="@mipmap/ic_launcher">
+       android:icon="${icon}">
            ...
    </application>
</manifest>
```

Android側は以上となります。

## iOS側の設定

### 1. 環境別のアイコンを用意してflavor別に配置する

iOS側も同じく、環境別にアイコン用意してデフォルトの`Assets.xcassets`内のAppIconと置き換えます。
flavor名を使って区別できるようにリネームしておきます。

この場では`AppIcon-[FLAVOR]`とします。

![スクリーンショット 2023-06-02 13.48.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129872/74e4d776-a76d-d889-ee11-2c8b5cc5d193.png)

### 2. `dart-define-from-file`で定義した要素をビルド時に反映させる

iOS側も`dart-define-from-file`で定義した内容はpbxprojやInfo.plist内で参照できます。
ios/Runner.xcworkspaceをXcodeで開いてください。

#### アプリ名

アプリ名はInfo.plistに定義されているのでCFBundleDisplayNameとCFBundleNameを定義した`$(appName)`に変更します。

```diff_plist:ios/Runner/Info.plist
<dict>
    <key>CFBundleDisplayName</key>
-   <string>yourapp</string>
+   <string>$(appName)</string>
</dict>
```

:::note info
余談ですが、Xcode13以降でiOSやMac等の新規プロジェクトを生成するとInfo.plistは生成されず、アプリ名等の設定はpbxproj内のBuild Settingsの統合されています。
が、少なくとも現状(Flutter 3.10.3)のFlutterの新規プロジェクトテンプレートではInfo.plistはまだ存在しているようです。
ゆえに将来的にプロジェクトテンプレートがアップデートされた場合は記述場所が変わる可能性はあります。
:::

#### Bundle Identifier

Bundle Identifierはpbxproj内のBuild Settingsから変更します。
`Product Bundle Identifier`に環境別のIdに一致するように情報を更新してください。
今回でいうと下記のような形になります。

```diff:ios/Runner.xcodeproj/project.pbxproj
- PRODUCT_BUNDLE_IDENTIFIER = com.yourdomain.yourapp;
+ PRODUCT_BUNDLE_IDENTIFIER = com.yourdomain.yourapp$(appIdSuffix);
```

![スクリーンショット 2023-06-02 13.55.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129872/3a570158-9abc-a73e-65b2-72e148f27f71.png)

:::note warn
project.pbxprojを直接書き換えるのはおすすめしません。Xcodeから操作してください。
:::


#### アプリアイコン

アプリアイコンもBundle Identifierと同様にpbxproj内のBuild Settingsから変更します。
`Primary App Icon Set Name`の内容を環境別のアプリアイコン名に一致するように書き換えてください。

```diff:ios/Runner.xcodeproj/project.pbxproj
- ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon";
+ ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-$(flavor)";
```

![スクリーンショット 2023-06-02 13.54.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129872/3bd19be9-720f-348d-a277-4218fed83d46.png)

:::note warn
project.pbxprojを直接書き換えるのはおすすめしません（2回目）
:::

## Web側の設定

Webはデプロイ先を分ければブラウザのURLで環境の識別が可能なので、基本的にはプラットフォーム側で環境ごとにあれこれ変える必要性は薄いです。

また、例えばfaviconを環境ごとに変えたいとなった場合index.htmlの段階で切り分けることになりますが、htmlにはビルドフェーズが存在しないため動的に値をセットするのにdart側からDOMを書き換える手法を取ります。
iOS/Androidのやり方に比べると若干力技感が強くあまりおすすめしませんが、**どうしてもindex.htmlの内容を出し分ける必要があるという場合にのみ下記を参考にしてください。**

### 1. 環境別のアイコンを用意してflavor別に配置する

iOS/Androidと同様に環境別に`favicon_[FLAVOR].png`を用意して置き換えてください。

![スクリーンショット 2023-06-05 1.22.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129872/ea8efc5f-2b40-803d-3692-89b21f833e2f.png)

:::note warn
最初からあるfavicon.pngに関してはデフォルトだとFlutterが実行されるまでの読み込み中に表示されるのでindex.htmlのicon要素を書き換えるなり、favicon_prod.pngをコピーするなりして読み込み中にアイコンが欠落しないようにしておいたほうがいいです。
:::

### 2. `dart-define-from-file`で定義した要素を実行時に反映させる

WebではappIdやアプリ名は反映させる箇所がないので、アイコン（favicon）のみを対象とします。
DOMを操作するので、`import 'dart:js';`と`import 'dart:html';`をインポートしたい所ですが、importするとWeb以外のプラットフォームの実行時にエラーが出てしまうのでサードパーティの[universal_html](https://pub.dev/packages/universal_html)を追加します。

```yaml:pubspec.yaml
dependencies:
  universal_html: ^2.2.3
```

Flutter側の実行時に`dart-define-from-file`から読み取った値ををdispatchEventで流します。

```dart:libs/main.dart
import 'package:universal_html/html.dart';
import 'package:universal_html/js.dart';

Future<void> main() async {
  if (kIsWeb) {
    context['flavor'] = const String.fromEnvironment('flavor');
    document.dispatchEvent(CustomEvent('dart_loaded'));
  }

  runApp(const MyApp());
}
```

index.html側で受け取り、変更したい属性に上書きします。

```html:web/index.html
<body>
  <script>
    document.addEventListener("dart_loaded", function() {
      document
        .querySelector('link[rel="icon"]')
        .setAttribute('href', `favicon_${window.flavor}.png`)
    });
    ...
  </script>
</body>
```

## Dartコード内の出し分けについて

Dartコード内で`dart-define-from-file`の要素を使うには`String.fromEnvironment('KEY')`や`int.fromEnvironment('KEY')`等を利用します。
その上で、例えばflavorごとに処理を分けたい場合は下記のようなコードになります。

```dart:libs/main.dart
const flavor = String.fromEnvironment('flavor');
switch (flavor) {
  case 'prod':
    // 本番環境
  case 'dev':
    // 開発環境
  default:
    throw ArgumentError('Not available flavor');
}
```

:::note warn
上記ではわかりやすさのためにflavorによる分岐を紹介しましたが、個人的にはflavor名を使った分岐処理はできる限り避けるべきだと思っています。
flavorの増減により処理が崩壊する可能性があるため、分岐したい内容に合わせてより具体的なフラグを`dart-define-from-file`に書き込むことをおすすめします。

例えば開発時のみログを出したい場合は`if (flavor != 'prod')`等で区別するのではなく、`isShowLog: true/false`のような要素をjsonに持つなどです。
:::

jsonの値は自由に取得できるのでjsonの定義を追加することでアクセスするAPIサーバーの向き先を変えたりすることも容易になるでしょう。

# Firebaseプロジェクトの出し分け

続いてFirebaseを利用している場合にビルド環境ごとに切り替える方法について紹介します。
Firebase使ってないよ！という方は読み飛ばしてもらって大丈夫です。

flutterfire_cliのセットアップは済ませていることを前提とします。
環境別のFirebaseプロジェクトは`flutterfire configure`時に作ることもできますが、事前に作成しておいたほうがスムーズです。

## 1. firebase_coreのセットアップ

[firebase_core](https://pub.dev/packages/firebase_core)を設定しておきます。

```sh:shell
$ flutter pub add firebase_core
```

## 2. Firebaseプロジェクトのセットアップ

`flutterfire　configure`を実行しますが、下記手順を環境の数分だけ実行します。
firebase関連の設定値はそれぞれ固定の名前のjson/plistファイルで吐き出されるため、別々に管理できるようにリネーム処理を加えます。

### 環境ごとに`flutter configure`の実行

```sh:shell
$ flutterfire configure --project [YOUR-PROD-PROJECT] --android-app-id com.yourdomain.yourapp[APP_ID_SUFFIX] --ios-bundle-id com.yourdomain[APP_ID_SUFFIX]
```

### 環境ごとに出力された`firebase_options.dart`内の要素を`dart-define-from-file`に移す

`FirebaseOptions`にはFirebaseプロジェクトの情報ががっつりハードコードされるため、環境ごとに切り分けるべく内容をdart-define-from-fileに移してそちらから読み込む形に変更します。
キー名は任意ですが下記のような形になるかと。

```diff: dart_defines/prod.json
{
  "flavor": "prod",
  "appIdSuffix": "",
  "appName": "YourApp",

+  "firebaseAndroidApiKey": "...",
+  "firebaseAndroidAppId": "...",
+  "firebaseAndroidMessagingSenderId": "...",
+  "firebaseAndroidProjectId": "...",
+  "firebaseAndroidStorageBucket": "...",
+
+  "firebaseIosApiKey": "...",
+  "firebaseIosAppId": "...",
+  "firebaseIosMessagingSenderId": "...",
+  "firebaseIosProjectId": "...",
+  "firebaseIosStorageBucket": "...",
+  "firebaseIosAndroidClientId": "...",
+  "firebaseIosIosClientId": "...",
+  "firebaseIosIosBundleId": "...",
+
+  "firebaseWebApiKey": "...",
+  "firebaseWebAppId": "...",
+  "firebaseWebMessagingSenderId": "...",
+  "firebaseWebProjectId": "...",
+  "firebaseWebAuthDomain": "...",
+  "firebaseWebStorageBucket": "...",
+  "firebaseWebMeasurementId": "...",
}
```

吐き出された`firebase_options.dart`も下記のように書き換えます。

```dart:libs/firebase_options.dart
class DefaultFirebaseOptions {
  ...

  static const FirebaseOptions web = FirebaseOptions(
    apiKey: String.fromEnvironment('firebaseWebApiKey'),
    appId: String.fromEnvironment('firebaseWebAppId'),
    messagingSenderId: String.fromEnvironment('firebaseWebMessagingSenderId'),
    projectId: String.fromEnvironment('firebaseWebProjectId'),
    authDomain: String.fromEnvironment('firebaseWebAuthDomain'),
    storageBucket: String.fromEnvironment('firebaseWebStorageBucket'),
    measurementId: String.fromEnvironment('firebaseWebMeasurementId'),
  );

  static const FirebaseOptions android = FirebaseOptions(
    apiKey: String.fromEnvironment('firebaseAndroidApiKey'),
    appId: String.fromEnvironment('firebaseAndroidAppId'),
    messagingSenderId: String.fromEnvironment('firebaseAndroidMessagingSenderId'),
    projectId: String.fromEnvironment('firebaseAndroidProjectId'),
    storageBucket: String.fromEnvironment('firebaseAndroidStorageBucket'),
  );

  static const FirebaseOptions ios = FirebaseOptions(
    apiKey: String.fromEnvironment('firebaseIosApiKey'),
    appId: String.fromEnvironment('firebaseIosAppId'),
    messagingSenderId: String.fromEnvironment('firebaseIosMessagingSenderId'),
    projectId: String.fromEnvironment('firebaseIosProjectId'),
    storageBucket: String.fromEnvironment('firebaseIosStorageBucket'),
    androidClientId: String.fromEnvironment('firebaseIosAndroidClientId'),
    iosClientId: String.fromEnvironment('firebaseIosIosClientId'),
    iosBundleId: String.fromEnvironment('firebaseIosIosBundleId'),
  );
}
```

### 環境ごとにFirebaseの設定ファイルをリネーム

```sh:shell
$ mv android/app/google-services.json android/app/google-services-[FLAVOR].json
$ mv ios/Runner/GoogleService-Info.plist ios/Runner/GoogleService-Info-[FLAVOR].plist
$ mv ios/firebase_app_id_file.json ios/firebase_app_id_file_[FLAVOR].json
```

## 3. ビルド時にリネームした設定ファイルを元に戻すようにスクリプトを組む

リネームしたままだと、Firebaseの設定ファイルを読み込めないためビルド時にもとに戻す必要があります。
プラットフォームごとに作業が必要です。

### Android側の設定

`android/app/build.gradle`に下記のスクリプトを追記します。

```diff_groovy:android/app/build.gradle
...

+ task copyGoogleSeviceisJson(type: Copy) {
+     from "google-services-${flavor}.json"
+     into '.'
+     rename("google-services-${flavor}.json", 'google-services.json')
+ }
+ tasks.whenTaskAdded { task ->
+     task.dependsOn copyGoogleSeviceisJson
+ }
```

また、`google-services.json`自体は環境を切り替えるごとに内容が変わるため、.gitignoreで例外指定しておくことをおすすめします。

```diff_shell:android/.gitignore
...

+ # Manual rules.
+ app/google-services.json
```

### iOS側の設定

iOS側はpbxproj内のBuild Phasesにスクリプトを追加してリネーム処理を割り込ませます。
Xcodeでプロジェクトを開いて、左上の+ボタンから`New Run Script Phase`をクリックして追加してください。

![スクリーンショット 2023-06-05 9.50.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129872/a8b5a023-61af-2cab-2ec6-bc46c0d9ab02.png)

内容は下記のようにリネーム処理を挿入します。

```sh:Copy GoogleServices-Info.plist
cp -f ${SRCROOT}/Runner/GoogleService-Info-${flavor}.plist ${SRCROOT}/Runner/GoogleService-Info.plist
cp -f ${SRCROOT}/firebase_app_id_file_${flavor}.json ${SRCROOT}/firebase_app_id_file.json
```

また、下部のOutput Filesに忘れずに下記の2つを追加してください。

```sh:Output Files
${SRCROOT}/Runner/GoogleService-Info.plist
${SRCROOT}/firebase_app_id_file.json
```

フェーズ自体をドラッグすると順番を入れ替えられるため**Copy Bundle Resourcesより上**に設定してください。
完成すると下記のような形になります。

![スクリーンショット 2023-06-02 17.27.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129872/e7e241bf-431e-b129-dc94-cd03dd3bc885.png)

また、Android側と同様に`GoogleService-Info.plist`と`firebase_app_id_file.json`は環境を切り替えるごとに内容が変わるため、.gitignoreで例外指定をしてください。

```diff_shell:ios/gitignore
...

+ # Manual rules.
+ Runner/GoogleService-Info.plist
+ firebase_app_id_file.json
```

### Web側の設定

Dart側でFirebaseの初期化ができるためプラットフォーム側での手順は不要です。

## 4. Flutter側のFirebaseの初期化

最後にFirebaseの初期化処理をFlutter側で記述します。
ここは[ドキュメント通り](https://firebase.google.com/docs/flutter/setup?platform=ios#initialize-firebase)で問題ありません。

```dart:libs/main.dart
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';

Future<void> main() async {
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(MyApp());
}
```

# 実行して確認する

ここまでの工程を適切に実装できていれば、flutter run時にパラメータを流し込むだけで環境は切り替えられるはずです。
具体的には下記のコマンドを実行します。

```sh:shell
$ flutter run --dart-define-from-file=dart_defines/[FLAVOR].json
```

`--dart-define-from-file`にjsonファイル名を指定して渡してください。
ちなみにAndroid StudioなどのIntelliJ系IDEから実行する場合は下記のようにRun/Debug Configurationsを調整してあげればOKです。

![スクリーンショット 2023-06-05 10.47.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129872/c2e652c4-3d9f-518b-810e-b600909de9a8.png)

VSCodeでも`launch.json`に似たような記述をすればボタン一つで環境切り分けができるはずです。

# おわり

`dart-define-from-file`はFlavor方式に比べネイティブ側の手順が少ないのが嬉しいですね。
Firebase周りは特定の場所にファイルを配置しなければならない関係上やや面倒ですが、ネイティブ層まで含めて機密情報を1つのjsonに集約できるのは取り回しやすいと思います。

ただ、ここまで言っておいてなんですが、ローカルで本番環境用のビルドを生成できるのは普通にあんま良くないので、今回紹介したようなdev,prodの2環境のみであれば、CI/CD上で設定ファイルを上書きさせるアプローチのほうが適していると考えています。
例えばアプリ名等を`dart-define-from-file`から伝播させる仕組みは構築しつつ、ローカルビルドはdevで固定してprodはCI/CD上で`dart_defines.json`、`google-services.json`,`GoogleService-Info.plist`,`firebase_app_id_file.json`あたりを上書きしてあげるスクリプトを組むなどでしょうか。
上記アプローチであればファイルの上書きコピー等のステップをCIに任せることで、ビルド時のGradleやXcodeによるコピースクリプトはなくすことができます。

今回の手順を全適用するケースとしてはdev環境を複数用意したいとか、同じソースだけど別コンテンツを配信するアプリを作りたいとか、そういった場合に活用するのがよさそうです。

# 参考

- https://zenn.dev/altiveinc/articles/separating-environments-in-flutter
- https://zenn.dev/blendthink/articles/392607db0a65dd
- https://stackoverflow.com/questions/65647090/access-dart-define-environment-variables-inside-index-html/65647968#65647968