🏗️

複数Firebaseプロジェクト対応!Flutterの環境別構成ファイル管理

に公開

はじめに

こんにちは、スペースマーケットでモバイルアプリの開発を楽しんでいる王です。
最近気温変化が激しく風邪気味ですが、皆さんも体調管理しっかりしましょう。

弊社のホスト向けアプリ「SPACEMARKET for HOST」はOAuth認証の技術負債返却において、
環境ごとに複数のFirebase Projectを追加する必要がありました。
今回は環境ごとに複数のFirebase Projectを切り替える方法について紹介したいと思います。

環境

  • Flutter 3.13.9
  • Dart 3.1.5

本記事で紹介しないこと

  • Firebaseコンソール上の設定

開発環境を分ける

Flutterでは開発環境を複数に分けて構築する方法がいくつかありますが、
本記事では--dart-define-from-fileオプションを使用する方法をご紹介します。

まず、環境ごとに変数をまとめるファイルを作成し、必要な項目を定義します。
DEFINE_BUILD_ENV=で環境を指定します。

ファイル名と配置場所は自由に設定できますが、プロジェクト内での統一を意識しましょう!

dart_defines/dev.env
onesignalAppId="xxxx-xxxx-xxxx-xxxx"
appName="SPACEMARKET for HOST debug"
androidIcon="@mipmap/debug_launcher_icon"
DEFINE_BUILD_ENV=dev
dart_defines/prod.env
onesignalAppId="xxxx-xxxx-xxxx-xxxx"
appName="SPACEMARKET for HOST"
androidIcon="@mipmap/launcher_icon"
DEFINE_BUILD_ENV=prod

そして、flutter run/buildコマンドに--dart-define-from-fileというオプションをつけることで環境を指定することができます。

# dev環境でflutterアプリを起動する
flutter run --dart-define-from-file=dart_defines/dev.env

これで環境を分けることができました。

準備

Firebaseを使うための準備は公式ドキュメントを参考にしてください。
https://firebase.google.com/docs/flutter/setup?hl=ja&platform=ios

ただ、複数のFirebase Projectが必要なので、
環境×Firebase Projectの数×プラットフォームの構成ファイル

  • iOS: GoogleService-Info.plist
  • Android: google-services.json

が必要です。
今回は2つのFirebase Projectが必要なので、
GoogleService-Info.plistgoogle-services.jsonそれぞれ4つダウンロードする必要があります。

やり方

共通

iOS/Androidは共通なので、先に説明します。

iOS/Androidの配下にdevとprodそれぞれの構成ファイルをおいて、ビルドする時に環境を見て正しい構成ファイルをコピーする

という力技で実現します。
例として、自分のファイル構成が以下になります。

iOS
├── GoogleService-Info.plist(コピーされた構成ファイル)
├── GoogleService-Info-Auth.plist(コピーされた構成ファイル)
└── FirebaseConfig
    ├── dev
    │   ├── GoogleService-Info-Dev.plist
    │   └── GoogleService-Info-Dev-Auth.plist
    └── prod
        ├── GoogleService-Info-Prod.plist
        └── GoogleService-Info-Prod-Auth.plist
Android
└── app
    ├── google-services.json(コピーされた構成ファイル)
    ├── google-services-auth.json(コピーされた構成ファイル)
    └── src
        └── config
            ├── dev
            │   └── google-services-auth.json
            └── prod
                ├── google-services.json // google-servicesは共通なので一個のみ
                └── google-services-auth.json

iOS側

まずコピー用のスクリプトを書いていきます

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

# IFS でカンマ区切りの値を配列として読み込む
IFS=',' read -r -a define_items <<< "$DART_DEFINES"

# 各項目をデコード
for index in "${!define_items[@]}"
do
    define_items[$index]=$(base64decode "${define_items[$index]}")
done

# DEFINE_BUILD_ENV の値を抽出する
DEFINE_BUILD_ENV=""
for item in "${define_items[@]}"; do
    if [[ $item == DEFINE_BUILD_ENV* ]]; then
        DEFINE_BUILD_ENV=$(echo $item | cut -d'=' -f2)
    fi
done

