mcpField
Z
zafer-skills

zafer-skills

github.com/zaferayan开发115385 次安装3 周前更新
代码仓库

关于 zafer-skills

zafer-skills 是面向 Claude Code 的 Expo 与 React Native 应用开发技能,规定新应用的屏幕、导航、依赖项、广告同意流程及代码检查步骤。

主要功能

  1. 01Expo 应用创建流程
  2. 02规定必需屏幕与导航
  3. 03广告配置及同意流程
  4. 04依赖项与存储规范
  5. 05代码检查和构建步骤

适用场景

  1. 01创建带入门流程和付费墙的 Expo 应用
  2. 02配置 AdMob 同意与广告展示
  3. 03检查 React Native 项目的实现规范

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/zaferayan/skills --skill zafer-skills

安装后在 Claude Code 中输入/zafer-skills确认已加载。

附带文件README.md

详细介绍

Expo 移动应用开发指南

重要:这是一个 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-client
  • expo-haptics
  • expo-notifications
  • i18next + react-i18next + expo-localization
  • react-native-reanimated
  • expo-video + expo-audio
  • expo-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"
横幅广告实现(必需)

将横幅广告放在独立组件中,并在 Tab 布局中使其悬浮于标签栏上方:

// 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);为滚动内容提供足够的底部内边距
  • 对高级版用户以及在同意流程和 SDK 初始化完成前,useAds().isAdsEnabled 均为 false
广告同意:UMP + ATT(必需)

未经用户同意,不得初始化广告。顺序:Google UMP 表单(欧盟/英国 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 时,设置中必须显示“隐私选项”一行(欧盟规定)
  • 同意表单内容在 AdMob 控制台 → Privacy & messaging 中配置
土耳其语本地化(重要)

编写 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 项目后,必须:

  1. 如果使用 (tabs) 文件夹,请删除 src/app/index.tsx,以避免路由冲突:
rm src/app/index.tsx
  1. 检查并从以下文件中移除 lineHeight:
  • src/components/themed-text.tsx(默认包含 lineHeight——请移除)
  • 任何其他使用 lineHeight 的组件

搜索并移除所有 lineHeight:

grep -r "lineHeight" src/

改用 padding 或 margin。

完成代码后(始终运行)

完成编写或修改代码后,必须按顺序运行以下命令:

npx expo install --fix
npx expo prebuild --clean
  1. install --fix 用于修复依赖版本不匹配问题
  2. prebuild --clean 用于重新创建 ios 和 android 文件夹

不得跳过这些步骤。


项目创建

用户要求创建应用时,必须:

  1. 首先询问 bundle ID(例如:“bundle ID 是什么?示例:com.company.appname”)
  2. 使用以下命令在当前目录中创建项目:
bunx create-expo -t default@next app-name

当前基线为 Expo SDK 57 / React Native 0.86 / React 19.2(仅限新架构)。在 app.json 中启用 React Compiler 和 typed routes:

{
  "expo": {
    "experiments": { "typedRoutes": true, "reactCompiler": true },
    "ios": {
      "infoPlist": {
        "ITSAppUsesNonExemptEncryption": false,
        "CFBundleAllowMixedLocalizations": true
      }
    }
  }
}
  1. 使用 bundle ID 更新 app.json:
{
  "expo": {
    "ios": {
      "bundleIdentifier": "com.company.appname"
    },
    "android": {
      "package": "com.company.appname"
    }
  }
}
  1. 然后进入项目目录并开始实现所有必需的屏幕
  2. 不要询问项目路径——始终使用当前目录

技术栈

  • 框架: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,欧盟主机)——事件、屏幕、错误跟踪、功能标志
  • 评分:expo-store-review
  • 通知:expo-notifications
  • 动画:react-native-reanimated
  • 存储:通过 expo-sqlite polyfill 使用 localStorage

WARNING:请勿使用 AsyncStorage!请改用 expo-sqlite polyfill。

  • 用法示例
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
  • 如果 NativeTabs 在 Android 上表现异常,可仅在 Android 上回退到 expo-router Tabs(if (Platform.OS === 'ios') return <NativeTabs>...),并使用 IconSymbol 图标和触觉反馈标签按钮;iOS 始终使用 NativeTabs
常用图标
用途SF SymbolMaterial Icon
主页house.fillhome
探索compass.fillexplore
设置gearsettings
个人资料person.fillperson
搜索magnifyingglasssearch
收藏heart.fillfavorite
通知bell.fillnotifications

