关于 zafer-skills
zafer-skills 是面向 Claude Code 的 Expo 与 React Native 应用开发技能,规定新应用的屏幕、导航、依赖项、广告同意流程及代码检查步骤。
主要功能
- 01Expo 应用创建流程
- 02规定必需屏幕与导航
- 03广告配置及同意流程
- 04依赖项与存储规范
- 05代码检查和构建步骤
适用场景
- 01创建带入门流程和付费墙的 Expo 应用
- 02配置 AdMob 同意与广告展示
- 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-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"
横幅广告实现(必需)
将横幅广告放在独立组件中,并在 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 项目后,必须:
- 如果使用
(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 文件夹
不得跳过这些步骤。
项目创建
用户要求创建应用时,必须:
- 首先询问 bundle ID(例如:“bundle 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 和 typed routes:
{
"expo": {
"experiments": { "typedRoutes": true, "reactCompiler": true },
"ios": {
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"CFBundleAllowMixedLocalizations": true
}
}
}
}
- 使用 bundle 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,欧盟主机)——事件、屏幕、错误跟踪、功能标志 - 评分:
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 Symbol | Material Icon |
|---|---|---|
| 主页 | 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非消耗型产品 - 高级版状态 = 有效订阅或终身购买。缓存上次的值(
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]);
付费墙(必需)
套餐价格均从商店获取(绝不硬编码):
- Weekly
- Yearly — 默认选中并突出显示;徽章显示根据商店价格计算的实际优惠幅度(
1 - yearly.price / (weekly.price * 52)),符合条件时还显示免费试用 - 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(),然后继续流程 / 返回
设置页面选项(必需)
设置页面必须包含:
- 语言 — 更改应用语言
- 主题 — 浅色 / 深色 / 跟随系统
- 通知 — 启用 / 停用通知
- 移除广告 — 跳转到付费墙(如果已是高级用户则隐藏)
- 恢复购买
- 隐私选项 —
showPrivacyOptions()(仅在privacyOptionsRequired时显示,适用于欧盟) - 分析开关 — 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(),选择第一个受支持的语言(也可能支持用户的第二语言);如果都不支持,则回退到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';
}
重要说明
- 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 表单之后)
- 必须确保购买恢复功能正常运行
- 付费墙必须符合 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 工具调用),而不是手动制作图片。
- 从模拟器获取原始截图(
xcrun simctl io booted screenshot <file>.png),每个关键屏幕截取一张,并覆盖所有商店语言 - 使用这些截图运行
/app-store-screenshot;它会添加设备边框、背景和标题,同时保持应用 UI 像素级精确 - 将输出文件放在
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 后运行此命令。