2026年5月、運営している壁紙アプリの Android 版 v2.1.0 を 5%→25% と段階公開していたとき、「リワード広告で広告を外したはずなのにペイウォールが出る」という報告がレビュー欄に書き込まれました。手元のデバッグビルドでは何度試しても再現しません。原因は後述するとおり課金判定の合成漏れだったのですが、このとき一番つらかったのは、本番と同じ条件 — 本物の Remote Config、本物の広告在庫、本物の課金状態 — を手元で再現する手段が、当時のそのアプリに無かったことです。
リリースビルドでだけ顔を出す不具合は、広告・課金・リモート設定の3つが絡む場所に集中します。逆に言えば、この3つを実機の画面から安全に切り替えられる「開発者メニュー」を一度作ってしまえば、この種の検証の大半はリリース前に終わらせられます。個人開発で壁紙アプリ6本を並行運用するなかで形になってきた、ストア提出ビルドには存在ごと残らないデバッグメニューの設計と実装をまとめます。
「設定画面の隅に隠す」では、なぜ破綻するのか
最初に作りがちなのは、設定画面の最下部に __DEV__ 条件で項目を足していくやり方です。私も最初の数年はそうしていました。これが破綻する理由は3つあります。
散らばる : 広告のテスト切替は広告モジュールに、接続先の切替は API クライアントに、と条件分岐がコードベース全体へ広がっていきます。半年後の自分は、どこに何を仕込んだか思い出せません。
消し忘れる : リリース直前に「あのフラグ、戻したかな」と不安になる構造そのものが事故の温床です。実際、私は過去に検証用の接続先 URL を本番ビルドへ残しかけたことがあります。提出前のチェックリストで拾えたものの、仕組みで防げていない不安は残りました。
再現手順を共有できない : 「◯◯をオンにして△△の画面を3回開くと出ます」という手順が口伝になります。一人で開発していても、未来の自分は他人です。
そこで方針を逆にします。デバッグ操作は1つの画面に集約し、その画面への入口と実装そのものをビルド種別で消す。散らばった if を追いかける代わりに、「上書きレイヤー」を1枚だけ設計するイメージです。
全体像 — 4セクションと、譲らない2つのルール
いま6本のアプリで運用しているメニューは、次の4セクションに落ち着きました。
環境 : コンテンツ配信や API の接続先切替(dev / staging / production)と、現在のビルド variant の表示
広告 : テスト広告モードの切替、直近の広告ロード結果ログ、メディエーションでどのネットワークがフィルしたかの表示
課金 : エンタイトルメント(広告非表示・プレミアム)の強制上書きと、購入フローのサンドボックス起動
Remote Config : 任意キーの上書きと、モーダル系施策の強制発火
そして、どのアプリでも譲っていないルールが2つあります。
ストアへ提出するビルドには、メニューの入口だけでなくコードごと含めない。 「見えないだけで入っている」状態は、審査の観点でも事故防止の観点でも中途半端です。
上書きが1つでも効いている間は、画面の隅に常時バッジを出す。 テスト状態のまま本番の数値を眺めて一喜一憂する、という間抜けな時間をこのバッジで撲滅しました。
なぜ2つ目をここまで強調するかというと、デバッグメニューの最大の敵は「自分が何を上書きしたか忘れること」だからです。上書きは便利になるほど忘れます。状態の可視化は飾りではなく、機能の一部だと考えています。
入口の実装 — ビルドフラグと7回タップの二段構え
Rork で生成した Expo プロジェクトなら、ビルド種別の宣言は app.config.ts に寄せるのが素直です。variant を環境変数で受け取り、internal ビルドのときだけデバッグ機能を有効化します。
// app.config.ts — variant の宣言を 1 箇所に寄せる
// EAS Build の profile 側から APP_VARIANT を渡す(例: internal / production)
import { ExpoConfig } from "expo/config" ;
const variant = process.env. APP_VARIANT ?? "development" ;
const config : ExpoConfig = {
name: variant === "production" ? "Beautiful Walls" : `Walls (${ variant })` ,
slug: "beautiful-walls" ,
extra: {
appVariant: variant, // expo-constants 経由で実行時に参照する
},
// ...残りの設定は省略
};
export default config;
実行時はこの値を1つの判定関数に閉じ込めます。Constants をあちこちで直読みしないことが、先ほどの「散らばり」対策になります。
// src/debug/isDebugMenuAvailable.ts — 判定の唯一の入口
import Constants from "expo-constants" ;
const variant : string =
Constants.expoConfig?.extra?.appVariant ?? "development" ;
// development と internal のみ true。production では常に false
export const isDebugMenuAvailable = () : boolean =>
variant === "development" || variant === "internal" ;
メニューへの入口は「バージョン表記を3秒以内に7回タップ」にしています。Android の開発者向けオプションと同じ作法なので、テスト協力者への説明が要りません。
// src/debug/DebugGate.tsx — 設定画面のバージョン表記に仕込む入口
import { useRef, useCallback } from "react" ;
import { Pressable, Text } from "react-native" ;
import { router } from "expo-router" ;
import { isDebugMenuAvailable } from "./isDebugMenuAvailable" ;
const REQUIRED_TAPS = 7 ;
const WINDOW_MS = 3000 ; // 3 秒以内に 7 回で発動
export function DebugGate ({ version } : { version : string }) {
const taps = useRef < number []>([]);
const onTap = useCallback (() => {
if ( ! isDebugMenuAvailable ()) return ; // production では何も起きない
const now = Date. now ();
taps.current = [ ... taps.current. filter (( t ) => now - t < WINDOW_MS ), now];
if (taps.current. length >= REQUIRED_TAPS ) {
taps.current = [];
router. push ( "/debug" ); // expo-router のデバッグ画面へ
}
}, []);
return (
< Pressable onPress = { onTap } accessibilityLabel = "app version" >
< Text >v { version } </ Text >
</ Pressable >
);
}
期待する挙動はこうです。development / internal ビルドでは7回タップで /debug 画面が開き、production ビルドでは何も起きません。さらに後述する仕掛けで、production では /debug ルートの中身そのものを読み込まないため、ディープリンクで直接叩かれてもホームへ戻るだけになります。
メニュー本体 — 「上書きレイヤー」を1枚だけ作る
メニュー画面そのものは何の変哲もない設定リストです。本質は裏側の上書きストアにあります。ポイントは、実値(本番ロジックの判定結果)と上書き値を別々に持ち、読み出し時に合成する ことです。上書き値で実値を直接書き換えてしまうと、「上書きを解除したら元に戻る」が保証できなくなります。
// src/debug/debugOverrides.ts — AsyncStorage 永続の上書きストア
import AsyncStorage from "@react-native-async-storage/async-storage" ;
export type DebugOverrides = {
apiEnv ?: "dev" | "staging" | "production" ;
forceTestAds ?: boolean ;
entitlement ?: "none" | "adFree" | "premium" ;
remoteConfig ?: Record < string , string | number | boolean >;
expiresAt ?: number ; // 消し忘れ対策の自動失効
};
const KEY = "debug.overrides.v1" ;
const TTL_MS = 24 * 60 * 60 * 1000 ; // 24 時間で自動失効
let cache : DebugOverrides = {};
export async function loadOverrides () : Promise < DebugOverrides > {
const raw = await AsyncStorage. getItem ( KEY );
if ( ! raw) return (cache = {});
const parsed : DebugOverrides = JSON . parse (raw);
// 期限切れなら破棄する — 「昨日の自分の上書き」に今日ハマらないため
if (parsed.expiresAt && Date. now () > parsed.expiresAt) {
await AsyncStorage. removeItem ( KEY );
return (cache = {});
}
return (cache = parsed);
}
export function getOverrides () : DebugOverrides {
return cache; // 同期読み出し用(起動時に loadOverrides 済みである前提)
}
export async function setOverride < K extends keyof DebugOverrides >(
key : K ,
value : DebugOverrides [ K ]
) : Promise < void > {
cache = { ... cache, [key]: value, expiresAt: Date. now () + TTL_MS };
await AsyncStorage. setItem ( KEY , JSON . stringify (cache));
}
export async function clearOverrides () : Promise < void > {
cache = {};
await AsyncStorage. removeItem ( KEY );
}
export const hasActiveOverrides = () : boolean =>
Object. keys (cache). some (( k ) => k !== "expiresAt" );
expiresAt による24時間の自動失効は、運用を始めて2週間で追加した仕様です。金曜の夜に入れた上書きを、月曜の自分は覚えていません。「覚えている前提」をやめたら、上書き起因の「あれ、直ってない?」という空騒ぎが消えました。
上書きが効いていることを示すバッジは、ルートレイアウトへ重ねます。
// app/_layout.tsx の末尾に重ねる上書きバッジ(internal ビルドのみ)
{ isDebugMenuAvailable () && hasActiveOverrides () && (
< View pointerEvents = "none" style = { styles.debugBadge } >
< Text style = { styles.debugBadgeText } >OVERRIDE</ Text >
</ View >
)}
広告テストモード — 本物の広告ユニットを汚さない
広告まわりの検証で本当に怖いのは、クラッシュよりもポリシーです。開発中に本物の広告を表示して自分でタップしてしまうと、無効トラフィックとして AdMob アカウントの警告につながりかねません。私のアプリ事業は累計5,000万ダウンロードの大半を無料+広告モデルで積み上げてきたので、AdMob アカウントの健全性は文字どおり生命線です。だからテスト広告への切替は、ビルドフラグ任せにせず、メニューから1タップでできるようにしてあります。
// src/ads/adUnitIds.ts — 上書きを考慮した広告ユニット解決
import { TestIds } from "react-native-google-mobile-ads" ;
import { getOverrides } from "../debug/debugOverrides" ;
import { isDebugMenuAvailable } from "../debug/isDebugMenuAvailable" ;
const PRODUCTION_UNITS = {
banner: "ca-app-pub-XXXXXXXXXXXXXXXX/NNNNNNNNNN" , // 実 ID は設定から注入する
interstitial: "ca-app-pub-XXXXXXXXXXXXXXXX/NNNNNNNNNN" ,
rewarded: "ca-app-pub-XXXXXXXXXXXXXXXX/NNNNNNNNNN" ,
} as const ;
type AdKind = keyof typeof PRODUCTION_UNITS ;
export function resolveAdUnitId ( kind : AdKind ) : string {
// internal ビルドで forceTestAds が有効なら Google 公式のテスト ID を返す
if ( isDebugMenuAvailable () && getOverrides ().forceTestAds) {
const testIds : Record < AdKind , string > = {
banner: TestIds. ADAPTIVE_BANNER ,
interstitial: TestIds. INTERSTITIAL ,
rewarded: TestIds. REWARDED ,
};
return testIds[kind];
}
return PRODUCTION_UNITS [kind];
}
メニューの広告セクションには、切替スイッチに加えて「直近のロード結果」を5件だけ表示しています。どのネットワークがフィルしたか、エラーコードは何だったか。メディエーションの調整をしていた時期は、この5行を読むだけで調査の往復が一晩ぶん減りました。
もう1つの小さな工夫として、テスト広告モードのときはバッジの色を変えています(上書きありが黄色、テスト広告中は緑)。本物の在庫で eCPM の出方を確認したいときに「テストのまま眺めていた」を防ぐためです。
課金状態のシミュレーション — 判定を1カ所へ寄せてから上書きする
冒頭の v2.1.0 の不具合は、課金判定の合成漏れでした。買い切りによる広告非表示は isAdFree、リワード広告視聴による一時的な広告非表示は isRewardAdFree と判定が2系統あったのに、後から足したペイウォールの表示条件が isAdFree しか見ていなかった。デバッグビルドの私の端末は買い切り済み状態だったので、手元では永遠に再現しなかったわけです。
このときの教訓は、「上書きできるようにする前に、判定の入口を1つにする」でした。判定が散らばったままでは、どれだけ上書き機構を整えても検証に穴が残ります。
// src/entitlements/useEntitlements.ts — 実値と上書きの合成点を 1 つにする
import { getOverrides } from "../debug/debugOverrides" ;
import { isDebugMenuAvailable } from "../debug/isDebugMenuAvailable" ;
import { usePurchaseState } from "./usePurchaseState" ; // 実課金の状態
import { useRewardAdFree } from "./useRewardAdFree" ; // リワード視聴による一時解除
export type Entitlements = {
adFree : boolean ;
premium : boolean ;
source : "purchase" | "reward" | "debug" | "none" ;
};
export function useEntitlements () : Entitlements {
const purchase = usePurchaseState ();
const rewardAdFree = useRewardAdFree ();
// デバッグ上書きが最優先。ただし internal ビルド以外では評価すらしない
if ( isDebugMenuAvailable ()) {
const o = getOverrides ().entitlement;
if (o === "adFree" ) return { adFree: true , premium: false , source: "debug" };
if (o === "premium" ) return { adFree: true , premium: true , source: "debug" };
if (o === "none" ) return { adFree: false , premium: false , source: "debug" };
}
// 実値の合成 — 広告非表示は「購入 or リワード」のどちらでも成立する
const adFree = purchase.adFree || rewardAdFree.active;
return {
adFree,
premium: purchase.premium,
source:
purchase.adFree || purchase.premium
? "purchase"
: rewardAdFree.active
? "reward"
: "none" ,
};
}
source を返しているのが地味に効きます。メニューの課金セクションに「いまの広告非表示は purchase 由来か reward 由来か」を表示できるので、冒頭のような合成漏れは画面を見れば一目で分かります。
モーダルの出し分け検証では、この上書きを Rork アプリで課金画面とレビュー誘導が同時に出た — ModalGate パターンで解決するまで で書いた ModalGate と組み合わせています。「premium 上書き+ペイウォール強制発火」のように状態とトリガーを別々に操作できると、モーダルの優先順位バグを机上で炙り出せます。
Remote Config の上書きとモーダルの強制発火
Remote Config の値には配信反映までのラグがあり、「この値のときどう動くか」をサーバー側で切り替えながら検証するのは現実的ではありません。クライアント側で上書きします。
// src/config/remoteValue.ts — Remote Config 読み出しの唯一の窓口
import remoteConfig from "@react-native-firebase/remote-config" ;
import { getOverrides } from "../debug/debugOverrides" ;
import { isDebugMenuAvailable } from "../debug/isDebugMenuAvailable" ;
export function getRemoteValue < T extends string | number | boolean >(
key : string ,
fallback : T
) : T {
// 上書きがあれば最優先で返す(internal ビルドのみ)
if ( isDebugMenuAvailable ()) {
const o = getOverrides ().remoteConfig?.[key];
if (o !== undefined ) return o as T ;
}
const v = remoteConfig (). getValue (key);
if ( typeof fallback === "boolean" ) return v. asBoolean () as T ;
if ( typeof fallback === "number" ) return v. asNumber () as T ;
return (v. asString () || fallback) as T ;
}
例えばインタースティシャルの頻度キャップを interstitial_min_interval_sec: 90 のように配信している場合、メニューから 30 へ上書きして画面遷移を繰り返せば、頻度制御の境界挙動を数分で確認できます。クラッシュ率に応じて広告表示を絞る仕組みを検証したときも、しきい値を上書きして擬似的に「制動が掛かった状態」を作りました。この仕組み自体は クラッシュ率上昇時にAdMobを自動で絞る『収益を守る自動制動』アーキテクチャ — Rork × Firebase Remote Config × Crashlytics の連動設計 で詳しく書いています。
モーダルの強制発火は、ModalGate のキューへ直接積むボタンを並べるだけです。レビュー誘導・ペイウォール・お知らせの3種を任意の順番で積めるようにしておくと、「同時に発火条件を満たしたら何が起きるか」という、自然条件では月に一度しか遭遇できない競合状態を机の上で再現できます。
ストア提出ビルドから完全に消す — 「見えない」ではなく「存在しない」
仕上げです。production ビルドでは、入口を隠すだけでなくコード自体をバンドルから外します。私は expo-router のルートを variant で分岐させ、デバッグ画面のモジュールが production では require すらされない形にしています。
// app/debug.tsx — production では中身を読み込まないルート
import { Redirect } from "expo-router" ;
import { isDebugMenuAvailable } from "../src/debug/isDebugMenuAvailable" ;
export default function DebugRoute () {
if ( ! isDebugMenuAvailable ()) {
return < Redirect href = "/" />; // 直リンクで叩かれてもホームへ戻す
}
// 遅延 require — production バンドルでは到達不能になる
const DebugMenu = require ( "../src/debug/DebugMenu" ).default;
return < DebugMenu />;
}
ここで1つ、Expo 特有の注意があります。EXPO_PUBLIC_ 接頭辞の環境変数は、すべて JS バンドルへ平文で埋め込まれます。 ビルド時定数として分岐の最適化に使える反面、デバッグ用の認証トークンやスタッフ判定のメールアドレスのような秘密をここへ置くのは絶対に避けてください。「公開されてよい値だけを置く場所」と覚えるのが安全です。
審査の観点にも触れておきます。App Store のガイドラインは隠し機能に厳しく、解釈で争うより提出ビルドに含めないことが、余地なく安全な選択 です。私は EAS Build のプロファイルを production(ストア提出・メニューなし)と internal(TestFlight 内部テスト / Play 内部テスト用・メニューあり)に分けています。万一 internal 構成のまま提出操作をしても、前述の app.config.ts がアプリ名へ variant を付けるため、提出画面で「Walls (internal)」という名前を見た瞬間に気づけます。名前に出す、という原始的な仕掛けが最後の砦として案外働きます。
よくある落とし穴 — 6アプリの運用で実際に踏んだもの
EXPO_PUBLIC_ に秘密を置いてしまう : 前述のとおり平文でバンドルに残ります。デバッグメニューのパスコードすら置かないでください。守るべきは配布範囲(内部テストトラック)で守るのが正解です。
上書きの消し忘れ : TTL による自動失効と常時バッジの二段構えにしてから、上書き起因の誤報はゼロになりました。どちらか片方だけだと、いつか必ず踏みます。
デバッグ操作が分析データを汚す : テスト広告モードでインプレッションイベントが飛ぶと、ARPDAU の集計が静かに狂います。上書きが有効な間は計測イベントへ debug: true を付与し、集計側で除外するのが私の運用です。イベント設計の土台は Rork製6アプリで分析イベントを型で守る — 共有イベント層の設計メモ にまとめています。
EAS Update のチャンネル取り違え : internal 向けの JS バンドルを production チャンネルへ配信すると、「コードごと存在しない」前提が崩れます。channel 名と variant を同じ文字列に統一し、配信スクリプト側で照合チェックを入れてからは起きていません。
internal ビルド同士で上書きが生き残る : AsyncStorage の上書きはアプリ更新をまたいで残ります。バージョンを跨ぐ検証の前には、メニュー最下部の Clear All を押す癖をつけてください。production ビルドでは isDebugMenuAvailable() が false のため、仮にストレージへ値が残っていても評価自体がされません。
まとめ — 最初の一歩は「環境バッジ」だけでいい
ここまでの全部を一度に作る必要はありません。まず app.config.ts に variant を宣言し、画面の隅に internal と表示する小さなバッジを置く。それだけで「いま自分がどのビルドを触っているのか」という、検証中で一番基本的な不安が消えます。メニューは必要になった項目から1つずつ足していけば、数週間もすると自分のアプリ専用の検証台に育っています。
リリースビルドでしか出ない不具合に夜中まで付き合った経験のある方の、参考になれば幸いです。