おめでとう、アプリを本番にデプロイしました! ユーザーがインストールしていますが、すぐに最初のバグ報告が来ます。端末で本番ビルドを開くと、確かに問題があります。修正を作成して開発ビルドをインストールし、デバッグを始めます。ここで困るのは、1台の端末に一度にインストールできるアプリは1つだけ、という点です。合理的に聞こえますが、今週だけで本番アプリを3回アンインストールしては開発ビルドをデバッグし、また再インストールし、さらにアンインストールする――こんな手間が続きます。もっとよい方法があるはずです。
アプリバリアントは、各ビルドに固有の識別子を与えることでこの問題を解決します。つまり、dev、preview、production などのバリアントを同じデバイスに並行してインストールでき、それぞれが別アプリとして扱われます。個人的にはこれは開発体験の大きな向上で、実プロジェクトで最初に設定する項目のひとつです。
アプリバリアントは何で構成されるか
各ビルドを、独立した2つの設定――Identity(識別)と Environment(環境)を持つものとして考えてください。
-
Identity(識別): iOS の bundle identifier や Android の package name(例: com.myapp.app)のように、ネイティブビルドに埋め込まれる識別子です。これはどのインストールがユニークなアプリであるか、あるいは新しいインストールが古いものを置き換えるかを決めます。デバイスは識別子ごとに1つのアプリしか保持しないため、開発ビルドと本番ビルドが同じ識別子を共有すると、どちらか一方をインストールするともう一方が置き換えられてしまいます。各バリアントに固有の識別子を与えれば、並行してインストールできます。
注: ネイティブの識別子はネイティブコードに組み込まれるため、このガイドはローカルで作成するカスタムビルドや EAS Build を前提としています。Expo Go はすべてのプロジェクトを単一の識別子で実行するため、本ガイドの対象外です。
-
Environment(環境): アプリ設定が評価されるときに読み込まれる変数群です。EAS には組み込みの環境として development、preview、production の 3 種があり、API URL や分析キーなどの値は環境で決まります。Environment はアプリが実行中にどのように振る舞うかを決定します。
ステップ1: 異なるバリアント向けにアプリを設定する
多くのプロジェクトは現在静的な app.json を持っています。静的ファイルは安定した値には最適です。しかしバリアントを並列にインストールするには、それぞれ固有の識別子が必要です。識別子はアプリ設定に書かれるため、静的ファイルは1つしか持てません。そこで、変数に基づいて値を設定できる動的な設定が必要になります。私は型が付く app.config.ts を使うことが多いですが、プレーンな JavaScript を好むなら app.config.js でも構いません。
2つの設定ファイル
両方のファイルが存在する場合、Expo はまず app.json を読み、それを { config } として動的設定に渡し、動的設定が返した内容を使います。app.json をベースレイヤー、app.config.ts をその上の薄いオーバーライドと考えてください。動的設定は関数をエクスポートする必要があります。もし app.json が存在する状態で動的設定が単なるオブジェクトをエクスポートすると、静的ファイルは無視されます。
-
app.json は安定した値とデフォルトを保持します。
{
"expo": {
"slug": "my-app",
"owner": "your-org"
}
}
-
app.config.ts はオーバーライドを保持します。例えば:
import { ExpoConfig, ConfigContext } from "expo/config";
const appName = "MyApp";
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: appName,
});
APP_VARIANT で切り替える
バリアントを作るには、どのバリアントをビルドするかを設定に伝える必要があります。そのために APP_VARIANT という環境変数を使います(名前は慣例であり、何と呼んでも構いませんし、バリアントを増やすこともできます)。APP_VARIANT を使って識別子を選択します。例:
import { ExpoConfig, ConfigContext } from "expo/config";
const APP_ID_PREFIX = "com.myapp";
function getName(base: string) {
switch (process.env.APP_VARIANT) {
case "production":
return base;
case "preview":
return `${base} (Preview)`;
default:
return `${base} (Dev)`;
}
}
function getAppId() {
switch (process.env.APP_VARIANT) {
case "production":
return APP_ID_PREFIX;
case "preview":
return `${APP_ID_PREFIX}.preview`;
default:
return `${APP_ID_PREFIX}.dev`;
}
}
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: getName(config.name ?? "MyApp"),
ios: { ...config.ios, bundleIdentifier: getAppId() },
android: { ...config.android, package: getAppId() },
});
ここでは switch で各バリアントを明示的に分けているので、ヘルパーは読みやすく、バリアントを追加するのも新しい case を追加するだけです。識別子の共通部分は APP_ID_PREFIX に置き、各 case はサフィックスだけを設定するため、ベースが移動したときも変更箇所は1箇所です。
...config をスプレッドして app.json の値を動的設定に埋め込んでいる点に注意してください。もし config をスプレッドするのを忘れると app.json の内容をすべて失います。もっと深いレベルでも同様で、例えば ios に bundleIdentifier を設定したときに ...config.ios を先にスプレッドしないと、新しい ID は入っても ios 配下の他の値を失います。
不安なときは npx expo config を実行して、Expo が使う解決済みの設定を確認してください。機械可読な出力が欲しいときは --json を付け、jq があるなら npx expo config --json | jq .name で単一フィールドを取り出せます。
私が development、preview、production を選んだのは、eas build:configure がデフォルトで作るビルドプロファイルと一致させるためです。EAS の環境変数(最終的に変数が置かれる場所)も同じデフォルト環境を持ちます。補足として、ビルドプロファイルは好きなだけ追加できますが、組み込みの3つ以外のカスタム環境は Production および Enterprise プランで利用可能です。これらの概念については本文の後半でさらに掘り下げます。
両方のファイルを保持する理由
app.config.ts のみで運用することも可能で、多くのアプリがその方法を採っています。ただし、Expo ツールは静的な app.json に対してのみ書き込みを行います。eas build:configure や eas update:configure は app.json が存在すればそこに値を書き込み、存在しなければ手動で追加するよう促します。さらに一部サービスは静的な app.json を必須とします。例として Expo Launch は app.json がないと動作しません。なぜなら Launch は name や bundleIdentifier といった識別フィールドを書き込み、動的設定はそれを受け取れないためです。
デフォルトを development にする理由
先ほどの switch では APP_VARIANT が未設定のときに development にフォールスルーさせています。これは必須ではありませんが、私は本番の識別子は明確に要求したときにだけ使いたい派です。ローカルで実行するコマンド(expo start、expo run、expo prebuild)はバリアントを選ばせないため、デフォルトが何であれそれが使われます。ローカルではほとんどの場合開発ビルドなので、デフォルトが開発であるのは都合がよいです。
APP_VARIANT は固定されたバリアントのセットを扱うため、switch は意図的に網羅的です。デフォルトは、名前と識別子を超えて設定が分岐する場合に重要になります。例えば getAppId() の横に getBaseUrl() を追加して、結果を設定に渡し、各バリアントが独自の API URL を持つようにするケースを考えます。APP_VARIANT は設定にしか見えないため、その値をアプリケーションコードに渡すために少し工夫が必要です。
function getBaseUrl() {
switch (process.env.APP_VARIANT) {
case "production":
return "https://example.com";
case "preview":
return "https://preview.example.com";
default:
return "https://dev.example.com";
}
}
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
extra: {
...config.extra,
apiUrl: getBaseUrl()
},
});
これでデフォルトがどのバックエンドに接続するかを決定します。デフォルトを開発に落とすと開発用バックエンドを使い、デフォルトを本番にすると実際の本番に接続します。アプリによっては本番に誤って接続すると認証が弾かれるなど大きな問題になるか、テストデータで本番環境を汚してしまうかもしれません。後で APP_VARIANT を EAS の環境変数に移し、ローカルへ引き下ろすようにすれば、ほとんど常に APP_VARIANT が設定されるようになり、デフォルトが問題になることは少なくなります。
ステップ2: EAS で各バリアントをビルドする
設定が APP_VARIANT に反応するようになったので、EAS Build 側で各ビルドにその変数を設定する必要があります。最も単純なのは eas.json のプロファイルごとに env ブロックを置く方法です。例:
{
"build": {
"development": {
"developmentClient": true,
"env": {
"APP_VARIANT": "development"
}
},
"preview": {
"distribution": "internal",
"env": {
"APP_VARIANT": "preview"
}
},
"production": {
"env": {
"APP_VARIANT": "production"
},
"autoIncrement": true
}
}
}
eas build --profile development を実行すると、EAS はビルドを評価する前に APP_VARIANT=development を設定します。すると開発用の識別子が適用され、開発ビルドは本番と衝突せずにインストールできます。実は、development プロファイルはステップ1でのデフォルトが development なので変数は必須ではありません。production は明示的に設定する必要があり、その設定は通常このビルドプロファイルで行います。
変数の置き場所
eas.json の各プロファイルに APP_VARIANT を置くだけでバリアントをビルドでき、最初のうちはそれで十分です。しかしその env ブロックは eas build にしか適用されません(たとえば eas update は eas.json の env ブロックを参照しません)。実際、設定を評価する他のすべてのコマンド(ローカルの expo start や expo run を含む)は eas.json を見ず、ローカルシェルから APP_VARIANT を読みます。したがって当面は自分で設定する必要があり、APP_VARIANT=development npx expo start のようにインラインで、package.json のスクリプト内で、または .env ファイルで設定できます。
EAS はそれらの変数を保存することもできます。EAS 環境変数では、変数は eas.json 内ではなく EAS 上の「環境」に属します。これは完全に任意ですが、開発体験を改善するので推奨します。なぜなら同じ値を eas.json とローカルの .env 間で何度もコピーする手間が減るからです。環境ごとに変数を一度作成し、その後ビルドプロファイルは各変数を個別に記述するのではなく、どの環境をロードするかを名前で指定します。例:
{
"build": {
"development": {
"developmentClient": true,
"environment": "development"
}
}
}
ローカルでは eas env:pull を使って同じ値を取得します。環境の名前を指定すると、EAS はそれらの変数をローカルの .env.local に書き込みます。これで expo start や expo run は .env.local から APP_VARIANT を読み取り、インラインで設定しなくても済みます。ビルドも EAS 上の同じ環境から読み取ります。変更箇所は1箇所になるため管理が楽になります。
ローカルでバリアントをビルドする
expo run でローカルビルドする場合、変数が .env.local に入っていれば追加のセットアップは不要です。プリビルドするバリアントは APP_VARIANT の解決結果に依存するので、まずそれを設定(または環境を pull)し、バリアントを切り替えたら prebuild --clean でネイティブプロジェクトを再生成してください。そうすることで新しい識別子がネイティブ側に焼き付けられます。
補足: prebuild や CNG(Continuous Native Generation)に馴染みがない場合に説明すると、CNG はネイティブプロジェクトをソース管理にコミットする代わりに、app config、package.json、他の入力ファイルからオンデマンドでネイティブプロジェクトを生成します。EAS Build も(ネイティブプロジェクトをコミットしていない限り)同様に動作し、クラウドで毎回ゼロから再生成します。ローカルでも EAS でも、バリアントを切り替えても古いまま残るものはありません。
ローカルでビルドする場合、ほとんどは開発バリアントになります。Preview と Production は通常 EAS Build 側の仕事です。もしローカルでそれらをビルドするなら、リリース設定(リリースビルド)を使って JavaScript と設定を組み込み、ストアからのインストールと同様にデバイス上で dev サーバーなしに動作するようにしてください。その後は次の開発セッションの前に prebuild --clean で開発バリアントに戻します。そうしないと CLI はディスク上にあるバリアントに向けて QR コードを発行し続けます。
環境が実際に動くアプリに届く仕組み
バリアントを並べてビルドすると、identity と environment がずれることがあるため、環境がいつアプリに反映されるかを正確にしておきたいと思います。Identity は先に述べた通りビルド時に固定されます。ローカル開発する際は、