開発版と本番版を app バリアントと並べてインストールする

デバッグのために本番環境の App をアンインストールするのはもう終わりにしよう。App variants を使えば Expo のビルドごとに独立した identity を持たせられるので、dev、preview、production を同じ端末に同時にインストールできる。

日本語
コピー
How to set up app variants in Expo

アプリを本番環境にリリースしたばかりなら、おめでとう。ユーザーがインストールしてくれている。だが、ほどなく最初のバグ報告が届く。手元のスマホで本番版を開くと、確かに問題が再現する。修正を書き、開発版をインストールして、調査を始める。

1台のスマホにインストールできるバージョンは同時に1つだけ。大した問題には思えない——今週3度目に、開発版の何かをデバッグするために本番版をアンインストールし、入れ直し、また削除するまでは。

App variants はまさにこの問題を解決する。ビルドごとに固有の識別子を持たせることで、各バリアント(dev、preview、production など)を同じデバイスに並べてインストールでき、それぞれが独立したアプリとして扱われる。

個人的にはこれが実質的な開発体験の向上につながっており、実際のプロジェクトで真っ先に設定する項目でもある。

App variant を構成するもの

各ビルドは、互いに無関係な2つの設定を持つと考えられる。アイデンティティと環境だ。

1つ目はアイデンティティ、つまり iOS の bundle identifier か Android の package name で、たとえば com.myapp.app のようなものだ。アイデンティティはネイティブビルド時に固定される。何が独立したアプリと見なされるか、新しいインストールが古いものを上書きするかどうかを決めるのはこれだ。1つのデバイス上では、1つの識別子につきアプリは1つしか持てない。だから開発版と本番版が同じ識別子を共有していると、片方をインストールするともう片方が置き換わる。バリアントごとに識別子を分ければ、両方がデバイス上に並んで残る。

expo start の段階で行うことはアイデンティティを変えない。ネイティブのアイデンティティはアプリのネイティブコードに固定されているので、このガイドはローカルマシン上または EAS Build 経由で作成したカスタムビルドを使っていることを前提とする。Expo Go には当てはまらない——Expo Go ではすべてのプロジェクトが同じアイデンティティで動く。

2つ目は環境、つまり app config を評価するときに読み込まれる変数の集合だ。EAS には developmentpreviewproduction という3つの組み込み環境があり、API URL やアナリティクスのキーといった値はここに置く。環境はアプリが起動した後の挙動を決める。

ステップ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 を導入する。設定から直接読める普通の環境変数だ。この名前は単なる慣習で、何でも構わない。必要に応じてバリアントを増やしたり変えたりしてもいい。これで分岐してアイデンティティを選ぶ。

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() },
});

ここでは各バリアントが1行ずつ、switch が1つずつ対応し、ヘルパー関数は読みやすく保たれ、バリアントの追加は case を1つ足すだけになる。ID の共通部分は APP_ID_PREFIX に置き、各 case はサフィックスだけを設定するので、将来ベースを変えるときも1か所で済む。

...config を展開している点に注意。これは app.json の値を動的設定に埋め込む。config の展開を忘れると、app.json の中身がすべて失われる。さらに下の階層でも同じ理屈だ。bundleIdentifier を設定するときに先に config.ios を展開しなければ、新しい ID は残るが、ios 配下のほかの内容はすべて失われる。

判断に迷ったら、npx expo config を実行して Expo が最終的に解決した設定を確認する。--json を付けると機械可読な出力が得られ、jq をインストールしていれば npx expo config --json | jq .name で単一のフィールドを取り出せる。

developmentpreviewproduction を選んだのは、eas build:configure が作成するデフォルトのビルド設定と揃えるためだ。ちなみに、EAS 環境変数(あなたの変数が最終的に置かれる場所)にも同じデフォルト環境が備わっている。ついでに言えば、ビルド設定はいくつでも追加できるが、組み込みの3つ以外では、カスタム環境は Production と Enterprise プランでのみ利用できる。

この2つの概念については、以降でさらに掘り下げる。

なぜ両方のファイルを残すのか

