ある朝、配信前の修正を試そうとしてテスト用ビルドを自分の iPhone に入れたところ、毎日使っている本番アプリのアイコンが消えていました。bundle ID が同じだったため、テストビルドが本番インストールをそのまま上書きしてしまったのです。再ログイン、課金状態の復元、通知の許可ダイアログ。失ったのは10分ほどでしたが、「自分の常用端末でうかつにテストできない」という制約は、その後の開発速度を地味に削っていきました。
個人開発で複数のアプリを回していると、この摩擦はそのまま生産性に直結します。ここでは Rork が生成した Expo アプリを dev・staging・本番の3つに分け、同じ端末に並べて運用するための環境分離設計を、私自身が壁紙アプリ群で実際に使っている構成をもとにまとめます。
セットアップの全体像
具体的な設定に入る前に、やることは3つだけだと整理しておきます。
`app.config.ts` を `APP_VARIANT` で動的化し、環境ごとに bundle ID とアプリ名を変える
`eas.json` に development / preview / production の3プロファイルを用意し、各プロファイルで `APP_VARIANT` を固定する
通知・分析・課金などの外部サービス資格情報を、環境ごとに別物へ差し替える
この3点が揃うと、同じソースコードから別人格のアプリが3つ生まれ、同じ端末に共存できるようになります。
なぜ bundle ID をひとつにすると詰むのか
Rork が最初に吐き出すコードは、たいてい bundle ID(iOS の bundleIdentifier / Android の package)が1つだけです。プロトタイプの段階ではこれで十分なのですが、リリース後は次の3つで必ずつまずきます。
まず、本番アプリとテストアプリを同じ端末に共存できません。OS は bundle ID でインストールを識別するため、同じ ID のビルドは上書きされます。次に、EAS Update(OTA 配信)のチャンネルが混線します。テスト用に投げた JS バンドルが、運悪く本番ユーザーに届くことがあるのです。最後に、分析・課金・通知のデータが汚れます。あなたのデバッグ操作が本番の DAU やコンバージョンに混ざり、数字が読めなくなります。
解決の方針はシンプルです。環境を「dev(手元の開発)」「staging(配信前の検証)」「production(本番)」の3つに分け、それぞれに別の bundle ID・別のアプリ名・別のサービス資格情報を与えます。
app.config.ts を APP_VARIANT で動的に切り替える
静的な app.json を app.config.ts に置き換え、環境変数 APP_VARIANT を見て値を組み立てます。これが環境分離の心臓部です。
// app.config.ts
import { ExpoConfig, ConfigContext } from "expo/config" ;
const VARIANT = process.env. APP_VARIANT ?? "development" ;
const variants = {
development: {
name: "MyApp (Dev)" ,
bundleId: "net.dolice.myapp.dev" ,
scheme: "myapp-dev" ,
icon: "./assets/icon-dev.png" ,
},
preview: {
name: "MyApp (Stg)" ,
bundleId: "net.dolice.myapp.stg" ,
scheme: "myapp-stg" ,
icon: "./assets/icon-stg.png" ,
},
production: {
name: "MyApp" ,
bundleId: "net.dolice.myapp" ,
scheme: "myapp" ,
icon: "./assets/icon.png" ,
},
} as const ;
export default ({ config } : ConfigContext ) : ExpoConfig => {
const v = variants[ VARIANT as keyof typeof variants];
return {
... config,
name: v.name,
slug: "myapp" ,
scheme: v.scheme,
icon: v.icon,
ios: { ... config.ios, bundleIdentifier: v.bundleId },
android: { ... config.android, package: v.bundleId },
extra: { variant: VARIANT },
};
};
ポイントは slug を3環境で同一に保つことです。slug は EAS のプロジェクト識別子なので、ここを分けると別プロジェクト扱いになり、ビルド履歴やシークレットが分断されてしまいます。分けるのは bundle ID と表示名であって、プロジェクトそのものではありません。
ホーム画面で見分けるためのアイコンと名前
同じ端末に3つ並べると、見た目が同じでは事故が起きます。私は本番のアイコンをベースに、dev には赤帯、staging には黄帯をオーバーレイした画像を用意しています。名前も (Dev) (Stg) を末尾に付けるだけで、ホーム画面とスポットライト検索で即座に区別できます。
地味ですが、この「ひと目で分かる」状態が、本番を触っているつもりで dev を操作する、あるいはその逆、というヒューマンエラーを実用上ほぼゼロにしてくれます。
eas.json を3層のビルドプロファイルに整理する
ビルド時に APP_VARIANT を流し込むのは eas.json の役割です。プロファイルごとに環境変数を固定します。
{
"build" : {
"development" : {
"developmentClient" : true ,
"distribution" : "internal" ,
"env" : { "APP_VARIANT" : "development" }
},
"preview" : {
"distribution" : "internal" ,
"channel" : "preview" ,
"env" : { "APP_VARIANT" : "preview" }
},
"production" : {
"autoIncrement" : true ,
"channel" : "production" ,
"env" : { "APP_VARIANT" : "production" }
}
}
}
3環境の役割分担を整理すると、次のようになります。
環境 用途 bundle ID 配布 更新チャンネル
development 手元での開発・dev client ...myapp.dev internal (OTA なし)
preview 配信前の実機検証・TestFlight ...myapp.stg internal preview
production ストア配信 ...myapp store production
ビルドは eas build --profile preview のようにプロファイルを指定するだけです。APP_VARIANT を取り違える心配は、CI 側でプロファイルに固定してあるため起きません。
通知・分析・課金を環境ごとに隔離する
bundle ID を分けても、アプリが参照する外部サービスのキーが本番と同じでは、データ汚染は止まりません。extra.variant を起点に、サービス設定を環境ごとに切り替える小さなマップを1つ用意します。
// config/services.ts
import Constants from "expo-constants" ;
const variant = (Constants.expoConfig?.extra?.variant as string ) ?? "development" ;
const table = {
development: {
apiBaseUrl: "https://dev-api.example.com" ,
revenueCatKey: "YOUR_RC_KEY_DEV" ,
analyticsKey: "YOUR_ANALYTICS_KEY_DEV" ,
sendAnalytics: false ,
},
preview: {
apiBaseUrl: "https://stg-api.example.com" ,
revenueCatKey: "YOUR_RC_KEY_STG" ,
analyticsKey: "YOUR_ANALYTICS_KEY_STG" ,
sendAnalytics: true ,
},
production: {
apiBaseUrl: "https://api.example.com" ,
revenueCatKey: "YOUR_RC_KEY_PROD" ,
analyticsKey: "YOUR_ANALYTICS_KEY_PROD" ,
sendAnalytics: true ,
},
} as const ;
export const services = table[variant as keyof typeof table];
ここで実体験から強くお勧めしたいのが、RevenueCat を環境ごとに分けることです。同じ API キーを使い回すと、デバッグで作ったサンドボックスの購入が本番の課金率や LTV に混ざり、実態より数%高い課金率を見て判断を誤りかねません。プロジェクト(あるいは少なくとも App 単位)を分け、sendAnalytics: false を dev に置いて、手元の操作が数字に乗らないようにしておくと、後から分析するときに自分を責めずに済みます。
AdMob も同様で、dev・staging では必ずテスト広告ユニットを使います。本番ユニットを実機テストで叩くと、無効なトラフィックとみなされて配信が止まるリスクがあるためです。
EAS Update のチャンネルを環境とそろえる
OTA 配信の事故は、チャンネルとプロファイルがずれていると起こります。更新を投げるときも、ビルド時と同じチャンネル名を必ず明示します。
# staging への OTA
eas update --branch preview --message "checkout fix"
# 本番への OTA
eas update --branch production --message "checkout fix"
eas.json で各プロファイルに channel を固定してあるので、preview ビルドは preview ブランチの更新だけを受け取り、production ビルドには届きません。「テストのつもりが本番に流れた」という最悪の事故は、この対応関係をそろえるだけで防げます。
運用で踏みがちな落とし穴
実際に回してみると、いくつか定番のつまずきがあります。
Google Play では、bundle ID(package 名)ごとに別アプリ扱いになるため、staging 用の package をうっかり本番トラックにアップロードすると署名鍵の不一致で弾かれます。アップロード前に APP_VARIANT=production でビルドしたかを確認する習慣をつけてください。
通知トークンと ATT の状態も環境ごとに別管理になります。dev で許可した通知許可が staging に引き継がれることはありません。これは仕様どおりですが、「通知が来ない」と慌てる前に、どのビルドで許可したかを思い出すと早く解決します。
最後に、expo-constants の extra は OTA 更新では新しい値に差し替わらない場合があります。サービスのエンドポイントのように更新で変えたい値は extra ではなく expo-updates の channel 判定や、サーバー側のリモート設定に寄せるほうが安全です。
環境を分ける作業は地味で、最初のセットアップに半日ほどかかります。けれども、自分の常用端末で本番を壊す不安なくテストできるようになると、リリース前の確認が驚くほど気軽になります。個人開発は確認の手数をどれだけ減らせるかの勝負でもあるので、私はこの初期投資をいつも真っ先に回収しています。お読みいただきありがとうございました。