zafer-skills について
zafer-skills は、Claude Code で Expo と React Native アプリを開発するためのスキルです。新規アプリの画面、ナビゲーション、依存ライブラリ、広告同意、コード検査の手順を定めています。
主な機能
- 01Expo アプリ作成手順
- 02必須画面とナビゲーションの指定
- 03広告設定と同意フロー
- 04依存関係とストレージの規則
- 05コード検査とビルド手順
活用シーン
- 01オンボーディングとペイウォールを備えた Expo アプリの作成
- 02AdMob の同意と広告表示の設定
- 03React Native プロジェクトの実装規則の確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zaferayan/ skills --skill zafer-skillsインストール後、Claude Code で/zafer-skillsを入力して読み込まれたか確認します。
同梱ファイルREADME.md
詳細
Expo モバイルアプリ開発ガイド
IMPORTANT: これは SKILL ファイルであり、プロジェクトではありません。このフォルダーでは絶対に npm/bun install を実行しないでください。この場所にコードファイルを作成しないでください。新しいプロジェクトを作成するときは、必ず最初にユーザーにプロジェクトのパスを確認するか、別のディレクトリ(例:
~/Projects/app-name)に作成してください。
このガイドは、Claude Code で Expo プロジェクトを扱う際に必要なコンテキストを提供するために作成されています。
必須要件
新しい Expo プロジェクトを作成するときは、以下のすべてを必ず含めてください。
必須画面(必ず作成)
-
src/app/onboarding.tsx- 全画面表示の背景動画とグラデーションオーバーレイを使った、スワイプ式オンボーディング -
src/app/notification-permission.tsx- システムの許可ダイアログを表示する前に通知について説明する画面(アプリで位置情報を使用する場合は、location-permission.tsxも追加してください) -
src/app/paywall.tsx- ペイウォール画面(許可画面の後に表示) -
src/app/settings.tsx- 言語、テーマ、通知、オンボーディングのリセットオプションを備えた設定画面
オンボーディング動画の実装(必須)
オンボーディング画面には、全画面表示の背景動画が必須です。ローカルファイルではなく URL を使用してください。
import { useVideoPlayer, VideoView } from "expo-video";
const VIDEO_URL =
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
const player = useVideoPlayer(VIDEO_URL, (player) => {
player.loop = true;
player.muted = true;
player.play();
});
// In render:
<VideoView
player={player}
style={StyleSheet.absoluteFill}
contentFit="cover"
nativeControls={false}
/>;
expo-video をインポートするだけで、VideoView コンポーネントを実際に使用しないことは避けてください。
必須ナビゲーション(必ず使用)
- タブナビゲーションには
expo-router/unstable-native-tabsのNativeTabsを使用してください。@react-navigation/bottom-tabsや expo-router のTabsは絶対に使用しないでください。
必須コンテキストプロバイダー(必ずラップ)
import { ThemeProvider } from "@/context/theme-context";
import {
DarkTheme,
DefaultTheme,
ThemeProvider as NavigationThemeProvider,
} from "@react-navigation/native";
<ThemeProvider>
<OnboardingProvider>
<AdsProvider>
<NavigationThemeProvider
value={colorScheme === "dark" ? DarkTheme : DefaultTheme}
>
<Stack />
</NavigationThemeProvider>
</AdsProvider>
</OnboardingProvider>
</ThemeProvider>;
AdsProvider は OnboardingProvider の内側に配置してください。オンボーディングが完了するまで、同意フォーム/ATT プロンプトが表示されないように待機します(広告の同意を参照)。
必須ライブラリ(必ずインストール)
ライブラリのインストールには npx expo install を使用してください(npm/yarn/bun install は使用しないでください)。
npx expo install expo-iap react-native-purchases react-native-google-mobile-ads expo-tracking-transparency expo-notifications i18next react-i18next expo-localization react-native-reanimated expo-video expo-audio expo-sqlite expo-linear-gradient expo-store-review expo-updates expo-dev-client posthog-react-native expo-haptics
ライブラリ:
expo-iap(StoreKit 2/Play Billing - 購入とプレミアム状態の確認)react-native-purchases(RevenueCat - オブザーバーモードのみ:購入記録+Apple Ads のアトリビューション)react-native-google-mobile-ads(AdMob+Google UMP の同意)expo-tracking-transparency(iOS ATT プロンプト)posthog-react-native(アナリティクス、エラー追跡、機能フラグ)expo-store-review(ネイティブの評価プロンプト)expo-updates(RTL/LTR の切り替え後に再読み込み)、expo-dev-clientexpo-hapticsexpo-notificationsi18next+react-i18next+expo-localizationreact-native-reanimatedexpo-video+expo-audioexpo-sqlite(localStorage 用)expo-linear-gradient(グラデーションオーバーレイ用)
AdMob の設定(app.json で必須)
AdMob を動作させるには、必ず app.json に以下を追加してください。
{
"expo": {
"plugins": [
[
"react-native-google-mobile-ads",
{
"androidAppId": "ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy",
"iosAppId": "ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy"
}
]
]
}
}
開発/テストでは、テスト用 App ID を使用してください。
- iOS:
ca-app-pub-3940256099942544~1458002511 - Android:
ca-app-pub-3940256099942544~3347511713
この設定を省略すると、アプリが GADInvalidInitializationException でクラッシュするため、省略しないでください。
以下のプラグインも追加してください(広告をパーソナライズできる場合、iOS では ATT の文言が必須です)。
[
"expo-tracking-transparency",
{
"userTrackingPermission": "Your permission lets us show ads that are more relevant to you. If you decline, you can keep using the app and will see less relevant ads."
}
],
"expo-iap"
バナー広告の実装(必須)
バナーは専用のコンポーネントに配置し、タブレイアウト内でタブバーの上に浮かせてください。
// src/components/banner-ad.tsx
import { useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { BannerAd, BannerAdSize, TestIds } from 'react-native-google-mobile-ads';
import { useAds } from '@/context/ads-context';
import { AD_UNIT_IDS } from '@/lib/ads';
export function AdBanner() {
const { isAdsEnabled } = useAds();
const [loaded, setLoaded] = useState(false);
if (!isAdsEnabled) return null;
return (
// Hidden until filled, so an empty slot never takes space
<View style={[styles.container, !loaded && styles.hidden]}>
<BannerAd
unitId={__DEV__ ? TestIds.BANNER : AD_UNIT_IDS.BANNER ?? TestIds.BANNER}
size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER}
// Personalisation follows the UMP + ATT choice; do NOT force requestNonPersonalizedAdsOnly
onAdLoaded={() => setLoaded(true)}
onAdFailedToLoad={() => setLoaded(false)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { alignItems: 'center', width: '100%' },
hidden: { opacity: 0, height: 0 },
});
// src/app/(tabs)/_layout.tsx
import { I18nManager, Platform, StyleSheet, View } from 'react-native';
import { usePathname } from 'expo-router';
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { AdBanner } from '@/components/banner-ad';
const TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 49 : 56;
export default function TabLayout() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
// Hide the banner on screens where it would get in the way (camera, compass, full-screen media...)
const showBanner = usePathname() !== '/camera';
return (
<View style={styles.container}>
<NativeTabs
// The native tab bar ignores React Native's RTL setting
unstable_nativeProps={{ direction: I18nManager.isRTL ? 'rtl' : 'ltr' }}
>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>{t('tabs.home')}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<NativeTabs.Trigger.Label>{t('tabs.settings')}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
</NativeTabs.Trigger>
</NativeTabs>
{showBanner && (
<View style={[styles.banner, { bottom: TAB_BAR_HEIGHT + insets.bottom }]}>
<AdBanner />
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
banner: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
});
- 開発時には必ず
TestIds.BANNERを使用してください。 - バナーはタブバーのすぐ上(
TAB_BAR_HEIGHT + insets.bottom)に浮かせてください。スクロールコンテンツには十分な下部パディングを設けてください。 useAds().isAdsEnabledは、プレミアムユーザー、および同意と SDK の初期化が完了するまではfalseです。
広告の同意:UMP + ATT(必須)
同意を得る前に広告を初期化してはいけません。順序:Google UMP フォーム(EU/UK の GDPR)→ iOS ATT プロンプト → mobileAds().initialize()。オンボーディングが完了してから実行し、権限画面と重ならないようにしてください。プレミアムユーザーの場合は完全にスキップしてください。
// src/lib/ads-consent.ts
import { Platform } from 'react-native';
import { AdsConsent, AdsConsentDebugGeography, AdsConsentPrivacyOptionsRequirementStatus } from 'react-native-google-mobile-ads';
import * as TrackingTransparency from 'expo-tracking-transparency';
// Test the EU form from anywhere: EXPO_PUBLIC_ADS_DEBUG_EEA=1 bun start (simulator / test devices only)
const DEBUG_EEA = __DEV__ && process.env.EXPO_PUBLIC_ADS_DEBUG_EEA === '1';
export async function gatherAdsConsent() {
let canRequestAds = false;
let privacyOptionsRequired = false;
try {
const info = await AdsConsent.gatherConsent(DEBUG_EEA ? { debugGeography: AdsConsentDebugGeography.EEA } : undefined);
canRequestAds = info.canRequestAds;
privacyOptionsRequired = info.privacyOptionsRequirementStatus === AdsConsentPrivacyOptionsRequirementStatus.REQUIRED;
} catch {
// Offline: fall back to the previous session's decision
canRequestAds = (await AdsConsent.getConsentInfo().catch(() => null))?.canRequestAds ?? false;
}
// ATT only if ads will be shown; declining still shows (non-personalised) ads
if (canRequestAds && Platform.OS === 'ios' && TrackingTransparency.isAvailable()) {
const { status } = await TrackingTransparency.getTrackingPermissionsAsync();
if (status === 'undetermined') await TrackingTransparency.requestTrackingPermissionsAsync();
}
return { canRequestAds, privacyOptionsRequired };
}
export const showAdsPrivacyOptions = () => AdsConsent.showPrivacyOptionsForm();
AdsProvider では:
- まずプレミアムかどうかを確認します(キャッシュされた値を確認してからストアを確認)。プレミアムユーザーには同意フォームを表示しません
gatherAdsConsent()がcanRequestAdsを返したら、mobileAds().setRequestConfiguration({ maxAdContentRating: MaxAdContentRating.PG, ... })を呼び出し、その後mobileAds().initialize()を呼び出しますisAdsEnabled、isPremium、privacyOptionsRequired、showPrivacyOptions、refreshPremiumStatusを公開します- 開発環境ではデフォルトで広告をオフにします(
__DEV__ && process.env.EXPO_PUBLIC_ADS_DEV !== '1') privacyOptionsRequiredが true の場合、設定画面に「プライバシーの選択肢」の行を必ず表示します(EU のルール)- 同意フォームの内容は AdMob コンソール → プライバシーとメッセージで設定します
トルコ語のローカライズ(重要)
tr.json を作成する際は、正しいトルコ語の文字を必ず使用してください。
- ı(小文字の点なし i)— i ではありません
- İ(大文字の点付き I)— I ではありません
- ü、Ü、ö、Ö、ç、Ç、ş、Ş、ğ、Ğ
例:
- ✅ "Ayarlar"、"Giriş"、"Çıkış"、"Başla"、"İleri"、"Güncelle"
- ❌ "Ayarlar"、"Giris"、"Cikis"、"Basla"、"Ileri"、"Guncelle"
禁止事項(絶対に使用しないでください)
- ❌ AsyncStorage — 代わりに
expo-sqlite/localStorage/installを使用します - ❌ lineHeight スタイル — 代わりに padding/margin を使用します
- ❌ expo-router の
Tabs— 代わりにNativeTabsを使用します - ❌
@react-navigation/bottom-tabs— 代わりにNativeTabsを使用します - ❌
expo-av— 動画にはexpo-video、音声にはexpo-audioを使用します - ❌
expo-ads-admob— 代わりにreact-native-google-mobile-adsを使用します - ❌ その他の広告ライブラリ —
react-native-google-mobile-adsのみを使用します - ❌ コールバック内の Reanimated フック — コンポーネントのトップレベルで呼び出します
- ❌ ペイウォール上の固定価格や割引バッジ — 常にストア形式の価格(
displayPrice)を使用します - ❌ UMP の同意を得る前の
mobileAds().initialize()の呼び出し - ❌ 監視なしの
react-native-purchases購入呼び出し(purchasePackage)— RevenueCat はオブザーバーモードで動作し、購入はexpo-iap経由で行います
Reanimated の使用(重要)
コールバック、ループ、条件分岐の中で useAnimatedStyle、useSharedValue、その他の Reanimated フックを絶対に呼び出さないでください。
❌ 誤り:
const renderItem = () => {
const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 })); // ERROR!
return <Animated.View style={animatedStyle} />;
};
✅ 正しい例:
function MyComponent() {
const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 })); // Top level
return <Animated.View style={animatedStyle} />;
}
リストの場合は、各項目用に別のコンポーネントを作成してください。
function AnimatedItem({ item }) {
const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 }));
return <Animated.View style={animatedStyle}>{item.name}</Animated.View>;
}
// In FlatList:
renderItem={({ item }) => <AnimatedItem item={item} />}
作成後のクリーンアップ(必ず実行)
新しい Expo プロジェクトを作成した後は、必ず次の作業を行ってください。
(tabs)フォルダーを使用する場合は、ルートの競合を避けるためsrc/app/index.tsxを削除します。
rm src/app/index.tsx
- 次のファイルを確認し、
lineHeightを削除します。
src/components/themed-text.tsx(デフォルトで lineHeight が含まれているため、削除します)lineHeightを使用しているその他のコンポーネント
lineHeight が含まれる箇所をすべて検索して削除します。
grep -r "lineHeight" src/
代わりに padding または margin を使用します。
コード完成後(必ず実行)
コードの作成または変更が完了したら、次のコマンドを順番に必ず実行してください。
npx expo install --fix
npx expo prebuild --clean
install --fixは依存関係のバージョンの不一致を修正しますprebuild --cleanは ios および android フォルダーを再作成します
これらの手順を省略しないでください。
プロジェクトの作成
ユーザーからアプリの作成を依頼された場合、必ず次の手順に従ってください。
- 最初にバンドル ID を尋ねます(例:「バンドル ID は何ですか?例:com.company.appname」)
- 次のコマンドを使用して、現在のディレクトリにプロジェクトを作成します。
bunx create-expo -t default@next app-name
現在のベースラインは Expo SDK 57/React Native 0.86/React 19.2(新アーキテクチャのみ)です。app.json で React Compiler と型付きルートを有効にします。
{
"expo": {
"experiments": { "typedRoutes": true, "reactCompiler": true },
"ios": {
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"CFBundleAllowMixedLocalizations": true
}
}
}
}
- バンドル ID を指定して
app.jsonを更新します。
{
"expo": {
"ios": {
"bundleIdentifier": "com.company.appname"
},
"android": {
"package": "com.company.appname"
}
}
}
- その後、プロジェクトディレクトリに移動して、必要なすべての画面の実装を開始します
- プロジェクトのパスは尋ねないでください。必ず現在のディレクトリを使用します
技術スタック
- フレームワーク:Expo、React Native
- ナビゲーション:Expo Router(ファイルベースのルーティング)、NativeTabs
- 状態管理:React Context API
- 翻訳:i18next、react-i18next
- 購入:
expo-iapを介した StoreKit 2 / Play Billing(プレミアムの信頼できる情報源) - 購入記録と Apple Ads のアトリビューション:オブザーバーモードの RevenueCat(
react-native-purchases) - 広告:Google AdMob(react-native-google-mobile-ads)+ UMP 同意 + ATT
- 分析:PostHog(
posthog-react-native、EU ホスト)- イベント、画面、エラー追跡、機能フラグ - 評価:
expo-store-review - 通知:expo-notifications
- アニメーション:react-native-reanimated
- ストレージ:expo-sqlite ポリフィル経由の localStorage
WARNING:AsyncStorage は使用しないでください!代わりに expo-sqlite ポリフィルを使用してください。
- 使用例
import "expo-sqlite/localStorage/install";
globalThis.localStorage.setItem("key", "value");
console.log(globalThis.localStorage.getItem("key")); // 'value'
WARNING:
lineHeightは絶対に使用しないでください!React Native でレイアウトの問題が発生します。代わりに padding または margin を使用してください。
プロジェクト構成
project-root/
├── src/
│ ├── app/
│ │ ├── _layout.tsx
│ │ ├── index.tsx
│ │ ├── explore.tsx
│ │ ├── settings.tsx
│ │ ├── paywall.tsx
│ │ ├── notification-permission.tsx
│ │ └── onboarding.tsx
│ ├── components/
│ │ ├── ui/
│ │ ├── themed-text.tsx
│ │ └── themed-view.tsx
│ ├── constants/
│ │ ├── theme.ts
│ │ └── [data-files].ts
│ ├── context/
│ │ ├── onboarding-context.tsx
│ │ └── ads-context.tsx
│ ├── hooks/
│ │ ├── use-notifications.ts
│ │ └── use-color-scheme.ts
│ ├── lib/
│ │ ├── notifications.ts
│ │ ├── purchases.ts # expo-iap (StoreKit 2)
│ │ ├── revenuecat.ts # observer mode
│ │ ├── premium-cache.ts
│ │ ├── ads.ts
│ │ ├── ads-consent.ts # UMP + ATT
│ │ ├── analytics.ts # PostHog
│ │ ├── rating.ts # expo-store-review
│ │ └── i18n.ts
│ └── locales/
│ ├── tr.json
│ └── en.json
├── assets/
│ └── images/
├── ios/
├── android/
├── app.json
├── eas.json
├── store.config.json # EAS Metadata (App Store listing)
├── package.json
└── tsconfig.json
タブナビゲーション(NativeTabs)
Expo Router はネイティブのタブナビゲーションに NativeTabs を使用します。
import { NativeTabs } from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="compass.fill" md="explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
</NativeTabs.Trigger>
</NativeTabs>
);
}
NativeTabs のプロパティ
- sf:SF Symbols のアイコン名(iOS)
- md:Material Design のアイコン名(Android)
- name:ルートファイル名
- タブの順序はトリガーの順序に従います
- RTL:
unstable_nativeProps={{ direction: I18nManager.isRTL ? 'rtl' : 'ltr' }}を渡してください。ネイティブバーはI18nManagerに自動では従いません - Android で NativeTabs の動作に問題がある場合は、Android のみ expo-router の
Tabsに切り替えてください(if (Platform.OS === 'ios') return <NativeTabs>...)。IconSymbolアイコンとハプティック対応のタブボタンを使用してください。iOS では常に NativeTabs を使用します
よく使うアイコン
| 用途 | SF Symbol | Material アイコン |
|---|---|---|
| ホーム | house.fill | home |
| 探索 | compass.fill | explore |
| 設定 | gear | settings |
| プロフィール | person.fill | person |
| 検索 | magnifyingglass | search |
| お気に入り | heart.fill | favorite |
| 通知 | bell.fill | notifications |
開発コマンド
bun install
bun start
bun ios
bun android
bun lint
npx expo install --fix
npx expo prebuild --clean
EAS ビルドコマンド
eas.json の基本設定:
{
"cli": { "version": ">= 16.28.0", "appVersionSource": "remote", "promptToConfigurePushNotifications": false },
"build": {
"development": { "developmentClient": true, "distribution": "internal" },
"preview": { "distribution": "internal" },
"production": { "autoIncrement": true }
},
"submit": { "production": { "ios": { "ascAppId": "<APP_STORE_CONNECT_APP_ID>" } } }
}
eas build --profile development --platform ios
eas build --profile development --platform android
eas build --profile production --platform ios
eas build --profile production --platform android
eas submit --platform ios
eas submit --platform android
eas metadata:push # push store.config.json (title, subtitle, keywords, description per locale)
重要なモジュール
購入:expo-iap 経由の StoreKit 2 + RevenueCat のオブザーバーモード
src/lib/purchases.ts:購入とプレミアムに関するすべての処理はexpo-iapを介します(initConnection、fetchProducts、requestPurchase、finishTransaction、getActiveSubscriptions、getAvailablePurchases、restorePurchases)src/lib/revenuecat.ts:RevenueCat はpurchasesAreCompletedBy: { type: PURCHASES_ARE_COMPLETED_BY_TYPE.MY_APP, storeKitVersion: STOREKIT_VERSION.STOREKIT_2 }で設定されています。購入の記録(Purchases.recordPurchase(productId))と Apple Ads のアトリビューション(enableAdServicesAttributionTokenCollection()。ATT は不要)のみを行います。プレミアムかどうかの判定は行いません。RevenueCat が停止していても、購入は引き続き機能します- 製品:
weeklyとyearlyのサブスクリプション(同じサブスクリプショングループ)および任意のlifetime非消費型アイテム - プレミアムは、アクティブなサブスクリプションまたは lifetime 購入がある状態です。最後の値をキャッシュ(
premium-cache.ts)し、バックグラウンドタスクがストアにアクセスせずに読み取れるようにします
// Listen once at startup; renewals, interrupted purchases and Ask to Buy approvals arrive here too.
// Unfinished transactions are re-delivered on every launch.
purchaseUpdatedListener((purchase) => {
if (purchase.purchaseState === 'pending') return notify(purchase); // Ask to Buy: wait for approval
void recordRevenueCatPurchase(purchase.productId) // with a ~5s timeout, before finishing
.then(() => finishTransaction({ purchase, isConsumable: false }))
.finally(() => notify(purchase));
});
// Purchase
requestPurchase({
request: { apple: { sku: productId }, google: { skus: [productId] } },
type: productId === PRODUCT_IDS.LIFETIME ? 'in-app' : 'subs',
});
// Errors come from purchaseErrorListener; ErrorCode.UserCancelled => 'cancelled', not an error alert
- 結果の型:
'purchased' | 'pending' | 'cancelled' | 'failed'。エラーアラートを表示するのはfailedの場合のみです - 無料トライアル:
isEligibleForIntroOfferIOS(subscriptionGroupId)が true の場合のみ表示します。introductoryPricePaymentModeIOS === 'free-trial'と期間から日数を求めます - シミュレーターで購入をテストするには、Xcode スキームに
.storekit構成ファイルを追加します - RevenueCat の属性
$posthogUserIdは PostHog の distinct id に設定し、購入データを分析ユーザーに紐付けます
AdMob
- ファイル:
src/lib/ads.ts(プラットフォームごとのユニット ID)、src/lib/ads-consent.ts、src/context/ads-context.tsx、src/components/banner-ad.tsx - プレミアムユーザーおよび開発中は広告を無効にします(
EXPO_PUBLIC_ADS_DEV=1でオプトイン) - 開発中はテスト ID を使用する必要があります
オンボーディングとペイウォールのフロー(重要)
フロー:Onboarding → Permission screens (location?, notifications) → Paywall → Main App (tabs)
- 各ステップには、
OnboardingProvider内に永続化されるフラグがあります(hasCompletedOnboarding、hasShownNotificationPermission、hasShownPaywall、…とcompleteX()セッター) - ルートの
_layout.tsxが唯一のルーターガードです。エフェクトがフラグとuseSegments()を監視し、最初に完了していないステップへrouter.replaceします - 現在のセグメントが期待されるステップと一致するまでは、
Stackの代わりに読み込み画面を表示します(タブが一瞬表示されるのを防ぎます) - 権限画面ではまずメリットを説明してから、システムのプロンプトを呼び出します。「今はしない」を選んだ場合もステップは完了します
- ペイウォールは
!hasShownPaywallによってオンボーディング中かどうかを判定します。閉じるとcompletePaywall()とrouter.replace('/')を呼び出します。後から(設定画面やロックされた機能から)開いた場合は、router.back()を呼び出すだけです。分析用に?from=settings|featureを渡します
useEffect(() => {
if (isLoading) return;
const step = segments[0];
if (!hasCompletedOnboarding && step !== 'onboarding') router.replace('/onboarding');
else if (hasCompletedOnboarding && !hasShownNotificationPermission && step !== 'notification-permission')
router.replace('/notification-permission');
else if (hasShownNotificationPermission && !hasShownPaywall && step !== 'paywall') router.replace('/paywall');
}, [isLoading, hasCompletedOnboarding, hasShownNotificationPermission, hasShownPaywall, segments]);
ペイウォール(必須)
プランにはすべてストアの価格を使用します(価格をハードコードしないでください)。
- 週額
- 年額 - デフォルトで選択され、強調表示されます。バッジには、ストア価格から算出した実際の割引額(
1 - yearly.price / (weekly.price * 52))と、対象となる場合は無料トライアルが表示されます - 買い切り(任意の非消耗型)
const [selectedPlan, setSelectedPlan] = useState<'weekly' | 'yearly' | 'lifetime'>('yearly');
const [plans, setPlans] = useState<Plan[]>([]); // { id, displayPrice, price, currency, trialDays }
useEffect(() => {
getPlans().then(setPlans); // open the page immediately, fill prices when they arrive
}, []);
App Store の審査(ガイドライン 3.1.2)要件として、ペイウォールには次の項目が必須です。
- 画面に表示される閉じるボタン(購入しなくてもオンボーディングを続行できること)
- 購入を復元ボタン
- すべてのプランの価格と期間、トライアル期間、およびトライアル終了後に更新される内容
- 自動更新に関する文言(「App Store の設定からいつでも解約できます」)
- タップ可能な プライバシーポリシー と 利用規約(EULA) のリンク(App Store の説明文にも同じリンクを掲載)
- 購入成功後:
useAds()のrefreshPremiumStatus()を呼び出し、その後フローを続行するか、前の画面に戻ります
設定画面の項目(必須)
設定画面には次の項目が必須です。
- 言語 - アプリの言語を変更
- テーマ - ライト/ダーク/システム
- 通知 - 通知の有効/無効を切り替え
- 広告を削除 - ペイウォールへ移動(すでにプレミアムの場合は非表示)
- 購入を復元
- プライバシー設定 -
showPrivacyOptions()(privacyOptionsRequiredが設定されている場合のみ、EU) - 分析の切り替え - PostHog のオプトイン/オプトアウト(分析が設定されている場合のみ)
- 評価/共有/プライバシーポリシー/利用規約/サポートのリンク
- オンボーディングをリセット - オンボーディングフローを再開(
__DEV__の場合のみ)
const { isPremium } = useAds();
// Remove Ads - navigates to paywall
const handleRemoveAds = () => {
router.push('/paywall');
};
// Reset onboarding
const handleResetOnboarding = async () => {
await setOnboardingCompleted(false);
router.replace('/onboarding');
};
// In settings list:
{!isPremium && (
<SettingsItem
title={t('settings.removeAds')}
icon="crown.fill"
onPress={handleRemoveAds}
/>
)}
<SettingsItem
title={t('settings.resetOnboarding')}
icon="arrow.counterclockwise"
onPress={handleResetOnboarding}
/>
ローカライズ
- ファイル:
src/lib/i18n.ts、文字列:src/locales/*.json - 初期言語:
Localization.getLocales()を順に確認し、最初に見つかった対応言語を使用します(ユーザーの第2言語が対応している場合もあります)。該当する言語がない場合はenにフォールバックします - ユーザーの選択を保存し、起動時に読み込みます
- iOS の権限に関する文言/アプリ名は、
app.jsonのexpo.locales({ "tr": "./locales/ios/tr.json" })とCFBundleAllowMixedLocalizations: trueを使って翻訳します
RTL(アラビア語、ヘブライ語、ペルシア語、ウルドゥー語)
言語の変更時に、実行時に方向を切り替えます。
I18nManager.allowRTL(rtl);
I18nManager.forceRTL(rtl);
if (Platform.OS === 'ios') Settings.set({ AppleLanguages: [lang] }); // native strings, share sheet, alerts
if (I18nManager.isRTL !== rtl) {
// Guard with a stored flag so a failed reload cannot loop
if (__DEV__) DevSettings.reload();
else await Updates.reloadAsync();
}
left/rightの代わりにstart/end(marginStart、paddingEnd)を使います- 方向を示すアイコン(山かっこ、矢印)は、
transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }]を使って反転します
分析(PostHog)
ファイル:src/lib/analytics.ts
const posthog = process.env.EXPO_PUBLIC_POSTHOG_KEY
? new PostHog(process.env.EXPO_PUBLIC_POSTHOG_KEY, {
host: process.env.EXPO_PUBLIC_POSTHOG_HOST ?? 'https://eu.i.posthog.com',
disabled: __DEV__ && process.env.EXPO_PUBLIC_POSTHOG_DEV !== '1',
captureAppLifecycleEvents: true,
errorTracking: { autocapture: { uncaughtExceptions: true, unhandledRejections: true, nativeCrashes: true } },
})
: null;
type Events = {
onboarding_completed: { skipped: boolean };
paywall_viewed: { from: 'onboarding' | 'settings' | 'feature' };
purchase_started: { plan: 'weekly' | 'yearly' | 'lifetime' };
purchase_completed: { plan: 'weekly' | 'yearly' | 'lifetime'; revenue?: number; currency?: string; trial?: boolean };
purchase_failed: { plan: 'weekly' | 'yearly' | 'lifetime'; reason: 'pending' | 'cancelled' | 'failed' };
restore_completed: { active: boolean };
// ...one entry per product event
};
export function track<E extends keyof Events>(event: E, properties: Events[E]) {
posthog?.capture(event, properties);
}
export const trackScreen = (name: string) => void posthog?.screen(name);
- 型付きイベントマップを使用し、自由形式のイベント名は使いません
- ルートレイアウトから画面をトラッキングします:
useEffect(() => trackScreen(pathname), [pathname]) Notifications.useLastNotificationResponse()で通知からの起動をトラッキングします(ID と日付で重複を排除)- 匿名データのみ:個人を特定できる情報、正確な座標、ユーザーが入力したテキストは記録しません
- リモート設定/A-B テストにはフィーチャーフラグ(
posthog.getFeatureFlag)を使い、常にデフォルト値を設定します - キーは
.env(EXPO_PUBLIC_POSTHOG_KEY、EXPO_PUBLIC_POSTHOG_HOST)に保存し、コミットしないでください
評価(expo-store-review)
ファイル:src/lib/rating.ts
- 価値を感じられるタイミング(タスク完了、目標達成)で依頼します。アプリ起動時やエラー発生後には依頼しません
- 条件:アプリを開いた日が3日以上あり、前回の依頼から60日以上経過していること
- 成功アニメーションが終わるように約1.2秒待ち、
StoreReview.isAvailableAsync()を確認してからStoreReview.requestReview()を呼び出します - iOS では年に最大3回まで表示され、表示されたかどうかは通知されません
- 設定の「評価する」からは、代わりに
https://apps.apple.com/app/id<ID>?action=write-reviewを開きます
通知
- ファイル:
src/lib/notifications.ts、src/hooks/use-notifications.ts - リモートプッシュ通知には
aps-environmentエンタイトルメントが必要です。ローカル通知には必要ありません - iOS で保留できるローカル通知は最大64件です。スライド式の期間を設定し、
AppState→activeのたび、およびバックグラウンドタスクから再同期します - カスタムサウンド:
expo-notificationsプラグインのsoundsオプションに.caf(iOS)/.wavファイルを列挙し、ファイル名で指定します。長さは30秒未満にしてください - 起動時ではなく、権限を求める前の画面で許可を求めます
バックグラウンドタスク
// src/lib/background-sync.ts - imported from app/_layout.tsx so defineTask runs at module load
TaskManager.defineTask(TASK_NAME, async () => {
try {
await syncScheduledContent();
return BackgroundTask.BackgroundTaskResult.Success;
} catch {
return BackgroundTask.BackgroundTaskResult.Failed;
}
});
await BackgroundTask.registerTaskAsync(TASK_NAME, { minimumInterval: 60 }); // minutes, a lower bound only
- パッケージ:
expo-background-task+expo-task-manager - 実行タイミングは iOS が決定するため、保証はありません。実際の更新はフォアグラウンドで行い、タスクは安全策として使います
- タスクから UI 専用 API(RTL の再読み込み、プロンプトなど)には決してアクセスしないでください
ウィジェットとライブアクティビティ(iOS、任意)
expo-widgets+@expo/ui/swift-ui:ウィジェットは'widget'ディレクティブを付けた JSX 関数です。拡張機能内で実行されるため、フック、インポート、外側で定義した定数は使えません。すべて props から渡しますapp.jsonのexpo-widgetsプラグインで設定し(groupIdentifier、ウィジェットのname、supportedFamilies)、コードでも同じ App Group を使用しますWidget.updateTimeline(entries)でデータをプッシュします(アプリが開かれていない間もウィジェットが動作するよう、数日分のエントリーを事前に計算します)- 拡張機能が読み取れるのは App Group コンテナ内のファイルだけです。画像をそこにコピーし(
Paths.appleSharedContainers[APP_GROUP])、画像を変更した場合はファイル名のバージョンを変更します - ライブアクティビティ(iOS 16.2+)を開始できるのは、アプリがフォアグラウンドにある間だけです。バックグラウンドタスクでできるのは、実行中のライブアクティビティの更新だけです
コーディング規約
- 関数コンポーネントを使用します
- TypeScript の厳格モードを使用します
- 文字列のハードコードを避けます
- ファイル名はケバブケース(
use-prayer-times.ts)、コンポーネント名は PascalCase にします - 純粋なロジックには
bun testを使った単体テストを用意します("test": "bun test"、@types/bun) lineHeightの代わりに padding を使用します- 必要に応じてメモ化を使用します
コンテキストプロバイダー
<ThemeProvider>
<OnboardingProvider>
<AdsProvider>
<Stack />
</AdsProvider>
</OnboardingProvider>
</ThemeProvider>
useColorScheme フック
ファイル:src/hooks/use-color-scheme.ts
import { useThemeContext } from '@/context/theme-context';
export function useColorScheme(): 'light' | 'dark' | 'unspecified' {
const { isDark } = useThemeContext();
return isDark ? 'dark' : 'light';
}
重要事項
- iOS の権限は
app.jsonで定義します - Android の権限は
app.jsonで定義します - SDK 55 以降は新アーキテクチャのみです。
newArchEnabledフラグは不要です experiments.typedRoutes/experiments.reactCompilerで型付きルートと React Compiler を有効にします- シークレットとキーは
.envのEXPO_PUBLIC_*(ビルドでは EAS 環境変数も)に設定します
App Store と Play Store に関する注意事項
- パーソナライズ広告を表示する前に、iOS の ATT 許可が必要です(UMP フォームの後)
- 購入の復元が正しく動作する必要があります
- ペイウォールはガイドライン 3.1.2 を満たす必要があります(ペイウォールのセクションを参照)。App Store の説明にも自動更新に関する文言と、プライバシーポリシーおよび利用規約(EULA)へのリンクを含める必要があります
- アプリで使用しない権限は要求しないでください(5.1.1)。すべての権限に目的を説明する文字列が必要です
ITSAppUsesNonExemptEncryption: falseを設定すると、輸出コンプライアンスに関する質問をスキップできます- ストアの掲載情報は
store.config.json(EAS Metadata)にあり、言語ごとにローカライズされています - ターゲット SDK を最新の状態に保つ必要があります
ストアのスクリーンショット
App Store / Google Play のスクリーンショットやストア用ビジュアルを求められた場合は、画像を手作業で作成せず、必ず /app-store-screenshot スキルを使用してください(Skill ツールで呼び出します)。
- シミュレーターから主要な画面ごとに、すべてのストア言語で生のスクリーンショットを撮影します(
xcrun simctl io booted screenshot <file>.png) - そのスクリーンショットを使って
/app-store-screenshotを実行します。アプリの UI をピクセル単位で正確に保ちながら、デバイスフレーム、背景、見出しを追加します - 出力は
store.config.jsonと同じ階層にあるstore/screenshots/に保存します
テストチェックリスト
- すべての言語で UI をテスト
- ダークモード/ライトモード
- 通知
- プレミアムフロー(シミュレーターでは StoreKit 設定ファイル、デバイスでは Sandbox アカウント)
- 無料トライアルの利用資格、Ask to Buy(保留中)、購入のキャンセル
- 購入の復元
- EU 同意フォーム(
EXPO_PUBLIC_ADS_DEBUG_EEA=1)と ATT プロンプト - RTL レイアウト
- オフライン対応
- 複数の画面サイズ
開発後
npx expo prebuild --clean
bun ios
bun android
注:
prebuild --cleanは ios フォルダーと android フォルダーを再作成します。ネイティブモジュールまたは app.json を変更した後に実行してください。