技术栈:TypeScript + React Native + Expo
目标平台:iOS / Android(+ 桌面端补充说明)
深度:从装环境到打包出可分发的 APK / IPA
1. React Native 是什么
React Native 用 React 写 UI,但渲染的不是 HTML,而是原生组件(iOS 的 UIView / Android 的 View)。JS 运行时通过 Bridge(旧架构)或 JSI(新架构 / Hermes)与原生层通信。
objectivec
┌───────────────────────────────────────────┐
│ 你的 React Native App │
│ │
│ TypeScript + React │
│ ↓ JSI / Hermes │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ iOS 原生层 │ │ Android 原生层 │ │
│ │ UIView │ │ View / Activity │ │
│ │ UIButton │ │ Button │ │
│ └─────────────┘ └──────────────────┘ │
└───────────────────────────────────────────┘
优势 :真正的原生 UI 和性能,庞大生态(npm),iOS 和 Android 代码复用率约 90%。
劣势 :原生工具链复杂,环境搭建是三者里最重的;<div> 不存在,CSS 是子集,上手有一段适应期。
1.1 Expo vs 裸 RN
| Expo | 裸 React Native | |
|---|---|---|
| 环境复杂度 | 低(屏蔽大部分原生配置) | 高(直接改 Xcode / Gradle) |
| 原生模块 | Expo SDK + EAS 插件,大多数需求覆盖 | 完全自由 |
| 本地打包 | 可以(prebuild 后) | 是默认方式 |
| 云端打包 | EAS Build(推荐,有免费额度) | 需自建 CI |
| 适合场景 | 新项目、快速迭代 | 需要深度原生定制 |
本文用 Expo,新项目 2025 年没有理由不用。
2. 环境准备
2.1 所有平台通用
| 依赖 | 版本 | 安装 |
|---|---|---|
| Node.js | ≥ 20 LTS | nodejs.org 或 fnm/nvm |
| pnpm | ≥ 8 | npm install -g pnpm |
| Git | 任意 | git-scm.com |
| JDK | 17(必须是 17) | 推荐 Temurin |
JDK 17 验证:
bash
java -version
# openjdk version "17.0.x"
2.2 Android 开发(三平台都能做)
-
安装 Android Studio :developer.android.com/studio
-
在 SDK Manager 安装(Android Studio → Settings → Android SDK):
- SDK Platforms:勾选 Android 14 (API 34)
- SDK Tools:勾选 Android SDK Build-Tools 34、Android Emulator、Android SDK Platform-Tools、Intel x86 Emulator Accelerator (HAXM)
-
设置环境变量:
macOS/Linux(加入
~/.zshrc或~/.bashrc):bashexport ANDROID_HOME="$HOME/Library/Android/sdk" # macOS # export ANDROID_HOME="$HOME/Android/Sdk" # Linux export PATH="$PATH:$ANDROID_HOME/emulator" export PATH="$PATH:$ANDROID_HOME/platform-tools"Windows(Git Bash 加入
~/.bashrc):bashexport ANDROID_HOME="/c/Users/你的用户名/AppData/Local/Android/Sdk" export PATH="$PATH:$ANDROID_HOME/emulator" export PATH="$PATH:$ANDROID_HOME/platform-tools"同时在 Windows 系统环境变量里也加
ANDROID_HOME。 -
创建模拟器:Android Studio → Device Manager → Create Device → Pixel 8 → API 34
-
验证:
bashadb --version # Android Debug Bridge version 1.0.41
2.3 iOS 开发(仅 macOS)
bash
# 安装 Xcode(从 App Store,约 12 GB)
# 安装完后确认许可
sudo xcodebuild -license accept
# 安装 CocoaPods(如果要本地打包)
sudo gem install cocoapods
# 或
brew install cocoapods
验证:
bash
xcodebuild -version # Xcode 15.x
pod --version # 1.14.x
2.4 安装 Expo CLI
bash
pnpm add -g expo-cli eas-cli
3. 创建项目
bash
pnpm create expo-app my-rn-app
选择模板时选 Blank (TypeScript)。
bash
cd my-rn-app
pnpm install
3.1 目录结构
perl
my-rn-app/
├── app/ # 路由(expo-router 文件系统路由)
│ ├── _layout.tsx # 根布局(类似 _app.tsx)
│ ├── index.tsx # 首页(对应路径 /)
│ └── (tabs)/ # Tab 导航组
│ ├── index.tsx
│ └── explore.tsx
├── components/ # 复用组件
├── assets/ # 图片/字体等静态资源
├── app.json # Expo 配置(等价于 tauri.conf.json)
├── eas.json # EAS Build 配置
├── tsconfig.json
└── package.json
3.2 启动开发服务器
bash
pnpm start
# 或
pnpm expo start
启动后会出现二维码和选项:
arduino
› Press a │ open Android
› Press i │ open iOS simulator
› Press w │ open web
按 a 打开 Android 模拟器,按 i 打开 iOS 模拟器(仅 macOS),或用手机扫码通过 Expo Go 预览(手机要和电脑同 WiFi)。
4. 核心概念:和 Web 开发的差异
4.1 基础组件映射
| Web | React Native | 说明 |
|---|---|---|
<div> |
<View> |
容器元素 |
<span> / <p> |
<Text> |
所有文字必须在 Text 里 |
<img> |
<Image> |
图片 |
<input> |
<TextInput> |
输入框 |
<button> |
<Button> 或 <TouchableOpacity> |
按钮 |
<ul> + <li> |
<FlatList> |
列表(虚拟化,性能好) |
<a> |
<Link>(expo-router) |
路由导航 |
window.alert |
Alert.alert() |
系统弹窗 |
4.2 样式
没有 CSS 文件,用 StyleSheet.create:
tsx
import { View, Text, StyleSheet } from 'react-native'
export default function Card({ title }: { title: string }) {
return (
<View style={styles.card}>
<Text style={styles.title}>{title}</Text>
</View>
)
}
const styles = StyleSheet.create({
card: {
padding: 16,
backgroundColor: '#fff',
borderRadius: 8,
// 注意:不支持 margin: '0 auto',只有具体数值
marginHorizontal: 16,
marginTop: 12,
// 阴影(iOS)
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
// 阴影(Android)
elevation: 3,
},
title: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
})
不支持的 CSS 属性(常见踩坑):
display: grid(用flexbox替代,RN 默认就是 flex 布局)position: fixed(用position: absolute+ 顶层容器)transition/animation(用AnimatedAPI 或react-native-reanimated)overflow: scroll(用<ScrollView>)
4.3 路由(expo-router)
expo-router 是文件系统路由,和 Next.js App Router 类似:
bash
app/
├── index.tsx → /
├── about.tsx → /about
├── user/
│ ├── [id].tsx → /user/:id(动态路由)
│ └── index.tsx → /user
└── (tabs)/ → Tab 导航组(括号里的名字不出现在 URL)
├── _layout.tsx → Tab 配置
├── home.tsx
└── settings.tsx
导航:
tsx
import { Link, router } from 'expo-router'
// 声明式
<Link href="/about">关于</Link>
<Link href={`/user/${userId}`}>用户详情</Link>
// 命令式
router.push('/about')
router.replace('/login') // 替换,不保留历史
router.back() // 返回
5. 完整实战示例:本地 Note 应用
5.1 安装依赖
bash
pnpm expo install expo-file-system @react-native-async-storage/async-storage
始终用
expo install而不是pnpm add,它会安装和当前 Expo SDK 版本兼容的版本。
5.2 用 AsyncStorage 存简单键值数据
适合存配置、用户设置等小数据:
tsx
// app/index.tsx
import { useState, useEffect } from 'react'
import { View, Text, TextInput, Button, StyleSheet } from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
const NOTE_KEY = '@my_note'
export default function NoteScreen() {
const [text, setText] = useState('')
const [saved, setSaved] = useState(true)
useEffect(() => {
AsyncStorage.getItem(NOTE_KEY).then((val) => {
if (val !== null) setText(val)
})
}, [])
const handleSave = async () => {
await AsyncStorage.setItem(NOTE_KEY, text)
setSaved(true)
}
return (
<View style={styles.container}>
<Text style={styles.title}>My Notes {!saved && '●'}</Text>
<TextInput
style={styles.input}
multiline
value={text}
onChangeText={(t) => { setText(t); setSaved(false) }}
placeholder="开始写..."
/>
<Button title={saved ? 'Saved' : 'Save'} onPress={handleSave} disabled={saved} />
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20 },
title: { fontSize: 22, fontWeight: 'bold', marginBottom: 12 },
input: {
flex: 1,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
fontSize: 15,
textAlignVertical: 'top', // Android 多行从顶部开始
marginBottom: 12,
},
})
5.3 用 expo-file-system 操作文件
适合读写真实文件(比如导入导出 .txt、.json):
tsx
import * as FileSystem from 'expo-file-system'
import * as DocumentPicker from 'expo-document-picker'
import * as Sharing from 'expo-sharing'
// 写文件到 App 私有目录
const FILE_PATH = FileSystem.documentDirectory + 'note.txt'
async function saveToFile(content: string) {
await FileSystem.writeAsStringAsync(FILE_PATH, content)
console.log('Saved to:', FILE_PATH)
}
async function loadFromFile(): Promise<string> {
const info = await FileSystem.getInfoAsync(FILE_PATH)
if (!info.exists) return ''
return await FileSystem.readAsStringAsync(FILE_PATH)
}
// 选择文件导入
async function importFile() {
const result = await DocumentPicker.getDocumentAsync({
type: 'text/*',
copyToCacheDirectory: true,
})
if (!result.canceled && result.assets[0]) {
return await FileSystem.readAsStringAsync(result.assets[0].uri)
}
return null
}
// 分享 / 导出文件
async function exportFile(content: string) {
const tmpPath = FileSystem.cacheDirectory + 'export.txt'
await FileSystem.writeAsStringAsync(tmpPath, content)
await Sharing.shareAsync(tmpPath, { mimeType: 'text/plain' })
}
装这些插件:
bash
pnpm expo install expo-document-picker expo-sharing
6. 常用原生能力
6.1 摄像头
bash
pnpm expo install expo-camera
tsx
import { CameraView, useCameraPermissions } from 'expo-camera'
export default function CameraScreen() {
const [permission, requestPermission] = useCameraPermissions()
if (!permission?.granted) {
return (
<View>
<Text>需要摄像头权限</Text>
<Button title="授权" onPress={requestPermission} />
</View>
)
}
return (
<CameraView style={{ flex: 1 }} facing="back">
{/* 覆盖在摄像头上的 UI */}
</CameraView>
)
}
6.2 推送通知
bash
pnpm expo install expo-notifications
ts
import * as Notifications from 'expo-notifications'
// 请求权限
const { status } = await Notifications.requestPermissionsAsync()
// 发本地通知
await Notifications.scheduleNotificationAsync({
content: {
title: '提醒',
body: '你有一条新消息',
data: { screen: '/home' },
},
trigger: { seconds: 5 }, // 5 秒后触发
})
6.3 网络请求
RN 里直接用 fetch(标准 Web API,已内置):
ts
const resp = await fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' }),
})
const data = await resp.json()
6.4 安全存储(密码/Token)
bash
pnpm expo install expo-secure-store
ts
import * as SecureStore from 'expo-secure-store'
// 存储(加密,存在系统 Keychain/Keystore)
await SecureStore.setItemAsync('auth_token', token)
// 读取
const token = await SecureStore.getItemAsync('auth_token')
// 删除
await SecureStore.deleteItemAsync('auth_token')
6.5 系统分享
bash
pnpm expo install expo-sharing
ts
import * as Sharing from 'expo-sharing'
await Sharing.shareAsync(fileUri, {
mimeType: 'application/pdf',
dialogTitle: '分享文件',
})
7. 调试
7.1 Expo Go(开发预览,最快)
手机安装 Expo Go App,运行 pnpm expo start,扫码即可。支持热更新(修改代码后手机自动刷新)。
限制:只能用 Expo SDK 内置的原生模块,自定义原生代码需要 dev build。
7.2 Development Build(功能完整的调试版)
bash
# 本地打 dev build(需要完整原生环境)
pnpm expo run:android
pnpm expo run:ios
# 或用 EAS 云端打 dev build(推荐)
eas build --profile development --platform android
eas build --profile development --platform ios
安装到设备后,用同样 pnpm expo start 连接,体验和 Expo Go 一样但支持所有自定义原生模块。
7.3 DevTools
运行时按 j 打开 React DevTools,或设备上摇一摇 / 快速摇三次打开 Dev Menu:
- Reload:热重载
- Open Debugger:打开 Chrome DevTools(查看 JS 日志、断点调试)
- Performance Monitor:查看帧率/内存
8. 打包:Android
8.1 路线 A:EAS Build(推荐)
配置 EAS(首次):
bash
eas login
eas build:configure # 生成 eas.json
生成的 eas.json:
json
{
"cli": {
"version": ">= 12.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"android": {
"buildType": "apk"
},
"distribution": "internal"
},
"production": {
"android": {
"buildType": "app-bundle"
}
}
}
}
打包:
bash
# 出 APK(直接安装到手机,测试用)
eas build -p android --profile preview
# 出 AAB(上传 Google Play,正式发布)
eas build -p android --profile production
构建完会给你一个下载链接(云端构建,不需要在本机装 Android Studio)。
免费额度:每月 30 次构建,个人项目够用。
8.2 路线 B:本地打包
先生成原生工程:
bash
pnpm expo prebuild
# 会生成 android/ 和 ios/ 目录
生成签名 keystore(只做一次,丢了就悲剧,备份好!):
bash
keytool -genkeypair -v \
-keystore my-release-key.keystore \
-alias my-key-alias \
-keyalg RSA \
-keysize 2048 \
-validity 10000
# 按提示输入密码和信息
配置签名,编辑 android/app/build.gradle:
groovy
android {
...
signingConfigs {
release {
storeFile file('../../my-release-key.keystore')
storePassword System.getenv('KEYSTORE_PASSWORD')
keyAlias 'my-key-alias'
keyPassword System.getenv('KEY_PASSWORD')
}
}
buildTypes {
release {
...
signingConfig signingConfigs.release
}
}
}
打包:
bash
cd android
# 出 APK(可直接安装)
KEYSTORE_PASSWORD=你的密码 KEY_PASSWORD=你的密码 \
./gradlew assembleRelease
# 出 AAB(Google Play 格式)
KEYSTORE_PASSWORD=你的密码 KEY_PASSWORD=你的密码 \
./gradlew bundleRelease
产物:
- APK:
android/app/build/outputs/apk/release/app-release.apk - AAB:
android/app/build/outputs/bundle/release/app-release.aab
9. 打包:iOS
前提:必须在 macOS 上操作,必须有 Apple Developer 账号($99/年)。
9.1 路线 A:EAS Build
bash
eas build -p ios --profile preview # 出 .ipa(内部测试)
eas build -p ios --profile production # 出 .ipa(App Store)
EAS 会引导你配置 Apple 证书,第一次会让你登录 Apple ID 并自动创建/管理证书。
bash
# 提交到 App Store(需要在 App Store Connect 创建 App)
eas submit -p ios
9.2 路线 B:本地打包
bash
pnpm expo prebuild # 生成 ios/ 目录
cd ios && pod install && cd ..
open ios/MyRnApp.xcworkspace # 在 Xcode 里打开
在 Xcode 里:
- 选中 Target → Signing & Capabilities → 选你的 Team
- Product → Archive
- Archive 完成后点 Distribute App
- 选 App Store Connect → 按向导走完
10. app.json 配置参考
json
{
"expo": {
"name": "My App",
"slug": "my-rn-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "myapp",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.yourcompany.myapp",
"buildNumber": "1"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.yourcompany.myapp",
"versionCode": 1,
"permissions": [
"CAMERA",
"READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE"
]
},
"plugins": [
"expo-router",
"expo-camera",
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#ffffff"
}
]
]
}
}
11. 图标和启动屏
图标规格
| 用途 | 文件 | 尺寸 |
|---|---|---|
| App 图标 | assets/icon.png |
1024×1024 PNG |
| Android 自适应图标前景 | assets/adaptive-icon.png |
1024×1024 PNG(留安全区) |
| 启动屏 | assets/splash.png |
1284×2778 PNG(竖屏)或 2778×1284(横屏) |
| 通知图标(Android) | assets/notification-icon.png |
96×96 PNG,纯白+透明背景 |
Expo 会自动生成各平台所需的尺寸变体,你只需要提供高分辨率源文件。
修改图标后重新 prebuild 或让 EAS Build 处理:
bash
pnpm expo prebuild --clean # 重新生成原生工程
12. 常见问题排查
Metro bundler 端口 8081 被占
bash
pnpm expo start --port 8082
或找到占用进程:
bash
lsof -i :8081 # macOS/Linux
netstat -ano | findstr :8081 # Windows
Android 模拟器连不上 Metro
bash
adb reverse tcp:8081 tcp:8081
或在 Dev Menu 里手动输入电脑 IP:http://你的IP:8081。
iOS pod install 卡住
CocoaPods 要从 GitHub 拉 spec,国内慢:
bash
# 方法一:换 tuna 镜像
cd ios
bundle exec pod install --repo-update
或使用代理,或等待(30 分钟以内通常能完)。
升级 Expo SDK 后出现红屏
bash
pnpm expo install --fix # 修复依赖版本冲突
pnpm expo start --clear # 清缓存重启
如果 ios/ 已生成,还要:
bash
cd ios && pod install
Android 构建报 SDK 找不到
检查 android/local.properties(pnpm expo prebuild 会生成):
properties
sdk.dir=/Users/你的用户名/Library/Android/sdk
或设置 ANDROID_HOME 环境变量后重新 prebuild。
真机扫码进不去
- 手机和电脑必须在同一 WiFi
- 公司/学校网络可能隔离,换手机热点
- 检查防火墙是否拦截 8081 端口
打包后 app 闪退
在 eas.json 里 production profile 加:
json
"production": {
"env": {
"APP_ENV": "production"
}
}
用 eas build 的 --local 标志本地打包,看完整的 Gradle / Xcode 日志:
bash
eas build -p android --profile production --local
13. 桌面端:RN for Windows / macOS(补充)
这是微软维护的扩展,Expo 目前不直接支持。需要用裸 RN CLI。
Windows:
bash
npx @react-native-community/cli init MyApp
cd MyApp
npx react-native-windows-init --overwrite
npx react-native run-windows
macOS:
bash
npx @react-native-community/cli init MyApp
cd MyApp
npx react-native-macos-init
npx react-native run-macos
打包:
- Windows:Visual Studio 打开
windows/MyApp.sln→ Build → Publish → MSIX - macOS:Xcode 打开
macos/MyApp.xcworkspace→ Archive → Distribute
老实话:RN 桌面端生态比 Electron/Tauri 弱得多,组件库覆盖率低,遇到问题资料少。如果桌面是主要目标,直接用 Electron 或 Tauri。RN 桌面适合你已经有一个 RN 移动项目,想顺手出个桌面版时用。
14. OTA 热更新(EAS Update)
EAS Update 让你在不重新提交 App Store / Google Play 的情况下更新 JS 代码。原生代码(Kotlin/Swift/C++)变了仍需走商店审核。
14.1 配置 EAS Update
bash
pnpm expo install expo-updates
eas update:configure
app.json 会加上:
json
{
"expo": {
"updates": {
"url": "https://u.expo.dev/your-project-id",
"enabled": true,
"fallbackToCacheTimeout": 0,
"checkAutomatically": "ON_LOAD"
},
"runtimeVersion": {
"policy": "appVersion"
}
}
}
eas.json 里绑定 channel:
json
{
"build": {
"production": {
"channel": "production"
},
"preview": {
"channel": "preview"
}
}
}
14.2 发布更新
bash
# 发布到 production channel(对应 production 安装包的用户)
eas update --channel production --message "修复登录 bug"
# 发布到 preview(内测用户)
eas update --channel preview --message "新功能预览"
用户下次打开 App 时会自动下载新 JS,再次重启生效。
14.3 代码里手动控制更新
ts
import * as Updates from 'expo-updates'
async function checkAndApplyUpdate() {
if (!Updates.isEnabled) return
try {
const update = await Updates.checkForUpdateAsync()
if (update.isAvailable) {
await Updates.fetchUpdateAsync()
// 提示用户重启
await Updates.reloadAsync()
}
} catch (e) {
console.error('Update check failed:', e)
}
}
15. 状态管理
React Native 项目推荐 Zustand (轻量)或 Jotai(原子化),不推荐 Redux(过重)。
15.1 Zustand(推荐)
bash
pnpm add zustand
ts
// store/useAuthStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'
interface AuthState {
token: string | null
user: { id: string; name: string } | null
login: (token: string, user: AuthState['user']) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
login: (token, user) => set({ token, user }),
logout: () => set({ token: null, user: null }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage), // 持久化到 AsyncStorage
}
)
)
使用:
tsx
import { useAuthStore } from '@/store/useAuthStore'
export default function ProfileScreen() {
const { user, logout } = useAuthStore()
return (
<View>
<Text>{user?.name}</Text>
<Button title="退出" onPress={logout} />
</View>
)
}
15.2 React Query(服务端状态)
服务端数据(API 请求)用 React Query 管理,不要放进 Zustand:
bash
pnpm add @tanstack/react-query
tsx
// app/_layout.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 分钟内认为数据是新鲜的
retry: 2,
},
},
})
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack />
</QueryClientProvider>
)
}
tsx
// 在组件里
import { useQuery, useMutation } from '@tanstack/react-query'
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then((r) => r.json()),
})
if (isLoading) return <ActivityIndicator />
if (error) return <Text>加载失败</Text>
return (
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text>{item.name}</Text>}
/>
)
}
16. 性能优化
16.1 FlatList 调优(长列表必读)
FlatList 是 RN 最常用的性能敏感组件,踩坑最多:
tsx
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={renderItem}
// ─── 性能关键配置 ───
// 初始渲染数量(屏幕够显示的 2 倍即可,不要太大)
initialNumToRender={10}
// 预渲染的窗口高度倍数(默认 10,适当降低节省内存)
windowSize={5}
// 超出渲染窗口的 item 是否卸载(内存敏感时开启)
removeClippedSubviews={true}
// 防止重复渲染
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
// 拉刷新
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
// 无限加载
onEndReached={loadMore}
onEndReachedThreshold={0.3} // 距底部 30% 时触发
// 预估 item 高度(能启用更精确的滚动位置计算)
getItemLayout={(_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
/>
renderItem 必须用 React.memo 包裹:
tsx
const ItemCard = React.memo(({ item }: { item: Item }) => {
return (
<View>
<Text>{item.title}</Text>
</View>
)
})
// keyExtractor 保证 key 稳定
const renderItem = useCallback(
({ item }: { item: Item }) => <ItemCard item={item} />,
[]
)
16.2 图片优化
bash
pnpm expo install expo-image
expo-image 比内置 Image 性能更好(内置缓存、占位图、渐进加载):
tsx
import { Image } from 'expo-image'
<Image
source={{ uri: 'https://example.com/photo.jpg' }}
style={{ width: 200, height: 200 }}
placeholder={blurhash} // 加载前显示模糊占位
contentFit="cover"
transition={200} // 200ms 淡入
cachePolicy="memory-disk" // 内存+磁盘双级缓存
/>
16.3 动画(react-native-reanimated)
内置 Animated 在 JS 线程跑,遇到 UI 线程繁忙会掉帧。reanimated 在 UI 线程直接跑:
bash
pnpm expo install react-native-reanimated
tsx
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
} from 'react-native-reanimated'
export default function AnimatedCard() {
const scale = useSharedValue(1)
const opacity = useSharedValue(1)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}))
return (
<Animated.View style={[styles.card, animatedStyle]}>
<Button
title="Press"
onPress={() => {
scale.value = withSpring(1.1, {}, () => {
scale.value = withSpring(1)
})
}}
/>
</Animated.View>
)
}
16.4 避免重复渲染
tsx
// ❌ 每次父组件渲染,子组件也渲染(onPress 是新函数引用)
<Button onPress={() => handlePress(item.id)} />
// ✅ useCallback 稳定函数引用
const handlePress = useCallback((id: string) => {
// ...
}, []) // 依赖数组为空 = 函数永远是同一个引用
<Button onPress={() => handlePress(item.id)} />
17. 远程推送通知(生产级)
17.1 配置 FCM(Android)和 APNs(iOS)
bash
pnpm expo install expo-notifications expo-device
ts
// utils/notifications.ts
import * as Notifications from 'expo-notifications'
import * as Device from 'expo-device'
import { Platform } from 'react-native'
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
})
export async function registerForPushNotifications(): Promise<string | null> {
if (!Device.isDevice) {
console.warn('Push notifications only work on real devices')
return null
}
const { status: existing } = await Notifications.getPermissionsAsync()
let finalStatus = existing
if (existing !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync()
finalStatus = status
}
if (finalStatus !== 'granted') {
console.warn('Push notification permission denied')
return null
}
// Android 需要创建通知 channel
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: '默认',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
})
}
// 获取 Expo Push Token(用于通过 Expo 推送服务发送)
const token = await Notifications.getExpoPushTokenAsync({
projectId: 'your-expo-project-id', // app.json 里的 extra.eas.projectId
})
return token.data
}
17.2 处理通知点击跳转
tsx
// app/_layout.tsx
import { useEffect, useRef } from 'react'
import { router } from 'expo-router'
import * as Notifications from 'expo-notifications'
export default function RootLayout() {
const notificationListener = useRef<ReturnType<typeof Notifications.addNotificationReceivedListener>>()
const responseListener = useRef<ReturnType<typeof Notifications.addNotificationResponseReceivedListener>>()
useEffect(() => {
// App 在前台时收到通知
notificationListener.current = Notifications.addNotificationReceivedListener((notification) => {
console.log('Notification received:', notification)
})
// 用户点击通知
responseListener.current = Notifications.addNotificationResponseReceivedListener((response) => {
const screen = response.notification.request.content.data?.screen as string
if (screen) router.push(screen as never)
})
return () => {
notificationListener.current?.remove()
responseListener.current?.remove()
}
}, [])
return <Stack />
}
17.3 服务端发送推送
用 Expo Push API(最简单,不需要直接对接 FCM/APNs):
ts
// 你的后端 (Node.js)
const messages = [{
to: 'ExponentPushToken[xxx]',
sound: 'default',
title: '新消息',
body: '你有一条未读消息',
data: { screen: '/messages/123' },
}]
await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(messages),
})
18. 崩溃监控(Sentry)
bash
pnpm expo install @sentry/react-native
ts
// app/_layout.tsx 或 index.tsx 最顶层
import * as Sentry from '@sentry/react-native'
Sentry.init({
dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
debug: __DEV__,
enabled: !__DEV__,
tracesSampleRate: 0.2, // 20% 的请求采样性能数据
})
包裹根组件:
tsx
export default Sentry.wrap(function App() {
return <Stack />
})
主动上报:
ts
// 上报错误
Sentry.captureException(error)
// 上报消息
Sentry.captureMessage('Something unexpected', 'warning')
// 设置用户上下文(登录后调用)
Sentry.setUser({ id: user.id, email: user.email })
// 清除用户(登出后调用)
Sentry.setUser(null)
上传 Source Maps(让 Sentry 显示可读的堆栈):
bash
# eas.json 加
"production": {
"env": {
"SENTRY_AUTH_TOKEN": "your-sentry-token"
}
}
19. 环境变量管理
Expo 使用 EXPO_PUBLIC_ 前缀的变量暴露给客户端代码(类似 Vite 的 VITE_):
bash
# .env
EXPO_PUBLIC_API_URL=https://api.yourapp.com
EXPO_PUBLIC_SENTRY_DSN=https://xxx@sentry.io/yyy
# .env.local(本地覆盖,不提交 git)
EXPO_PUBLIC_API_URL=http://localhost:3000
在代码里:
ts
const apiUrl = process.env.EXPO_PUBLIC_API_URL
注意:这些变量会打包进 JS bundle,用户可以看到。不要放 secret。服务端 secret 放在后端,客户端只放公开配置。
20. CI/CD(GitHub Actions + EAS)
20.1 自动构建并发布
yaml
# .github/workflows/release.yml
name: Build and Release
on:
push:
branches:
- main # main 分支触发 preview 构建
tags:
- 'v*' # tag 触发 production 构建
jobs:
build-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Build Android preview
if: github.ref == 'refs/heads/main'
run: eas build -p android --profile preview --non-interactive
- name: Build Android production
if: startsWith(github.ref, 'refs/tags/v')
run: eas build -p android --profile production --non-interactive
build-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Build iOS production
if: startsWith(github.ref, 'refs/tags/v')
run: eas build -p ios --profile production --non-interactive
20.2 自动发布 OTA 更新
yaml
# .github/workflows/ota.yml
name: OTA Update
on:
push:
branches: [main]
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- uses: pnpm/action-setup@v4
with: { version: 9 }
- run: pnpm install --frozen-lockfile
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: eas update --channel production --message "${{ github.event.head_commit.message }}" --non-interactive
21. 自定义 Native Module(高级)
当 Expo SDK 不够用,需要写原生代码时。
21.1 Expo Modules API(推荐方式)
比手写 TurboModule 简单得多:
bash
npx create-expo-module my-native-module
生成模板后,核心文件:
kotlin
// android/src/main/java/expo/modules/mynativemodule/MyNativeModule.kt
package expo.modules.mynativemodule
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class MyNativeModule : Module() {
override fun definition() = ModuleDefinition {
Name("MyNativeModule")
// 同步函数
Function("getValue") {
"Hello from Kotlin"
}
// 异步函数
AsyncFunction("getValueAsync") { promise: Promise ->
promise.resolve("Async value from Kotlin")
}
// 事件
Events("onChange")
}
}
swift
// ios/MyNativeModule.swift
import ExpoModulesCore
public class MyNativeModule: Module {
public func definition() -> ModuleDefinition {
Name("MyNativeModule")
Function("getValue") {
return "Hello from Swift"
}
}
}
前端使用:
ts
import MyNativeModule from 'my-native-module'
const value = MyNativeModule.getValue()
const asyncValue = await MyNativeModule.getValueAsync()
22. 完整生产发布检查清单
代码质量
- TypeScript 零
any(或已有理由记录) - 所有异步操作都有 error handling
- FlatList 的
renderItem用React.memo包裹 - 没有忘记清理的事件监听器(useEffect return cleanup)
安全
- Token/密码用
expo-secure-store,不用AsyncStorage - API 请求有超时配置(避免永久挂起)
- 所有
EXPO_PUBLIC_*变量都是真正可公开的 - 敏感操作(删除账号、支付)有二次确认
发布前
- 版本号和
versionCode/buildNumber已递增(每次发版必须递增) - 所有权限声明有对应的使用说明文字(iOS 审核要求)
- 截图和 App Store 描述已更新
- 在真机上(不是模拟器)完整跑过核心流程
- Release 构建测试通过(dev 构建很多问题会被掩盖)
监控
- Sentry 已配置且能收到测试事件
- EAS Update channel 配置正确
- 推送通知 token 能正确注册并发送到后端
23. 参考资源
- React Native 官方文档:reactnative.dev
- Expo 官方文档:docs.expo.dev
- EAS Build:docs.expo.dev/build/intro...
- EAS Update:docs.expo.dev/eas-update/...
- expo-router:docs.expo.dev/router/intr...
- Expo Modules API:docs.expo.dev/modules/mod...
- Sentry RN:docs.sentry.io/platforms/r...
- React Native 组件库:
- React Native Paper(Material Design)
- NativeWind(Tailwind 语法)
- react-native-reanimated(高性能动画)