app.config.ts だけを唯一の設定ファイルとして残すこともできるし、多くのアプリがそうしている。問題は、Expo のツールチェーンが静的な app.json にしか書き込まないことだ。このファイルが存在すれば、eas build:configureeas update:configure が値を埋めてくれる。存在しなければ、手動で追加するしかない。なかには静的な app.json を必須とする、より厳格なサービスもある。たとえば Expo Launchapp.json ファイルがないと動かない。namebundleIdentifier といったアイデンティティ関連のフィールドを設定に書き込むが、動的設定はこれらを受け取れないからだ。

デフォルトは development に落とす

すでに気づいているかもしれないが、switch の中で欠けている APP_VARIANT は development にフォールバックさせている。必須ではないが、本番の identity は明示的に指定したときだけ現れてほしい。ローカルで動かすコマンド——expo startexpo runexpo prebuild——では variant を選べず、デフォルトがそのまま使われる。ローカルではほぼ毎回 dev ビルドなのだから、それをデフォルトにしてしまえばいい。

APP_VARIANT がカバーする variant は固定なので、この switch は設計上網羅的になっている。variant で分岐して名前や識別子以外の値を取るようになると、デフォルトの重みが増す。たとえば getAppId() の隣に getBaseUrl()あるいは環境固有の値なら何でも)を足して、その結果を設定に渡し、variant ごとに API URL を持たせるとする。APP_VARIANT は設定からしか見えないので、サンプルでは extra を通して値をアプリのコードに渡す。

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,
  // ... rest of your app.config.ts config here
  extra: {
    ...config.extra,
    apiUrl: getBaseUrl() // read back via Constants.expoConfig.extra.apiUrl
  },
});

こうなると、デフォルトがどのバックエンドに繋がるかを決める。development に落ちれば dev バックエンドのまま、production に落ちれば本物のバックエンドに繋がる。何が起きるかはアプリ次第だ。認証バックエンドが dev の access token を拒否する、といった派手な失敗もあれば、テストデータで本番を汚しながら静かに成功してしまうこともある。

あとで APP_VARIANT を EAS 環境変数に移してローカルに引っ張ってくれば、ほぼ常に値が入るようになり、デフォルトの出番はほとんどなくなる。

ステップ 2:EAS で各 variant をビルドする

設定が APP_VARIANT に反応するようになったので、EAS Build はビルドごとにこの変数を設定する必要がある。一番簡単なのは eas.json で profile ごとに 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 をセットする。dev identity が効力を持ち、dev ビルドと production ビルドが干渉せずに並んでインストールされる。強いて言えば development profile にこの変数は要らない。ステップ 1 の設定どおり development がデフォルトだからだ。production では明示的に設定する必要があり、その build profile こそが設定すべき場所になる。

変数をどこに置くか

eas.json profile で APP_VARIANT を設定すれば variant のビルドはできるし、出発点としてはそれで十分だ。ただしその env ブロックは eas build でしか効かない(たとえば eas update eas.json env ブロックを見られない)。実際、設定を解決する他のコマンド——ローカルマシンの expo startexpo run も含めて——からは見えず、代わりにローカルの shell から APP_VARIANT を読む。だから現時点では自分で設定する必要がある。APP_VARIANT=development npx expo start のようにインラインで渡すか、package.json スクリプトに書くか、.env ファイルに入れる。

EAS にこれらの変数を保存させることもできる。EAS 環境変数 を使えば、変数は eas.json ではなく EAS 上のある environment に紐づいて保存される。完全に任意だが、eas.json とローカルの .env ファイルの間で同じ値をコピーし合わなくて済むので、開発体験は良くなると思う。変数の作成は environment ごとに一度だけだ。

eas env:create --name APP_VARIANT --value development --environment development --visibility plaintext

あとは build profile が、変数を一つずつ書く代わりにどの environment を読み込むかを示すだけでいい。

{
  "build": {
    "development": {
      "developmentClient": true,
      "environment": "development"
    }
  }
}

