数年前、私の手元には壁紙アプリのリポジトリが6つ並んでいました。テーマも配色も違いますが、骨格はほとんど同じです。グリッド表示があって、お気に入りがあって、AdMob のバナーとインタースティシャルが入っていて、設定画面がある。最初の2本までは「似ているけど別物」として別々に育てていけました。
破綻したのは4本目あたりです。iOS の Privacy Manifest 対応が必要になったとき、私は同じ修正を6つのリポジトリに手で入れていきました。5本目まで終えて満足していたのですが、数週間後、6本目だけが古いままリジェクトされていることに気づきました。コピペの途中で1本飛ばしていたのです。
私は2014年から個人開発を続け、累計5,000万ダウンロードのアプリ事業を1人で回しています。壁紙アプリだけで6本、AdMob からの収益は月100万円規模で、その維持コストは私にとって死活問題です。このとき初めて、「似たアプリを別リポジトリで持つ」こと自体が技術的負債なのだと腹落ちしました。1箇所の修正漏れが、月の収益の数%を一晩で取りこぼすこともあります。本記事は、その6本を1つのコードベースから APP_VARIANT という環境変数1つで出し分ける ホワイトラベル構成へ寄せた実装記録です。Expo の app.config.ts を動的関数にする部分から、EAS Build のプロファイル設計、そして「バリアント定義の抜け漏れをビルド前に落とす」検証スクリプトまで、実際に動くコードで示します。
なぜ「共通コンポーネント化」だけでは足りなかったのか
最初に試したのは、共有コンポーネントを npm パッケージや monorepo のワークスペースに切り出す方向でした。これは UI の重複には効きます。けれど、私が本当に困っていたのは UI ではなく**「アプリごとに違う1点もの」の管理**でした。
具体的には、バンドルID(com.dolice.wallpaper.zen のような)、アプリ名、アイコン画像、スプラッシュ画像、AdMob のアプリID と各広告ユニットID、ディープリンクのスキーム、そしてテーマの基調色です。これらは「共通化できない差分」なので、コンポーネントを共有しても残り続けます。monorepo にしても、結局この差分を6つのパッケージそれぞれの app.json に手で書くなら、コピペ漏れのリスクは消えません。
そこで発想を変えました。コードは完全に1つにする。差分はバリアント定義というデータ に閉じ込める。ビルド時に APP_VARIANT=zen のように指定すると、app.config.ts がそのバリアントのデータを読んで、その場で正しい app.json 相当を組み立てる。この形なら、Privacy Manifest のような共通修正は1箇所を直すだけで6本に伝播します。差分は1つのファイルに集まっているので、レビューで抜けに気づけます。
バリアントを「型のあるデータ」として定義する
まず、6本分の差分を1つの型付きレジストリにまとめます。ここが設計の心臓部です。as const ではなく明示的な型を付けるのは、後述する検証スクリプトでキーの抜けを検出するためです。
// config/variants.ts
export type VariantId = "zen" | "aurora" | "noir" | "sakura" | "ocean" | "mono" ;
export interface AppVariant {
id : VariantId ;
name : string ; // ストア表示名・ホーム画面の名前
slug : string ; // expo の slug(プロジェクト識別)
scheme : string ; // ディープリンク用 URL スキーム
bundleId : string ; // iOS / Android 共通の applicationId
themeColor : string ; // スプラッシュ背景・ステータスバー基調色
admobAppIdIos : string ;
admobAppIdAndroid : string ;
easProjectId : string ; // EAS のプロジェクトID(バリアントごとに別)
}
export const VARIANTS : Record < VariantId , AppVariant > = {
zen: {
id: "zen" ,
name: "Zen Wallpapers" ,
slug: "zen-wallpapers" ,
scheme: "dolicezen" ,
bundleId: "com.dolice.wallpaper.zen" ,
themeColor: "#1B1B1F" ,
admobAppIdIos: "ca-app-pub-0000000000000000~1111111111" ,
admobAppIdAndroid: "ca-app-pub-0000000000000000~2222222222" ,
easProjectId: "00000000-0000-0000-0000-000000000001" ,
},
// aurora / noir / sakura / ocean / mono も同じ形で続けます
} as Record < VariantId , AppVariant >;
export function resolveVariant () : AppVariant {
const id = process.env. APP_VARIANT as VariantId | undefined ;
if ( ! id) {
throw new Error (
"APP_VARIANT が未設定です。例: APP_VARIANT=zen npx expo prebuild"
);
}
const variant = VARIANTS [id];
if ( ! variant) {
const known = Object. keys ( VARIANTS ). join ( ", " );
throw new Error ( `未知の APP_VARIANT: "${ id }"。有効な値: ${ known }` );
}
return variant;
}
ここで大事なのは、resolveVariant() が未設定や typo を黙って通さない ことです。APP_VARIANT を付け忘れたまま eas build を回すと、デフォルト値で6本目のアプリが「Zen の設定で Ocean としてビルドされる」ような事故が起きます。私は一度これで、別アプリのバンドルIDでアップロードしかけました。例外で即座に止めるだけで、この種の事故はほぼ消えます。
app.config.ts を動的関数にして差分を流し込む
Expo は app.json の代わりに app.config.ts(関数をエクスポートする形式)を置くと、ビルド時に評価してくれます。ここでバリアントを読み込み、その値で構成を組み立てます。
// app.config.ts
import type { ExpoConfig, ConfigContext } from "expo/config" ;
import { resolveVariant } from "./config/variants" ;
export default ({ config } : ConfigContext ) : ExpoConfig => {
const v = resolveVariant ();
return {
... config,
name: v.name,
slug: v.slug,
scheme: v.scheme,
icon: `./assets/variants/${ v . id }/icon.png` ,
userInterfaceStyle: "automatic" ,
splash: {
image: `./assets/variants/${ v . id }/splash.png` ,
resizeMode: "contain" ,
backgroundColor: v.themeColor,
},
ios: {
bundleIdentifier: v.bundleId,
supportsTablet: true ,
infoPlist: {
GADApplicationIdentifier: v.admobAppIdIos,
},
},
android: {
package: v.bundleId,
adaptiveIcon: {
foregroundImage: `./assets/variants/${ v . id }/adaptive-icon.png` ,
backgroundColor: v.themeColor,
},
},
extra: {
variantId: v.id,
themeColor: v.themeColor,
eas: { projectId: v.easProjectId },
},
plugins: [
[
"react-native-google-mobile-ads" ,
{
androidAppId: v.admobAppIdAndroid,
iosAppId: v.admobAppIdIos,
},
],
],
};
};
アセットは assets/variants/{id}/ の下に、バリアントごとに同じファイル名(icon.png、splash.png、adaptive-icon.png)で置いておきます。パスを v.id で組み立てるだけなので、新しいバリアントを足すときはフォルダを1つ追加して variants.ts に1エントリ書くだけで済みます。
extra.variantId と extra.themeColor を入れているのは、アプリの実行時コード 側からも自分がどのバリアントかを知るためです。expo-constants の Constants.expoConfig?.extra?.themeColor で読めば、テーマ色をハードコードせずに variants.ts の単一の真実から引けます。
// theme.ts(実行時側)
import Constants from "expo-constants" ;
const extra = Constants.expoConfig?.extra as
| { variantId ?: string; themeColor ?: string }
| undefined ;
export const THEME_COLOR = extra?.themeColor ?? "#1B1B1F" ;
export const VARIANT_ID = extra?.variantId ?? "zen" ;
EAS Build のプロファイルでバリアントを固定する
ローカルの prebuild は APP_VARIANT=zen npx expo prebuild で済みますが、EAS Build ではプロファイルごとに環境変数を固定しておくと、CI から --profile 指定だけで正しいバリアントが選ばれます。eas.json の env にバリアントを書きます。
{
"cli" : { "version" : ">= 12.0.0" },
"build" : {
"zen-production" : {
"extends" : "base" ,
"env" : { "APP_VARIANT" : "zen" }
},
"ocean-production" : {
"extends" : "base" ,
"env" : { "APP_VARIANT" : "ocean" }
},
"base" : {
"autoIncrement" : true ,
"ios" : { "resourceClass" : "m-medium" }
}
},
"submit" : {
"zen-production" : {
"ios" : { "ascAppId" : "1111111111" }
},
"ocean-production" : {
"ios" : { "ascAppId" : "2222222222" }
}
}
}
extends: "base" で共通設定を継承し、差分は env の APP_VARIANT だけにするのがコツです。プロファイル名にバリアント名を含めておくと、eas build --profile ocean-production と打った瞬間に、何をビルドしているのかが自分でも分かります。eas submit 側の ascAppId だけはバリアントごとに必須なので、ここも揃えて書いておきます。
公式ドキュメントは APP_VARIANT を「開発用とプロダクション用を分ける」例で紹介していますが、実運用ではこの仕組みをそのままマルチアプリの軸として転用できる のが本質的な価値です。Expo の設計は、もともとこういう使い方に耐えるようにできています。
公式ドキュメントはあまり強調しませんが、運用で効くのは easProjectId をバリアントごとに必ず別にする ことです。ここを共有してしまうと、EAS の OTA アップデートが「Zen 向けに配信したつもりのバンドルが Ocean のユーザーにも降ってくる」という、再現性が低く原因の追いにくい事故につながります。私は最初、6本で1つのプロジェクトIDを使い回していて、OTA の配信先が混線して肝を冷やしました。プロジェクトIDを分け、extra.eas.projectId をバリアントから引くようにしてからは、配信の取り違えは一度も起きていません。
抜け漏れをビルド前に落とす検証スクリプト
ここが、コピペ運用に戻らないための最後の砦です。バリアントを足したのに AdMob ID を書き忘れた、アイコンのフォルダを作り忘れた、といった抜けは、ビルドが通ってしまうとストア提出の直前まで気づけません 。本番のストア提出で初めて気づくと、リジェクトの対処に半日〜1日を失います。注意点は、この種の抜けは型では防げないことです。だから、ビルドの前に機械的に落として事故を回避します。
// scripts/validate-variants.ts
import { existsSync } from "node:fs" ;
import { VARIANTS, type AppVariant } from "../config/variants" ;
const REQUIRED_ASSETS = [ "icon.png" , "splash.png" , "adaptive-icon.png" ];
const errors : string [] = [];
for ( const [ id , v ] of Object. entries ( VARIANTS ) as [ string , AppVariant ][]) {
// 1. 必須フィールドが空文字やプレースホルダーのままでないか
for ( const key of [ "name" , "bundleId" , "scheme" , "easProjectId" ] as const ) {
if ( ! v[key] || v[key]. trim () === "" ) {
errors. push ( `[${ id }] ${ key } が空です` );
}
}
// 2. AdMob ID が本物の形式か(テストIDやダミーを弾く)
for ( const key of [ "admobAppIdIos" , "admobAppIdAndroid" ] as const ) {
if ( ! / ^ ca-app-pub- \d {16} ~ \d {10}$ / . test (v[key])) {
errors. push ( `[${ id }] ${ key } の形式が不正、または未設定です` );
}
}
// 3. バンドルIDの重複(別バリアントと衝突すると別アプリを上書きする)
const dup = Object. values ( VARIANTS ). filter (( o ) => o.bundleId === v.bundleId);
if (dup. length > 1 ) {
errors. push ( `[${ id }] bundleId "${ v . bundleId }" が他バリアントと重複` );
}
// 4. アセットの実在確認
for ( const asset of REQUIRED_ASSETS ) {
const path = `assets/variants/${ id }/${ asset }` ;
if ( ! existsSync (path)) {
errors. push ( `[${ id }] アセット欠番: ${ path }` );
}
}
}
if (errors. length > 0 ) {
console. error ( "バリアント検証に失敗しました: \n " + errors. map (( e ) => " - " + e). join ( " \n " ));
process. exit ( 1 );
}
console. log ( `✅ ${ Object . keys ( VARIANTS ). length } バリアントすべて検証通過` );
これを package.json の prebuild や CI の最初のステップで node --import tsx scripts/validate-variants.ts のように回します。3番目のバンドルID重複チェック は地味ですが効きます。新しいバリアントを既存のエントリからコピペして作ると、bundleId を書き換え忘れがちで、その状態でビルドすると別アプリを上書きアップロードしかねません。型では防げない「値の重複」を、データを舐めて落とすわけです。
EAS のリモートビルドで走らせる場合は、eas-build-pre-install フックにこの検証を入れておくと、クラウド側のビルドが始まる前に止まります。ローカルとCIの両方に同じゲートを置くのが、長期運用で最も裏切らない構成でした。
移行はどこから始めるか — 一度に6本やらない
最後に、すでに別リポジトリで動いている複数アプリをこの構成へ寄せる順番についてです。私は最初、全部いっぺんに統合しようとして手が止まりました。正解は、一番動きの激しいアプリ1本を「基準バリアント」にしてコードを完全に作り込み、残りはバリアント定義を足すだけ にすることでした。
具体的な移行手順は次の3ステップです。
更新頻度の高い1本のコードを新しい単一リポジトリへ移し、APP_VARIANT で出し分ける構造に作り替えます。これが基準バリアントになります。
基準バリアントが安定して出せるようになったら、2本目を「variants.ts に1エントリ追加 + アセットフォルダ追加 + EAS プロファイル追加」だけで再現できるか試します。コード分岐を一切足さずに出せれば、設計が正しい証拠です。
3本目以降は2の繰り返しです。もし再現に追加のコード分岐が必要になったら、その差分はまだ variants.ts に抽出しきれていないサインなので、データ側へ戻します。
私の場合、基準バリアント1本を作り込むのに数日かかりましたが、2本目以降は1本あたり30分ほどで追加できるようになりました。
祖父が宮大工で、同じ図面から少しずつ違う建具を仕上げていたのを思い出します。骨格は共通で、現場ごとの違いは寸法という「データ」に落ちている。ソフトウェアでも同じことができると分かってから、6本の保守はずいぶん静かになりました。
設定ドリフトに悩んでいる個人開発者の方の参考になれば嬉しいです。私は、いきなり全部を統合せず、まずは手元の似たアプリ2本だけで試すことを強く推奨します。片方を基準バリアントにして variants.ts に2エントリ書くところから始めてみてください。