# DEFINE_BUILD_ENV の値で判断してファイルをコピー
if [[ $DEFINE_BUILD_ENV == *"dev"* ]]; then
    cp ${SRCROOT}/FirebaseConfig/dev/GoogleService-Info-Auth-Dev.plist ${SRCROOT}/GoogleService-Info-Auth.plist
    cp ${SRCROOT}/FirebaseConfig/dev/GoogleService-Info-Dev.plist ${SRCROOT}/GoogleService-Info.plist
    echo "Your DEFINE_BUILD_ENV is '$DEFINE_BUILD_ENV'"
elif [[ $DEFINE_BUILD_ENV == *"prod"* ]]; then
    cp ${SRCROOT}/FirebaseConfig/prod/GoogleService-Info-Auth-Prod.plist ${SRCROOT}/GoogleService-Info-Auth.plist
    cp ${SRCROOT}/FirebaseConfig/prod/GoogleService-Info-Prod.plist ${SRCROOT}/GoogleService-Info.plist
    echo "Your DEFINE_BUILD_ENV is '$DEFINE_BUILD_ENV'"
else
    echo "configuration didn't match to 'dev' or 'prod'"
    echo "Your DEFINE_BUILD_ENV is '$DEFINE_BUILD_ENV'"
    exit 1
fi

そしてこのスクリプトを以下の箇所に追加します。

ビルドする前に構成ファイルをコピーする必要があるため、Project Targets -> Build PhasesではなくEdit Schemaのpre-actionsに追加。

また、Archiveへの追加が漏れると実際にアーカイブしたアプリが動かなくなるので絶対に追加してください!

Android側

android/app/build.gradleへ以下のコードを追加

def dartEnvironmentVariables = [:]

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

// Firebase構成ファイルを android/app ディレクトリにコピーします
task copyGoogleServicesAuthJson {
    doFirst {
        copy {
            from "src/config/${dartEnvironmentVariables.DEFINE_BUILD_ENV}/google-services-auth.json"
            into './'
        }
    }
}

// google-services.jsonはdev/prodで共通のため、コピーして android/app ディレクトリに移動
def googleServicesJson = "src/config/prod/google-services.json"
task copyGoogleServicesJson  {
    doFirst {
        copy {
            from "src/config/prod/google-services.json"
            into './'
        }
    }
}

// firebaseファイルのコピータスクを待ってからファイルが必要な処理を行うよう設定
tasks.configureEach { task ->
    if (task.name == 'processDebugGoogleServices') {
        task.dependsOn copyGoogleServicesJson
        task.dependsOn copyGoogleServicesAuthJson
    }
    if (task.name == 'processReleaseGoogleServices') {
        task.dependsOn copyGoogleServicesJson
        task.dependsOn copyGoogleServicesAuthJson
    }
}

感想

今回の取り組みで一番面倒だったのは、Flutter側から環境ごとの構成ファイルを動的に指定できないことでした。

ネイティブであれば、例えばアプリの初期化時に環境変数を読み取り、適切な構成ファイルをファイル名で指定して読み込むことができます。

ところが、Flutterにはそのような機構が存在しません。(このタスク自体が2024/11月でやったので今ならあったかもしれません)

さらにやっかいなのが、なぜか自分の環境ではビルド前に構成ファイルが正しい場所に存在していないと、そもそもビルド自体が失敗するという点です。

ネイティブコード側に処理を書くことも検討しましたが、Firebaseの初期化処理がアプリのライフサイクルよりも早く走るため、アプリ起動時にファイルを差し替えるのでは遅すぎるという制約がありました🫠

そのため、最終的には「ビルド前に必要な構成ファイルをコピーしておく」という力技に落ち着きました。

この制約があることで、CI/CDやローカルビルドにおいても、ファイルコピーの処理を常に忘れず仕込んでおく必要があります。

最後に

今回は、Flutterで環境ごとに設定を切り替える方法と、Firebase構成ファイルの管理手法について紹介しました。

ファイルをコピーして使うという手法で目的を実現しましたが、もっといいやり方がありましたら、ぜひコメントで教えていただけますと幸いです。

参考

https://zenn.dev/altiveinc/articles/separating-environments-in-flutter

スペースマーケット Engineer Blog

Discussion