ローカルでは eas env:pull で同じ値が手に入る。environment を指定すると、その変数がローカルの .env.local ファイルに書き出される。

eas env:pull --environment development

これで expo startexpo run.env.local から APP_VARIANT を読むようになり、インラインで設定する必要はない(.env ファイルの値を手で書き換える必要もない)。ビルドは EAS 上の同じ environment から読む。値を変える場所は一つで済み、あちこちに散らばらない。

手元で variant をビルドする

変数が .env.local に書き込まれていれば、ローカルで expo run を使ってビルドするのに追加設定は要らない。prebuild された variant は APP_VARIANT が解決したものになるので、まずそれを設定し(あるいは環境を pull し)、あとで variant を切り替えるときは prebuild --clean でネイティブプロジェクトを再生成して新しい identity を書き込む。

APP_VARIANT=<variant> npx expo prebuild --clean
npx expo run:[platform]

prebuildCNG(Continuous Native Generation) に馴染みがなければ:CNG は app config、package.json、その他の入力ファイルからネイティブプロジェクトをオンデマンドで生成する。バージョン管理にコミットするのではなく。EAS Build も同じやり方で(ネイティブプロジェクトをコミットしていない限り)、クラウドビルドのたびに一から生成し直す。ローカルでも EAS でも、variant を切り替えたときに古い成果物が残ることはない。

ローカルビルドの variant はほぼ常に development だ。preview と production は EAS Build の仕事。どうしてもローカルでビルドするなら release 構成を使う。JavaScript も設定も焼き込まれるので、dev server に依存せず、アプリストアからインストールしたものと同じように動く。

# iOS
npx expo run:ios --configuration Release
# Android
npx expo run:android --variant release

次に開発する前に development の prebuild --clean で切り戻すのを忘れずに。そうしないと CLI の QR コードがディスク上にある variant を指し続ける。

環境が実行中のアプリにどう届くか

複数のビルドを並行して走らせると、アイデンティティと環境が食い違うことがある。ここでは環境がいつアプリに届くのかをはっきりさせておきたい。

アイデンティティはビルド時に固定される。これはすでに見たとおりだ。ローカル開発で注意すべき点がある。npx expo prebuildnpx expo run:android|ios でローカルのネイティブプロジェクトを生成した場合、expo start の起動 scheme はディスク上のネイティブプロジェクトから取られる。つまり APP_VARIANT は、どのインストール済みアプリが開くかには影響しない。

実行時にアプリが expo-constants 経由で読む設定こそが重要だ。それがどこから来るかはビルドの種類によって変わる。

  • expo-dev-client なしの通常ビルドでは、設定はコンパイル時に埋め込まれる。Constants.expoConfig はビルド時に固定され、expo startAPP_VARIANT を実行しても何の影響もない。アプリが報告する設定を変えるには、リビルドするしかない。

  • 開発ビルド(つまり expo-dev-client 付きのビルド)は、プロジェクトを開くたびに開発サーバーから設定をダウンロードする。この場合 Constants.expoConfig が反映するのは、コンパイル時に使った環境ではなく、サーバーが動いている環境だ。

注意が必要なのは2つ目のケースだ。preview でサーバーを起動し、開発ビルドを開くと、プレビュー環境の値で何事もなく動いてしまう。開発メニューには、アイデンティティが dev のビルドなのに「MyApp (Preview)」、あるいはそのバリアントに付けた名前が表示され、エラーも出ない。対処法は、サーバーのバリアントを開くビルドと揃えるか、変数がすでにどこかの環境に存在する場合は expo start の前に eas env:pull --environment development を実行することだ。

もう一つ、挙動が異なる経路がある。EXPO_PUBLIC_ 変数はバンドル時に JS バンドルへインライン展開される。そのため、開発サーバーから JS を読み込むビルドはローカルの環境変数を読み、リリースビルドはそれを焼き込む。このプレフィックスのない変数は、APP_VARIANT を含め、JS に直接入ることはない。実行時に読むには、設定経由で公開する必要がある。通常は下に示す extra フィールドだ(あるいはこの場合、たとえば EXPO_PUBLIC_APP_VARIANT と名付けて直接読むこともできる)。残りは環境変数ガイドを参照してほしい。

