LoginSignup
25
9
お題は不問!Qiita Engineer Festa 2023で記事投稿!

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

Last updated at Posted at 2023-06-29

はじめに

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

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

Flutter自体のプロジェクトセットアップや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

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

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

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

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

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

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

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

dart_defines/prod.json
{
  "flavor": "prod",
  "appIdSuffix": "",
  "appName": "YourApp",
}
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とします。

実際にアプリリリースする際はAdaptiveIconに対応することをオススメしますが今回は割愛します。

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

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

android/app/build.gradle
android {
    defaultConfig {
        ...

+       applicationIdSuffix appIdSuffix
+       manifestPlaceholders["label"] = appName
+       manifestPlaceholders["icon"] = "@mipmap/ic_launcher_$flavor"
    }
}
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

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

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

アプリ名

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

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

余談ですが、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に一致するように情報を更新してください。
今回でいうと下記のような形になります。

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

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

アプリアイコン

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

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

スクリーンショット 2023-06-02 13.54.47.png

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

最初からある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を追加します。

pubspec.yaml
dependencies:
  universal_html: ^2.2.3

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

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側で受け取り、変更したい属性に上書きします。

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ごとに処理を分けたい場合は下記のようなコードになります。

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

上記ではわかりやすさのために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を設定しておきます。

shell
$ flutter pub add firebase_core

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

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

環境ごとにflutter configureの実行

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に移してそちらから読み込む形に変更します。
キー名は任意ですが下記のような形になるかと。

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も下記のように書き換えます。

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の設定ファイルをリネーム

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に下記のスクリプトを追記します。

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で例外指定しておくことをおすすめします。

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

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

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つを追加してください。

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

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

スクリーンショット 2023-06-02 17.27.44.png

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

ios/gitignore
...

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

Web側の設定

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

4. Flutter側のFirebaseの初期化

最後にFirebaseの初期化処理をFlutter側で記述します。
ここはドキュメント通りで問題ありません。

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時にパラメータを流し込むだけで環境は切り替えられるはずです。
具体的には下記のコマンドを実行します。

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

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.jsongoogle-services.json,GoogleService-Info.plist,firebase_app_id_file.jsonあたりを上書きしてあげるスクリプトを組むなどでしょうか。
上記アプローチであればファイルの上書きコピー等のステップをCIに任せることで、ビルド時のGradleやXcodeによるコピースクリプトはなくすことができます。

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

参考

25
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
9