React Native 实操开发文档

技术栈: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 开发(三平台都能做)

  1. 安装 Android Studiodeveloper.android.com/studio

  2. 在 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)
  3. 设置环境变量

    macOS/Linux(加入 ~/.zshrc~/.bashrc):

    bash 复制代码
    export 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):

    bash 复制代码
    export ANDROID_HOME="/c/Users/你的用户名/AppData/Local/Android/Sdk"
    export PATH="$PATH:$ANDROID_HOME/emulator"
    export PATH="$PATH:$ANDROID_HOME/platform-tools"

    同时在 Windows 系统环境变量里也加 ANDROID_HOME

  4. 创建模拟器:Android Studio → Device Manager → Create Device → Pixel 8 → API 34

  5. 验证

    bash 复制代码
    adb --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(用 Animated API 或 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 里:

  1. 选中 Target → Signing & Capabilities → 选你的 Team
  2. Product → Archive
  3. Archive 完成后点 Distribute App
  4. 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.propertiespnpm expo prebuild 会生成):

properties 复制代码
sdk.dir=/Users/你的用户名/Library/Android/sdk

或设置 ANDROID_HOME 环境变量后重新 prebuild。

真机扫码进不去

  1. 手机和电脑必须在同一 WiFi
  2. 公司/学校网络可能隔离,换手机热点
  3. 检查防火墙是否拦截 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 的 renderItemReact.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. 参考资源

相关推荐
HYCS1 小时前
用pixijs实现fabricjs(三):对象继承链和自定义对象
前端·javascript·canvas
渐儿1 小时前
Electron 实操开发文档
前端
小则又沐风a1 小时前
深入了解进程概念 第二章
java·linux·服务器·前端
亲亲小宝宝鸭1 小时前
微前端方案探索:qiankun
前端·微服务
渐儿1 小时前
跨端框架实操开发文档:Electron / Tauri / React Native
前端
ZC跨境爬虫1 小时前
跟着 MDN 学 HTML day_60:(表单与按钮技能测试实战)
服务器·前端·javascript·数据库·ui·html
lihaozecq1 小时前
做 Agent SDK 必须支持的插件能力:8 个钩子搞定横切关注点
前端·agent·ai编程
秦歌6661 小时前
Agent Skills详解
服务器·前端·数据库
ljt27249606611 小时前
Vue笔记(四)--组件基础
前端·vue.js·笔记