追加の値にアクセスする

動画:追加の値にアクセスする — expo.dev で視聴

開発ビルドがサーバーから設定を取得する仕組みを確かめるには、設定の extra フィールドにバリアントを追加する。そしてその値を画面に表示する。expo-constants のバリアント、process.env.APP_VARIANTprocess.env.EXPO_PUBLIC_APP_VARIANT だ。

const config: ExpoConfig = {
  name: getName(),
  slug: "my-app",
  extra: {
    variant: process.env.APP_VARIANT ?? "unset",
  },
  // the rest of your app config....
};
import Constants from "expo-constants";

<Text>variant: {Constants.expoConfig?.extra?.variant}</Text>
<Text>APP_VARIANT: {String(process.env.APP_VARIANT)}</Text>
<Text>EXPO_PUBLIC_APP_VARIANT: {String(process.env.EXPO_PUBLIC_APP_VARIANT)}</Text>

APP_VARIANTEXPO_PUBLIC_ プレフィックスがないため undefined と表示され、EXPO_PUBLIC_APP_VARIANT はその値を表示する。

別の APP_VARIANTおよび対応する EXPO_PUBLIC_APP_VARIANT)でサーバーを再起動し、もう一度プロジェクトを開く。デバイスで QR コードをスキャンするか、シミュレータで i を使う(単にリロードするだけでは効かないことがある)。開発ビルドは新しい値を表示する。プレビューやプロダクションのインストールは、コンパイル時に使ったバリアントを表示したままになる。

QR コードで開発ビルドを開く

複数のバリアントをインストールすると、Expo CLI が生成する QR コードが間違ったアプリを開くことがある。デフォルトでは、expo-dev-client 設定プラグインがネイティブプロジェクトに生成済み scheme を追加する。名前は exp+<slug> で、アプリの slug から作られる。slug はバリアント間で同じなので、どのバリアントも同じ scheme を登録し、同じリンクに応答する。どれが開くかは当てにできない。たとえば、最後にインストールしたのが開発ビルドであっても、毎回プレビュービルドが開くことがある。

解決策は、生成 scheme に応答するアプリを dev build だけにすることだ。

plugins: [
  [
    "expo-dev-client",
    {
      addGeneratedScheme: process.env.APP_VARIANT === "development",
    },
  ],
],

クリーンに prebuild してリビルドすれば、exp+<slug> を登録するのは development build だけになり、QR コードでそれが開くようになる。ただし、変更前にビルドしたバリアントは依然としてこのリンクに応答するので、それらもリビルドするか、デバイスからアンインストールする必要がある。

expo run で他のバリアントをローカルビルドする場合、落とし穴がある。CLI は直近に prebuild したネイティブプロジェクトからリンクの scheme を取るため、別のバリアントをローカルビルドした後では、次の expo start が間違ったアプリを指す可能性がある。次に dev session を開く前に、development 用のネイティブプロジェクトを再生成しておく。

APP_VARIANT=development npx expo prebuild --clean

変数を EAS に置いている場合は、先に eas env:pull --environment development を実行し、インライン変数はスキップする。他のバリアントをすべてリモートビルドしていて、ローカルのネイティブプロジェクトが常に development 用に設定されていれば、この問題は起きない。

addGeneratedScheme が変えるのは生成 scheme だけで、アプリ設定のカスタム scheme には触れない。カスタム scheme 自体、私がめったに変更しないものだ。これが役立つのは、外部リンクが同じプロジェクトからビルドされた複数のアプリを選び分ける必要がある場合、たとえばホワイトラベルアプリだ。各ブランドは brandone://brandtwo:// のような scheme で自分のリンクに応答する。

バリアントごとにサーバー側で登録する

各バリアントが独立したアプリになった後、残る雑用が一つある。これはネイティブのアイデンティティそのものに結びついている。APP_VARIANT を使えば設定から異なる bundle identifier や package name を返せるが、それらの識別子に必要な外部登録を作ることはできない。