开发命令

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 非消耗型产品
  • 高级版状态 = 有效订阅或终身购买。缓存上次的值(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' 和周期推算天数
  • 将 .storekit 配置文件添加到 Xcode 方案,以便在模拟器上测试购买
  • 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 是唯一的路由守卫:一个 effect 会监视这些标记和 useSegments(),并通过 router.replace 跳转到第一个尚未完成的步骤
  • 在当前 segment 与预期步骤匹配之前,渲染加载视图而非 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. Weekly
  2. Yearly — 默认选中并突出显示;徽章显示根据商店价格计算的实际优惠幅度(1 - yearly.price / (weekly.price * 52)),符合条件时还显示免费试用
  3. Lifetime(可选的非消耗型项目)
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(),然后继续流程 / 返回
设置页面选项(必需)

设置页面必须包含:

  1. 语言 — 更改应用语言
  2. 主题 — 浅色 / 深色 / 跟随系统
  3. 通知 — 启用 / 停用通知
  4. 移除广告 — 跳转到付费墙(如果已是高级用户则隐藏)
  5. 恢复购买
  6. 隐私选项 — showPrivacyOptions()(仅在 privacyOptionsRequired 时显示,适用于欧盟)
  7. 分析开关 — PostHog 启用 / 停用(仅在配置了分析时显示)
  8. 评分 / 分享 / 隐私政策 / 使用条款 / 支持链接
  9. 重置新手引导 — 重新开始新手引导流程(仅限 __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(),选择第一个受支持的语言(也可能支持用户的第二语言);如果都不支持,则回退到 en
  • 持久化用户的选择,并在启动时加载
  • 使用 app.json 中的 expo.locales 翻译 iOS 权限文本 / 应用名称({ "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();
}
  • 使用 start/end(marginStart、paddingEnd)代替 left/right
  • 使用 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 + 日期去重)
  • 仅匿名:不收集 PII、精确坐标或用户输入的文本
  • 使用功能标志(posthog.getFeatureFlag)进行远程配置 / A-B 测试;始终提供默认值
  • 密钥存放在 .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 函数;它们在扩展中运行,因此不能使用 Hook、导入或外部常量——所有内容都来自 props
  • 在 app.json 的 expo-widgets 插件下配置(groupIdentifier、小组件 name、supportedFamilies),并在代码中使用相同的 App Group
  • 使用 Widget.updateTimeline(entries) 推送数据(预先计算多天的条目,让小组件在应用未打开时仍能正常运行)
  • 扩展只能读取 App Group 容器中的文件:将图片复制到那里(Paths.appleSharedContainers[APP_GROUP]),图片更改时为文件名添加版本号
  • 实时活动(iOS 16.2+)只能在应用处于前台时启动;后台任务只能更新正在运行的活动

编码规范

  • 使用函数组件
  • 使用严格的 TypeScript
  • 避免硬编码字符串
  • 文件名使用 kebab-case(use-prayer-times.ts),组件使用 PascalCase
  • 使用 bun test 为纯逻辑编写单元测试("test": "bun test"、@types/bun)
  • 使用 padding,而不是 lineHeight
  • 必要时使用记忆化

Context Providers

<ThemeProvider>
  <OnboardingProvider>
    <AdsProvider>
      <Stack />
    </AdsProvider>
  </OnboardingProvider>
</ThemeProvider>

useColorScheme Hook

文件: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';
}

重要说明

  1. iOS 权限在 app.json 中定义
  2. Android 权限在 app.json 中定义
  3. 从 SDK 55 起,新架构是唯一架构——无需设置 newArchEnabled 标志
  4. 通过 experiments.typedRoutes / experiments.reactCompiler 启用类型化路由和 React Compiler
  5. 密钥和密钥值放在 .env 的 EXPO_PUBLIC_* 中(构建时还需配置 EAS 环境变量)

App Store 与 Play Store 说明

  • 投放个性化广告前需要获得 iOS ATT 权限(在 UMP 表单之后)
  • 必须确保购买恢复功能正常运行
  • 付费墙必须符合 Guideline 3.1.2(参见付费墙部分);App Store 描述还必须包含自动续订文案、隐私政策和条款(EULA)链接
  • 不要请求应用未使用的权限(5.1.1);每项权限都需要用途说明
  • 将 ITSAppUsesNonExemptEncryption: false 设为 false,以跳过出口合规问题
  • 商店页面配置位于 store.config.json(EAS Metadata),并按语言本地化
  • 目标 SDK 必须保持最新

商店截图

当用户要求提供 App Store / Google Play 截图或商店视觉素材时,始终使用 /app-store-screenshot 技能(通过 Skill 工具调用),而不是手动制作图片。

  1. 从模拟器获取原始截图(xcrun simctl io booted screenshot <file>.png),每个关键屏幕截取一张,并覆盖所有商店语言
  2. 使用这些截图运行 /app-store-screenshot;它会添加设备边框、背景和标题,同时保持应用 UI 像素级精确
  3. 将输出文件放在 store/screenshots/ 中,与 store.config.json 同目录

测试清单

  • 使用所有语言测试 UI
  • 深色 / 浅色模式
  • 通知
  • 高级版流程(模拟器上使用 StoreKit 配置文件,设备上使用 Sandbox 账户)
  • 免费试用资格、Ask to Buy(待处理)、已取消的购买
  • 恢复购买
  • 欧盟同意表单(EXPO_PUBLIC_ADS_DEBUG_EEA=1)和 ATT 提示
  • RTL 布局
  • 离线支持
  • 多种屏幕尺寸

开发完成后

npx expo prebuild --clean
bun ios
bun android

注意:prebuild --clean 会重新创建 ios 和 android 文件夹。修改原生模块或 app.json 后运行此命令。

评论

支持 Markdown