はじめに
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プラットフォームの環境切り分けのお作法からするとややトリッキーに映ることがあります。
ゆえにこの方法を採用することで将来的にプラットフォーム側で想定した仕組みと合わず、つまずく可能性も少なからずあることはご承知おきください
前提
今回は本番環境(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を作成します。
{
"flavor": "prod",
"appIdSuffix": "",
"appName": "YourApp",
}
{
"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 {
defaultConfig {
...
+ applicationIdSuffix appIdSuffix
+ manifestPlaceholders["label"] = appName
+ manifestPlaceholders["icon"] = "@mipmap/ic_launcher_$flavor"
}
}
<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]
とします。
2. dart-define-from-file
で定義した要素をビルド時に反映させる
iOS側もdart-define-from-file
で定義した内容はpbxprojやInfo.plist内で参照できます。
ios/Runner.xcworkspaceをXcodeで開いてください。
アプリ名
アプリ名はInfo.plistに定義されているのでCFBundleDisplayNameとCFBundleNameを定義した$(appName)
に変更します。
<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に一致するように情報を更新してください。
今回でいうと下記のような形になります。
- PRODUCT_BUNDLE_IDENTIFIER = com.yourdomain.yourapp;
+ PRODUCT_BUNDLE_IDENTIFIER = com.yourdomain.yourapp$(appIdSuffix);
project.pbxprojを直接書き換えるのはおすすめしません。Xcodeから操作してください。
アプリアイコン
アプリアイコンもBundle Identifierと同様にpbxproj内のBuild Settingsから変更します。
Primary App Icon Set Name
の内容を環境別のアプリアイコン名に一致するように書き換えてください。
- ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon";
+ ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-$(flavor)";
project.pbxprojを直接書き換えるのはおすすめしません(2回目)
Web側の設定
Webはデプロイ先を分ければブラウザのURLで環境の識別が可能なので、基本的にはプラットフォーム側で環境ごとにあれこれ変える必要性は薄いです。
また、例えばfaviconを環境ごとに変えたいとなった場合index.htmlの段階で切り分けることになりますが、htmlにはビルドフェーズが存在しないため動的に値をセットするのにdart側からDOMを書き換える手法を取ります。
iOS/Androidのやり方に比べると若干力技感が強くあまりおすすめしませんが、どうしてもindex.htmlの内容を出し分ける必要があるという場合にのみ下記を参考にしてください。
1. 環境別のアイコンを用意してflavor別に配置する
iOS/Androidと同様に環境別にfavicon_[FLAVOR].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を追加します。
dependencies:
universal_html: ^2.2.3
Flutter側の実行時にdart-define-from-file
から読み取った値ををdispatchEventで流します。
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側で受け取り、変更したい属性に上書きします。
<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ごとに処理を分けたい場合は下記のようなコードになります。
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を設定しておきます。
$ flutter pub add firebase_core
2. Firebaseプロジェクトのセットアップ
flutterfire configure
を実行しますが、下記手順を環境の数分だけ実行します。
firebase関連の設定値はそれぞれ固定の名前のjson/plistファイルで吐き出されるため、別々に管理できるようにリネーム処理を加えます。
環境ごとにflutter configure
の実行
$ 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に移してそちらから読み込む形に変更します。
キー名は任意ですが下記のような形になるかと。
{
"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
も下記のように書き換えます。
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の設定ファイルをリネーム
$ 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
に下記のスクリプトを追記します。
...
+ 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で例外指定しておくことをおすすめします。
...
+ # Manual rules.
+ app/google-services.json
iOS側の設定
iOS側はpbxproj内のBuild Phasesにスクリプトを追加してリネーム処理を割り込ませます。
Xcodeでプロジェクトを開いて、左上の+ボタンからNew Run Script Phase
をクリックして追加してください。
内容は下記のようにリネーム処理を挿入します。
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つを追加してください。
${SRCROOT}/Runner/GoogleService-Info.plist
${SRCROOT}/firebase_app_id_file.json
フェーズ自体をドラッグすると順番を入れ替えられるためCopy Bundle Resourcesより上に設定してください。
完成すると下記のような形になります。
また、Android側と同様にGoogleService-Info.plist
とfirebase_app_id_file.json
は環境を切り替えるごとに内容が変わるため、.gitignoreで例外指定をしてください。
...
+ # Manual rules.
+ Runner/GoogleService-Info.plist
+ firebase_app_id_file.json
Web側の設定
Dart側でFirebaseの初期化ができるためプラットフォーム側での手順は不要です。
4. Flutter側のFirebaseの初期化
最後にFirebaseの初期化処理をFlutter側で記述します。
ここはドキュメント通りで問題ありません。
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時にパラメータを流し込むだけで環境は切り替えられるはずです。
具体的には下記のコマンドを実行します。
$ flutter run --dart-define-from-file=dart_defines/[FLAVOR].json
--dart-define-from-file
にjsonファイル名を指定して渡してください。
ちなみにAndroid StudioなどのIntelliJ系IDEから実行する場合は下記のようにRun/Debug Configurationsを調整してあげればOKです。
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環境を複数用意したいとか、同じソースだけど別コンテンツを配信するアプリを作りたいとか、そういった場合に活用するのがよさそうです。