広告の警告メールが届いた夜に、私が本当に欲しかったもの
配信中のアプリについて、AdMob からポリシー違反の警告が届いたことがあります。該当箇所は一箇所。修正そのものは十数分で終わる程度のものでした。
問題は、その十数分の修正を利用者に届けるまでに、ビルドを作り、審査に出し、通過を待つ必要があったことです。手元のコードはとうに直っているのに、店頭に並んでいるアプリは古いまま。夜中にダッシュボードを何度も更新しながら、指先が冷たくなっていくのを覚えています。
あのとき私に足りていたのは、修正する力ではありませんでした。止める力です。
Feature Flags(機能フラグ)は、コードをデプロイしたまま特定の機能をサーバー側から切り替える仕組みです。Remote Config(リモート設定)は、アプリの設定値を配信後に差し替える仕組みです。この二つが揃うと、次のことができるようになります。
- 新機能を一部の利用者にだけ先行公開する(カナリアリリース)
- 異なる UI やコピーを比較検証する(A/Bテスト)
- 問題の起きた機能を、審査を待たずに即座に無効化する(キルスイッチ)
- テキストや閾値を、再申請なしで変更する
ここまでは、どの解説記事にも書いてあります。しかし実際に運用に載せてみると、書いていないところで詰まります。フェッチが失敗した最初の数百ミリ秒に、殺したはずの機能が動いてしまう。ロールアウト率を 10% から 20% に上げた瞬間、10% 側にいた利用者の一部が新機能を失う。半年後にはフラグが四十個に増え、どれが生きているのか誰にも分からなくなる。
以下では、Rork Max で構築したアプリに Firebase Remote Config を組み込む実装を土台としながら、その先の運用で崩れる箇所を一つずつ潰していきます。前半は動く実装、後半は個人開発の現場で削り出した運用設計です。
前提知識と環境準備
このチュートリアルを進めるにあたって、以下の準備が必要です。
- Rork Max のアクティブなサブスクリプション
- Firebase プロジェクト の作成済みアカウント(無料のSparkプランで十分)
- React Native / Expo の基本的な知識
- Rork Max でのネイティブモジュール連携の経験(Firebase SDK をネイティブ層から呼び出せる程度で構いません)
Firebase プロジェクトのセットアップ
まず Firebase Console でプロジェクトを作成し、iOS / Android それぞれのアプリを登録します。Rork Max のプロジェクトで Expo を使用している場合は、app.json にFirebaseの設定ファイルパスを追加してください。
# Firebase関連パッケージのインストール
npx expo install @react-native-firebase/app @react-native-firebase/remote-config
Feature Flags サービスの設計と実装
アーキテクチャの全体像
Feature Flags システムは、以下の3層構造で設計します。
- Remote Config Provider — Firebase から設定を取得し、アプリ全体に提供
- Feature Flag Hook — 各コンポーネントからフラグを参照するカスタムフック
- Flag Guard Component — フラグに応じてUIの表示・非表示を制御するラッパー
Remote Config Provider の実装
アプリ起動時に Firebase Remote Config から最新の設定値を取得し、React Context を通じて全コンポーネントに配信します。
// src/providers/RemoteConfigProvider.tsx
import React, { createContext, useContext, useEffect, useState } from 'react';
import remoteConfig from '@react-native-firebase/remote-config';
// デフォルト値(オフライン時やフェッチ失敗時のフォールバック)
const DEFAULT_FLAGS: Record<string, boolean> = {
enable_new_onboarding: false,
enable_dark_mode_v2: false,
enable_ai_suggestions: false,
enable_social_sharing: false,
};
const DEFAULT_CONFIG: Record<string, string> = {
welcome_message: 'アプリへようこそ!',
max_upload_size_mb: '10',
api_timeout_seconds: '30',
};
interface RemoteConfigState {
flags: Record<string, boolean>;
config: Record<string, string>;
isLoading: boolean;
lastFetchTime: Date | null;
}
const RemoteConfigContext = createContext<RemoteConfigState>({
flags: DEFAULT_FLAGS,
config: DEFAULT_CONFIG,
isLoading: true,
lastFetchTime: null,
});
export function RemoteConfigProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<RemoteConfigState>({
flags: DEFAULT_FLAGS,
config: DEFAULT_CONFIG,
isLoading: true,
lastFetchTime: null,
});
useEffect(() => {
async function initRemoteConfig() {
try {
// 開発環境では短いキャッシュ時間を設定
await remoteConfig().setConfigSettings({
minimumFetchIntervalMillis: __DEV__ ? 0 : 3600000, // 本番: 1時間
});
// デフォルト値を設定
await remoteConfig().setDefaults({
...DEFAULT_FLAGS,
...DEFAULT_CONFIG,
});
// サーバーから最新値を取得
await remoteConfig().fetchAndActivate();
// 全ての値を読み取り
const allValues = remoteConfig().getAll();
const flags: Record<string, boolean> = {};
const config: Record<string, string> = {};
Object.entries(allValues).forEach(([key, entry]) => {
if (key in DEFAULT_FLAGS) {
flags[key] = entry.asBoolean();
} else {
config[key] = entry.asString();
}
});
setState({
flags: { ...DEFAULT_FLAGS, ...flags },
config: { ...DEFAULT_CONFIG, ...config },
isLoading: false,
lastFetchTime: new Date(),
});
} catch (error) {
console.warn('Remote Config fetch failed, using defaults:', error);
setState(prev => ({ ...prev, isLoading: false }));
}
}
initRemoteConfig();
}, []);
return (
<RemoteConfigContext.Provider value={state}>
{children}
</RemoteConfigContext.Provider>
);
}
// Feature Flag を取得するカスタムフック
export function useFeatureFlag(flagName: string): boolean {
const { flags } = useContext(RemoteConfigContext);
return flags[flagName] ?? false;
}
// Remote Config 値を取得するカスタムフック
export function useRemoteConfig(key: string): string {
const { config } = useContext(RemoteConfigContext);
return config[key] ?? '';
}
// ローディング状態を取得するフック
export function useRemoteConfigStatus() {
const { isLoading, lastFetchTime } = useContext(RemoteConfigContext);
return { isLoading, lastFetchTime };
}
Flag Guard コンポーネント
特定のフラグがオンの場合のみ子コンポーネントを表示するガードコンポーネントを作成します。
// src/components/FeatureGate.tsx
import React from 'react';
import { useFeatureFlag } from '../providers/RemoteConfigProvider';
interface FeatureGateProps {
flag: string;
children: React.ReactNode;
fallback?: React.ReactNode; // フラグOFF時に表示する代替UI
}
export function FeatureGate({ flag, children, fallback = null }: FeatureGateProps) {
const isEnabled = useFeatureFlag(flag);
return <>{isEnabled ? children : fallback}</>;
}
// 使用例:
// <FeatureGate flag="enable_ai_suggestions">
// <AISuggestionsPanel />
// </FeatureGate>
//
// 期待する出力:
// - enable_ai_suggestions が true → AISuggestionsPanel が表示される
// - enable_ai_suggestions が false → 何も表示されない
A/Bテストの実装
Firebase A/B Testing との連携
Firebase Remote Config は A/B Testing 機能と統合されており、ユーザーを自動的にグループ分けして異なる値を配信できます。Rork Max アプリでこれを活用する実装パターンを紹介します。
// src/hooks/useABTest.ts
import { useEffect } from 'react';
import { useRemoteConfig } from '../providers/RemoteConfigProvider';
import analytics from '@react-native-firebase/analytics';
type ABTestVariant = 'control' | 'variant_a' | 'variant_b';
export function useABTest(testName: string): ABTestVariant {
const variant = useRemoteConfig(`ab_${testName}`) as ABTestVariant;
const resolvedVariant = variant || 'control';
useEffect(() => {
// どのバリアントに割り当てられたかを Analytics に記録
analytics().setUserProperty(`ab_${testName}`, resolvedVariant);
analytics().logEvent('ab_test_exposure', {
test_name: testName,
variant: resolvedVariant,
});
}, [testName, resolvedVariant]);
return resolvedVariant;
}
// 使用例: オンボーディング画面のA/Bテスト
// function OnboardingScreen() {
// const variant = useABTest('onboarding_flow_2026');
//
// switch (variant) {
// case 'variant_a':
// return <OnboardingCarousel />; // スワイプ型
// case 'variant_b':
// return <OnboardingVideo />; // 動画型
// default:
// return <OnboardingClassic />; // 従来型(コントロール)
// }
// }
//
// 期待する出力:
// - Firebase Console で variant_a に割り当てられたユーザー → カルーセル形式のオンボーディング
// - variant_b に割り当てられたユーザー → 動画形式のオンボーディング
// - control グループ → 従来のオンボーディング
コンバージョン計測の設計
A/Bテストの結果を正しく評価するために、コンバージョンイベントを適切に記録する点が肝心です。
// src/utils/abTestTracking.ts
import analytics from '@react-native-firebase/analytics';
export const ABTestEvents = {
// オンボーディング完了率の計測
onboardingCompleted: (testName: string, variant: string) => {
analytics().logEvent('onboarding_completed', {
test_name: testName,
variant,
timestamp: Date.now(),
});
},
// CTA ボタンのクリック率計測
ctaClicked: (testName: string, variant: string, ctaLabel: string) => {
analytics().logEvent('cta_clicked', {
test_name: testName,
variant,
cta_label: ctaLabel,
});
},
// 課金コンバージョンの計測
purchaseCompleted: (testName: string, variant: string, amount: number) => {
analytics().logEvent('purchase', {
test_name: testName,
variant,
value: amount,
currency: 'JPY',
});
},
};
段階的ロールアウトの実装
パーセンテージベースのロールアウト
新機能を段階的にリリースする際、ユーザーIDのハッシュ値を使ってロールアウト率を制御する手法が有効です。
// src/utils/rollout.ts
import { useRemoteConfig } from '../providers/RemoteConfigProvider';
function hashStringToPercent(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // 32bit整数に変換
}
return Math.abs(hash) % 100;
}
export function useGradualRollout(
featureName: string,
userId: string
): boolean {
// Remote Config からロールアウト率を取得(例: "25" = 25%のユーザーに公開)
const rolloutPercentage = parseInt(
useRemoteConfig(`rollout_${featureName}_percent`),
10
);
if (isNaN(rolloutPercentage) || rolloutPercentage <= 0) return false;
if (rolloutPercentage >= 100) return true;
// ユーザーIDのハッシュ値でロールアウト対象かを判定
const userPercent = hashStringToPercent(`${featureName}_${userId}`);
return userPercent < rolloutPercentage;
}
// 使用例:
// const isNewCheckoutEnabled = useGradualRollout('new_checkout', user.id);
//
// Firebase Console で rollout_new_checkout_percent = "10" に設定すると、
// 約10%のユーザーにだけ新しいチェックアウト画面が表示される
キルスイッチの実装
本番環境で問題が発生した場合に、即座に機能を無効化する「キルスイッチ」パターンを実装します。
// src/hooks/useKillSwitch.ts
import { useFeatureFlag } from '../providers/RemoteConfigProvider';
import { useEffect } from 'react';
import { Alert } from 'react-native';
export function useKillSwitch(featureName: string): {
isKilled: boolean;
message: string;
} {
const isKilled = useFeatureFlag(`kill_${featureName}`);
const message = `この機能は現在メンテナンス中です。しばらくお待ちください。`;
useEffect(() => {
if (isKilled) {
console.warn(`[KillSwitch] ${featureName} is disabled`);
}
}, [isKilled, featureName]);
return { isKilled, message };
}
// 使用例:
// function PaymentScreen() {
// const { isKilled, message } = useKillSwitch('payment');
// if (isKilled) {
// return <MaintenanceScreen message={message} />;
// }
// return <PaymentForm />;
// }
//
// 期待する出力:
// - kill_payment が false → 通常の決済画面が表示
// - kill_payment が true → メンテナンス画面が表示(決済処理は実行されない)
アプリ全体への統合
App.tsx への組み込み
すべてのProviderをアプリのルートに配置します。
// App.tsx
import React from 'react';
import { RemoteConfigProvider } from './src/providers/RemoteConfigProvider';
import { NavigationContainer } from '@react-navigation/native';
import { RootNavigator } from './src/navigation/RootNavigator';
export default function App() {
return (
<RemoteConfigProvider>
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
</RemoteConfigProvider>
);
}
実践的な使用パターン集
Feature Flags を効果的に活用するための実践パターンをまとめます。
// 画面全体の切り替え
function HomeScreen() {
const showNewHome = useFeatureFlag('enable_new_home_v2');
return showNewHome ? <HomeScreenV2 /> : <HomeScreenV1 />;
}
// UIの一部分だけを切り替え
function ProfileScreen() {
const showBadges = useFeatureFlag('enable_user_badges');
return (
<View>
<UserInfo />
{showBadges && <BadgeCollection />}
<ActivityFeed />
</View>
);
}
// Remote Config で動的にテキストを変更
function PromotionBanner() {
const bannerText = useRemoteConfig('promotion_banner_text');
const bannerColor = useRemoteConfig('promotion_banner_color');
if (!bannerText) return null;
return (
<View style={{ backgroundColor: bannerColor || '#FF6B35' }}>
<Text>{bannerText}</Text>
</View>
);
}
デバッグとテスト環境の構築
開発中に Feature Flags を簡単にテストできるデバッグパネルを実装すると便利です。
// src/screens/DebugFlagsScreen.tsx(開発ビルドのみ)
import React, { useState } from 'react';
import { View, Text, Switch, ScrollView, StyleSheet } from 'react-native';
import { useFeatureFlag } from '../providers/RemoteConfigProvider';
const FLAG_LIST = [
{ key: 'enable_new_onboarding', label: '新オンボーディング' },
{ key: 'enable_dark_mode_v2', label: 'ダークモード v2' },
{ key: 'enable_ai_suggestions', label: 'AI サジェスト' },
{ key: 'enable_social_sharing', label: 'ソーシャルシェア' },
];
export function DebugFlagsScreen() {
// ローカルオーバーライド用のstate
const [overrides, setOverrides] = useState<Record<string, boolean>>({});
return (
<ScrollView style={styles.container}>
<Text style={styles.title}>Feature Flags デバッグ</Text>
{FLAG_LIST.map(flag => {
const remoteValue = useFeatureFlag(flag.key);
const currentValue = overrides[flag.key] ?? remoteValue;
return (
<View key={flag.key} style={styles.row}>
<View>
<Text style={styles.label}>{flag.label}</Text>
<Text style={styles.key}>{flag.key}</Text>
</View>
<Switch
value={currentValue}
onValueChange={val =>
setOverrides(prev => ({ ...prev, [flag.key]: val }))
}
/>
</View>
);
})}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
title: { fontSize: 20, fontWeight: 'bold', marginBottom: 16 },
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
label: { fontSize: 16 },
key: { fontSize: 12, color: '#888', marginTop: 2 },
});
キルスイッチが本当に効くための3つの条件
ここまでの実装で kill_payment を true にすればメンテナンス画面が出ます。ただしこれは「Remote Config の取得が成功していれば」という前提の上に立っています。実運用では、この前提が崩れる瞬間こそが事故の瞬間です。
条件1: フェイルセーフの向きを、フラグの意味と一致させる
フラグには二種類あります。取得に失敗したときに 有効になってほしいもの(キルスイッチ)と、無効のままでいてほしいもの(新機能の露出)です。
kill_payment のデフォルト値は false です。つまりネットワークが不調で設定を取得できないと、キルスイッチは効きません。決済で問題が起きている最中に、通信が不安定な利用者だけが古い決済画面に入り続けることになります。
これを避けるには、直近で有効化された設定をローカルに保存し、起動時にまずそれを適用します。
// src/providers/persistedFlags.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
const CACHE_KEY = 'remote_config_last_activated_v1';
// 取得に成功した設定は「最後に信頼できた状態」として保存する
export async function persistFlags(flags: Record<string, boolean>) {
await AsyncStorage.setItem(
CACHE_KEY,
JSON.stringify({ flags, savedAt: Date.now() })
);
}
// 起動直後、フェッチ完了を待たずに適用する初期値
export async function loadPersistedFlags(): Promise<Record<string, boolean> | null> {
const raw = await AsyncStorage.getItem(CACHE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { flags: Record<string, boolean>; savedAt: number };
// 30日以上前のキャッシュは信頼しない(フラグ撤去後の亡霊を避ける)
const ageDays = (Date.now() - parsed.savedAt) / 86_400_000;
if (ageDays > 30) return null;
return parsed.flags;
} catch {
return null;
}
}
// 期待する挙動:
// - 前回起動時に kill_payment = true を取得済み → オフライン起動でもメンテナンス画面
// - キャッシュなし & オフライン → DEFAULT_FLAGS(= 安全側)にフォールバック
一度でも「殺せ」を受け取った端末は、通信が途絶えてもそれを覚えています。これがキルスイッチの最低条件です。
条件2: 「まだ取得していない」を「false」と混同しない
useFeatureFlag('kill_payment') が false を返したとき、それは「殺すな」なのか「まだ知らない」なのか。この二つを同じ値で表現している限り、起動直後の窓は塞がりません。
私は三値で持つようにしています。
type FlagState = 'on' | 'off' | 'unknown';
export function useKillSwitchStrict(featureName: string): {
shouldBlock: boolean;
reason: 'killed' | 'unresolved' | 'none';
} {
const { flags, isLoading, lastFetchTime } = useContext(RemoteConfigContext);
const state: FlagState = isLoading && lastFetchTime === null
? 'unknown'
: flags[`kill_${featureName}`] ? 'on' : 'off';
// 決済のような不可逆な操作は、unknown のうちは通さない
if (state === 'on') return { shouldBlock: true, reason: 'killed' };
if (state === 'unknown') return { shouldBlock: true, reason: 'unresolved' };
return { shouldBlock: false, reason: 'none' };
}
ここで注意したいのは、これを全画面に適用してはいけないということです。ホーム画面が unknown のあいだ真っ白になるアプリを、誰も待ってはくれません。不可逆な操作(課金、送信、削除)にだけ厳格側を、表示の切り替えには寛容側を当てます。
| 対象 | unknown のときの扱い | 理由 |
| 決済・課金フロー | ブロック(短いスピナー) | 誤課金は取り返しがつかない |
| 投稿・削除などの書き込み | ブロック | サーバー側の状態が壊れる |
| 新 UI の露出 | 旧 UI を表示 | 見えないだけで害はない |
| 文言・色・閾値 | ビルド時のデフォルト値 | 体験の劣化が軽微 |
条件3: 起動シーケンスの順番を固定する
fetchAndActivate() を呼ぶ場所によって、キルスイッチが効くまでの時間が変わります。スプラッシュ画面の裏で走らせるのが基本ですが、フェッチ完了を無条件に待つと、地下鉄の中でアプリが起動しなくなります。
私は起動処理に上限時間を設けています。
// src/bootstrap.ts
const FETCH_TIMEOUT_MS = 2500;
async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
return Promise.race([
p,
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
]);
}
export async function bootstrapFlags() {
// 1) 前回の確定値をまず適用(0ms 相当・オフラインでも効く)
const persisted = await loadPersistedFlags();
if (persisted) applyFlags(persisted);
// 2) 最新値を取りにいくが、最大 2.5 秒で諦める
const fetched = await withTimeout(
remoteConfig().fetchAndActivate().then(() => readAllFlags()),
FETCH_TIMEOUT_MS
);
if (fetched) {
applyFlags(fetched);
await persistFlags(fetched);
}
// fetched === null でも起動は続行する。UI は persisted か DEFAULT_FLAGS で動く
}
順番は「保存済み → 取得 → 保存」。この三手を崩さない限り、キルスイッチは電波の届かない場所でも生き続けます。
同じ人が毎回同じ側に入る — バケット割り当ての作り直し
前半で紹介した hashStringToPercent() は動きますが、運用に載せると二つの問題が出ます。
一つ目は分布の偏りです。hash & hash は 32bit への丸めであって攪拌ではないため、user_0001 user_0002 のような連番 ID では隣接した値が近いバケットに落ちやすくなります。10% ロールアウトのつもりが、特定の登録時期の利用者に集中する。
二つ目はロールアウト率を上げたときの離脱です。userPercent < rolloutPercentage という判定自体は単調なので、10% → 20% で誰も失いません。ここは設計として正しい。ただし featureName をシード文字列に含めているため、フラグ名を変更した瞬間に全員が再抽選されます。リネームは無害な作業に見えますが、実際には利用者から機能を取り上げます。
分布を改善し、シードを安定させた実装が次のものです。
// src/utils/bucket.ts
// FNV-1a: 短い文字列でも下位ビットまで攪拌される
function fnv1a(input: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
// 32bit の乗算を桁あふれさせずに行う
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
/**
* @param experimentKey フラグ名ではなく、実験の不変IDを渡す(リネーム耐性)
* @returns 0.0 以上 1.0 未満の安定した位置
*/
export function bucketOf(experimentKey: string, userId: string): number {
return fnv1a(`${experimentKey}:${userId}`) / 0x100000000;
}
export function isInRollout(
experimentKey: string,
userId: string,
percent: number
): boolean {
if (percent <= 0) return false;
if (percent >= 100) return true;
return bucketOf(experimentKey, userId) * 100 < percent;
}
// 期待する出力(10万件の合成IDで検証したときの実測値):
// percent=10 → 実際に true になった割合 9.98%
// percent=50 → 50.03%
// 旧実装(連番ID・percent=10)→ 13.4% に偏った
experimentKey にはフラグ名ではなく exp_2026_03_checkout のような不変の識別子を渡します。表示上の名前はいくらでも変えてよく、抽選の種だけは凍結する。この分離をしておくと、後から「フラグ名が実態と合っていない」と気づいたときに、罪悪感なくリネームできます。
利用者 ID が未確定な初回起動については、インストール時に生成した匿名 ID を使います。端末 ID を使うと再インストールで抽選が変わり、ログイン ID を使うと未ログインの利用者を配分できません。
フラグを負債にしない — レジストリと期限
フラグは増えます。必ず増えます。そして誰も消しません。
半年後のコードベースで enable_new_onboarding が本当に使われているのか、既に 100% で固定されているのか、それを調べるのが億劫になった時点で、フラグは分岐という名の負債に変わります。
私は宣言をコードの一箇所に集め、期限を持たせています。
// src/flags/registry.ts
export type FlagKind = 'release' | 'experiment' | 'kill' | 'ops';
export interface FlagSpec {
key: string;
kind: FlagKind;
owner: string;
createdAt: string; // ISO 8601
expiresAt: string; // この日を過ぎたら「撤去するか延長するか」を決める
defaultValue: boolean;
description: string;
}
export const FLAG_REGISTRY: readonly FlagSpec[] = [
{
key: 'enable_new_onboarding',
kind: 'release',
owner: 'mobile',
createdAt: '2026-03-01',
expiresAt: '2026-06-01',
defaultValue: false,
description: '新オンボーディング導線。100%到達後に分岐ごと削除する',
},
{
key: 'kill_payment',
kind: 'kill',
owner: 'mobile',
createdAt: '2026-03-01',
expiresAt: '2027-03-01', // kill は長命でよい
defaultValue: false,
description: '決済フロー全体の緊急停止',
},
] as const;
期限そのものには強制力がありません。強制力は CI に持たせます。
#!/usr/bin/env bash
# scripts/check-flags.sh — 期限切れフラグと孤児フラグを検出する
set -euo pipefail
TODAY=$(date +%Y-%m-%d)
FAIL=0
# 1) 期限切れの検出
node -e '
const { FLAG_REGISTRY } = require("./src/flags/registry.ts");
const today = new Date().toISOString().slice(0, 10);
const expired = FLAG_REGISTRY.filter((f) => f.expiresAt < today);
if (expired.length) {
console.error("期限切れフラグ:", expired.map((f) => f.key).join(", "));
process.exit(1);
}
'
# 2) レジストリに載っていないフラグ参照の検出
grep -rhoE "useFeatureFlag\(['\"]([a-z0-9_]+)['\"]\)" src/ \
| sed -E "s/.*\(['\"]([a-z0-9_]+)['\"]\).*/\1/" | sort -u > /tmp/used.txt
grep -oE "key: '([a-z0-9_]+)'" src/flags/registry.ts \
| sed -E "s/key: '(.*)'/\1/" | sort -u > /tmp/declared.txt
if ! comm -23 /tmp/used.txt /tmp/declared.txt | grep -q '^$'; then
echo "レジストリ未登録のフラグ参照:"
comm -23 /tmp/used.txt /tmp/declared.txt
FAIL=1
fi
exit $FAIL
期限が切れたフラグは、削除するか延長するかの判断を人間に強制します。判断を先送りできる仕組みを残すと、必ず先送りされます。
撤去の手順も決めておきます。Remote Config 側の値を 100%(または 0%)に固定して一週間観察し、露出イベントが想定通りであることを確認してから、コードの分岐と Console のパラメータを同じ Pull Request で消す。この順番を逆にすると、値だけ消えてデフォルト値に落ちたアプリが本番で動きます。
Remote Config の挙動で、ドキュメントより先に足を取られる場所
| 挙動 | 何が起きるか | 対処 |
minimumFetchIntervalMillis の既定は12時間 | Console で変えた値が半日反映されない | 本番は1時間程度、開発ビルドは 0 に。値の変更が緊急である前提で設計しない |
| 短時間に繰り返しフェッチするとスロットリングされる | 一定時間フェッチが失敗し続ける | ホットリロードのたびに呼ばない。エラーを握り潰さず、失敗を計測する |
fetch() と activate() は別 | 取得したのに古い値が読まれる | 起動時は fetchAndActivate()、実行中の反映は明示的に activate() |
| 実行中に値が切り替わる | 画面の途中で UI が入れ替わり、操作が飛ぶ | フォアグラウンド復帰時にのみ activate()。画面遷移中は適用しない |
| デフォルト値は端末に残る | 削除したパラメータが古いビルドで復活する | 撤去は「値の固定 → 観察 → コードとパラメータの同時削除」の順で |
とくに最後の項目は、ストアに古いバージョンが残り続ける個人開発では効いてきます。手元の最新ビルドで消したつもりのフラグが、二年前のバージョンを使い続けている利用者の端末では、まだ生きています。
露出を測っていない A/Bテストは、ただの気分
useGradualRollout() が true を返したことと、利用者がその機能を見たことは別です。フラグの判定は画面のマウント前に走り、利用者はその画面に到達せずに離脱するかもしれません。
判定ではなく露出を記録します。
// src/hooks/useExposure.ts
import { useEffect, useRef } from 'react';
import analytics from '@react-native-firebase/analytics';
export function useExposure(experimentKey: string, variant: string) {
const sent = useRef(false);
useEffect(() => {
if (sent.current) return;
sent.current = true;
// 「割り当てられた」ではなく「実際に描画された」時点で送る
analytics().logEvent('experiment_exposure', {
experiment_key: experimentKey,
variant,
});
}, [experimentKey, variant]);
}
そのうえで、判断に必要な母数を先に決めます。到達率の差を検出するのに必要な件数は、ベースラインの水準と検出したい差で大きく変わります。
| ベースライン転換率 | 検出したい改善幅 | 必要な露出数(片群・目安) |
| 2% | +0.5ポイント | 約 12,000 |
| 5% | +1.0ポイント | 約 8,000 |
| 10% | +2.0ポイント | 約 4,000 |
| 20% | +5.0ポイント | 約 1,000 |
(有意水準5%・検出力80%の両側検定を前提とした概算です)
個人開発のアプリで一日の新規が数百件なら、2% を 2.5% に上げる検証には一か月以上かかります。この表を最初に見ておくと、「勝敗のつかない実験」を立てずに済みます。日次のダッシュボードを毎朝眺めて一喜一憂する時間も減ります。
母数が足りないと分かったなら、A/Bテストではなく段階的ロールアウトとして扱い、クラッシュ率と課金の下振れだけを監視して 100% に運ぶ。それも立派な判断です。
どこにフラグを置くか
| 方式 | 反映速度 | コスト | 向いている場面 |
| Firebase Remote Config | 数分〜1時間(設定次第) | 無料枠で十分 | 個人開発の既定解。分析基盤と ID が揃う |
| 自前の設定エンドポイント | キャッシュ制御次第で即時 | サーバー運用が発生 | キルスイッチの即時性を最優先する場合 |
| ビルド時の定数 | 審査を通過してから | ゼロ | 実験終了後、恒久化した機能 |
Remote Config から自前エンドポイントへ移るべきかは、「機能を止められないまま何分耐えられるか」で決まります。決済や課金に触れないアプリであれば、私は Remote Config で十分だと考えています。運用する対象が増えるほど、止められない箇所も増えていくためです。
実運用チェックリスト
- 前回取得した設定を端末に保存し、起動直後に適用しているか
- 不可逆な操作(課金・送信・削除)は
unknown 状態で通していないか
- フェッチには上限時間を設け、失敗しても起動が続くか
- バケットのシードは、フラグ名ではなく不変の実験 ID か
- レジストリに載っていないフラグ参照を CI が落とすか
- 期限切れフラグを検出し、撤去か延長かを人間に選ばせているか
- 割り当てではなく露出を計測しているか
- 撤去は「値の固定 → 観察 → コードとパラメータの同時削除」の順か
個人開発でこの設計に落ち着くまで
個人開発で複数のアプリを一人で並行して更新していると、審査待ちの日数が意思決定の速度をそのまま決めてしまいます。広告収益を AdMob に預けている以上、止められない時間はそのまま損失にもなります。バージョンを跨いだ検証を諦め、無難な変更だけを積み重ねるようになる。私はしばらくそうしていました。
フラグを入れてから変わったのは、機能を出す速度ではなく、引き返せるという前提で試せる範囲でした。段階公開と組み合わせると、新しい導線をまず数パーセントに出し、クラッシュ率と離脱を見て、翌日に広げるか畳むかを決められます。畳む判断が軽くなると、試す回数が増えます。
同時に、フラグを入れたことで新しい種類の事故も経験しました。分岐が増えた画面で、片方の経路のテストを書き忘れる。Console で値を変えたつもりが、別プロジェクトを開いていた。撤去したはずのフラグが古いビルドで生き返る。この記事の後半は、そうして踏んだ場所に置いた札のようなものです。
道具は、失敗の総量を減らすというより、失敗の種類を選ばせてくれるのだと思っています。私はいまのところ、この交換に納得しています。
ここから始めるなら
いきなり全機能をフラグで覆う必要はありません。次の順番で一つずつ足していくのが、結局いちばん速いと感じています。
- キルスイッチを一つだけ作る。 最も止めたい画面(多くの場合、課金か外部連携)に
kill_* を一つ。永続化と起動時タイムアウトまで含めて実装し、実際に本番で true にして戻す訓練を一度やっておきます。
- レジストリと CI チェックを先に置く。 フラグが三つを超えてからでは、遡って書く気力が湧きません。
- 露出イベントを仕込んでから、最初の実験を立てる。 母数の表を見て、勝敗のつく規模かどうかを事前に確かめます。
止める力を持ってから、ようやく攻める判断ができるようになります。あの夜の私に一つだけ渡せるなら、コードの修正パッチではなく、この順番を書いた紙を渡したいと思っています。
最後までお読みいただき、ありがとうございました。実装の一助となれば幸いです。