一部のサービスや SDK は、この識別子でアプリを認識する。あるビルドが、事前に登録しておく必要のある ID を要求することがある。そのためバリアントごとに、独立したアプリとして、あるいは同じプロジェクトの下で許可された別の識別子として、個別に設定しなければならない。variants guide には Google Maps と Firebase Cloud Messaging の例が載っている。わかりやすい兆候は、サービスの設定中に package name や bundle identifier の入力を求められることだ。

各バリアントを個別に登録するのは手間がかかる。しかし、本番データを記録・送信するサービス——analytics、クラッシュレポート、プッシュ通知など——を使っているなら、dev build が本番データを汚染しないよう、大抵はそうする価値がある。

バリアントごとにアイコンを用意する

2つか3つのバージョンを同時に入れているとき、アイコンを変えるのが取り違えを防ぐ最速の方法で、同僚にもずっとウケがいい。動的設定を使えば、helper を1つ書くだけの話で、バリアント用アイコンは同じアイコンを背景色違いにする程度で済む。自分の実装では本番環境が undefined を返すので、app.json のアイコンはそのまま透過し、dev と preview は単色画像で上書きする。

function getIcon() {
  switch (process.env.APP_VARIANT) {
    case "production":
      return undefined; // production keeps the icons from app.json
    case "preview":
      return "./assets/images/icon-preview.png";
    default:
      return "./assets/images/icon-dev.png";
  }
}

const icon = getIcon();

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  icon: icon ?? config.icon,
  ios: {
    ...config.ios,
    icon: icon ?? config.ios?.icon,
  },
  android: {
    ...config.android,
    icon: icon ?? config.android?.icon,
    adaptiveIcon: {
      ...config.android?.adaptiveIcon,
      foregroundImage: icon ?? config.android?.adaptiveIcon?.foregroundImage
    },
  },
  // ... rest of your config
});

ios.iconandroid.adaptiveIcon はトップレベルの icon より優先されるので、サンプルでもあわせて上書きしている。

更新を通じてバリアントの一貫性を保つ

ここまではビルド時にバリアントを揃える話だった。EAS Update はアプリのリリース後に揃えるべきもう一つの場所だ。更新は channel を通じてバリアントに届き、公開時に使った環境(--environment で指定)がどの値を持たせるかを決める。

channel は build profile から来る。すでに指定されている environment のすぐ隣に書けばいい。

{
  "build": {
    "preview": {
      "environment": "preview",
      "channel": "preview"
    }
  }
}

名前を揃えておけば、経路全体が噛み合う。preview バリアントは preview profile でビルドされ、preview 環境を読み込み、preview channel から更新を受け取るので、そこで公開した更新は狙いどおりの場所に届く。

eas update --channel preview --environment preview

ただし、間違った環境を指すと、preview バリアントは文句も言わずに開発環境の値を持った更新を受け入れる。仕組みはビルド時と同じで、バリアントと環境は一致していなければならない。違うのは、ここでミスするとすでにアプリを入れているユーザーにまで影響が及ぶことだ。channel が更新をルーティングし、environment がその挙動を決める。両者を揃えておくのは自分の責任だ。

まとめ

大げさに見えるのは分かるが、実際にやることは単純だ。変数を1つ読むだけの動的設定を加え、環境ごとに build profile を1つ用意し、channel を合わせる。それだけで、気にしているビルドを全部同じ端末に入れられ、それぞれが自分が何者かを分かっている状態になる。この設定はめったに触る必要がないので、今では新しいプロジェクトにそのままコピーして数か所直すだけだ。ビルドを切り替えるたびに、そのありがたみが分かる。

本番でバグが出て、しかも作業の途中——そんなときは切り替えて再現し、また戻ってくる。何もアンインストールせず、何も失わない。何年も同じビルドを上書きして別のビルドを確認してきたなら、それはもう終わりにしていい。

App バリアントは開発者体験の重要な一部であり、そうした開発者体験は本来すべての人のものであるべきだ。

出典: Expo Blog← ホームへ戻る