前端5月30日 19:40
Expo 框架适合什么类型的 React Native 项目?Expo 是建立在 React Native 之上的框架和工具链,它把创建项目、真机预览、常用原生能力、路由、云端构建、商店提交和 OTA 更新放到一套工作流里。它适合快速验证产品、跨平台业务 App、前端团队主导的移动端项目,以及希望少碰 Xcode、Gradle、证书和原生桥接的团队。
## 追问
### Expo 和 React Native 是什么关系?
React Native 是底层跨平台运行框架,负责把 React 组件渲染成原生 UI;Expo 是围绕 React Native 的 SDK、CLI、路由和云服务。
### Expo Go、Development Build、EAS 分别做什么?
Expo Go 适合快速预览通用能力;Development Build 是带有你自己原生配置的调试包;EAS 负责云端构建、提交商店和发布更新。
### 哪些项目特别适合用 Expo?
内容型 App、工具型 App、内部系统、轻量电商、社区产品、MVP 验证都很适合,因为它们更看重交付速度和跨平台一致性。
### 什么场景不适合只依赖 Expo Go?
涉及自定义原生模块、厂商 SDK、推送、深链、后台任务、权限文案或原生启动页时,应尽早切到 development build。
### Expo 的主要取舍是什么?
收益是上手快、常用能力齐全、构建发布链路顺;代价是要遵守 Expo 的版本节奏和配置方式。
## 写段命令
```bash
npx create-expo-app my-app
npx expo start
npx expo run:ios
```标签
Expo
Expo是一个面向Android、iOS和网页应用的开源框架。Expo汇集了移动和网络的精华,为构建和扩展应用程序提供了许多重要的功能。Expo npm包为React Native应用程序提供了一套令人难以置信的功能。

前端5月30日 19:40
Expo 常用 API 如何在项目里安全使用?Expo 常用 API 是 Expo SDK 对原生能力的封装,常见场景包括相机、定位、通知、文件系统、媒体库、传感器、安全存储和剪贴板。它的好处是用 JavaScript/TypeScript 调 iOS、Android 能力,少写原生桥接;安全使用的重点是权限、平台差异、开发构建方式和失败兜底是否处理完整。
## 追问
### Expo 常用 API 可以分成哪些类型?
设备能力类包括 Camera、Location、Notifications、Sensors;系统能力类包括 FileSystem、SecureStore、Clipboard;媒体类包括 ImagePicker、Audio、Video。
### 为什么权限明明申请了还是拿不到?
很多权限既要运行时申请,也要在 app.json 或原生配置里声明用途文案。iOS 用户拒绝后再次请求不一定弹窗,Android 不同版本也有差异。
### Expo API 和自定义原生模块怎么取舍?
常规能力优先用 Expo SDK;需要厂商支付、蓝牙深度能力、特殊后台服务时,要考虑 development build、config plugin 或原生模块。
### Expo Go 能不能验证所有 API?
不能。Expo Go 适合预览通用能力,但不能包含你的自定义原生配置,也不适合验证真实推送证书、深链、后台任务。
### 实战里权限请求应该放在哪里?
不要每次渲染都请求权限,也不要一进 App 就弹所有权限。更稳的是在用户触发相机、定位、上传图片等动作时再申请。
## 写段代码
```ts
const { status } = await Location.requestForegroundPermissionsAsync();
if (status === 'granted') await Location.getCurrentPositionAsync({});
```前端5月30日 19:40
Expo EAS Build、Submit 和 Update 分别解决什么问题?Expo EAS 是 Expo Application Services,主要把移动应用从“本地能跑”推进到“团队可构建、可提交、可更新”。Build 负责在云端产出 iOS/Android 安装包,Submit 负责把产物提交到 App Store 或 Google Play,Update 负责在不改原生二进制的前提下推送 JS 和资源更新。
## 追问
### EAS Build 解决的核心问题是什么?
它把 Xcode、Gradle、证书、签名、构建机环境这些麻烦事集中到云端处理。团队成员不必每个人都配置一套原生打包环境。
### EAS Submit 和 EAS Build 有什么区别?
Build 只负责生成 ipa、aab 或 apk,Submit 负责把产物交给应用商店。构建成功不代表一定能提交成功,商店账号、版本号和证书仍要匹配。
### EAS Update 能更新所有线上问题吗?
不能。它只能更新 JavaScript、图片、字体等资源,不能新增原生模块、修改权限、scheme 或 Android 原生配置。
### eas.json 的 profile 应该怎么设计?
development 用 developmentClient,preview 用于内测分发,production 放商店发布配置。不要让测试包和生产包共用同一条 update channel。
### 团队使用 EAS 最容易踩什么坑?
Expo Go 能跑不代表生产包没问题;OTA 更新也要记录 runtimeVersion、channel 和提交哈希,出问题才能回滚。
## 写段配置
```json
{"build":{"development":{"developmentClient":true},"preview":{"distribution":"internal"},"production":{"autoIncrement":true}}}
```前端5月30日 19:40
Expo Router 文件系统路由是如何工作的?Expo Router 是 Expo 官方推荐的路由方案,它把 `app` 目录里的文件映射成页面路径:`app/user/[id].tsx` 对应 `/user/:id`,`_layout.tsx` 负责给同一层页面套 Stack、Tabs、Modal 或全局 Provider。它底层仍使用 React Navigation,不是另起炉灶,而是把手写 screen 配置改成文件约定。
## 追问
### Expo Router 和 React Navigation 有什么关系?
Expo Router 底层依然依赖 React Navigation,导航能力并没有少。区别是通过文件结构生成路由,适合页面多、需要深链或同时支持 Web 的 Expo 项目。
### 文件系统路由是怎么工作的?
`app/index.tsx` 是首页,`app/settings.tsx` 是 `/settings`,`app/user/[id].tsx` 是动态路由。`(tabs)` 目录用于分组,不直接出现在 URL 里。
### 动态参数应该怎么读取?
常用 `useLocalSearchParams` 读取当前页面参数。参数通常是字符串或字符串数组,接口需要数字时要自己转换和校验。
### 深度链接和 Web URL 有哪些坑?
改 scheme、Universal Links、Android App Links 后,通常要重新打 Development Build 或生产包验证。Expo Go 能跳不代表商店包配置正确。
### 项目变大后怎么组织路由?
把认证、Tabs、弹窗、业务模块分到不同分组里,让每个 `_layout.tsx` 只管当前层级的导航。
## 写段代码
```tsx
const { id } = useLocalSearchParams<{ id: string }>();
router.push(`/user/${id}/orders`);
```前端5月30日 19:40
Expo Web 如何实现跨端开发并避开常见坑?Expo 支持 Web 的基础是 React Native for Web:`View`、`Text`、`Pressable` 等组件会映射到浏览器 DOM,再配合 Metro、Expo Router 和静态导出,把同一套业务代码跑到 iOS、Android 和 Web。它最大的价值是复用,而不是把移动端应用一键变成高质量网站。上线时,Web 端还要单独处理 SEO、响应式宽度、鼠标键盘交互、无障碍和浏览器 API 差异。
## 追问
### Expo Web 怎么启动和发布?
开发时用 `npx expo start --web`。发布静态站点常用 `npx expo export --platform web`,再把产物交给 Vercel、Netlify、Nginx 或对象存储。
### React Native for Web 做了什么?
它把 React Native 组件和样式模型翻译成 Web 可理解的结构。好处是组件复用,代价是并非所有原生能力都有 Web 等价物。
### 哪些代码需要平台区分?
相机、推送、文件系统、安全存储、分享、下载和路由跳转最容易分叉。简单差异用 `Platform.OS`,差异大时用 `.web.tsx`。
### 跨端样式最容易踩什么坑?
移动端固定宽度、全屏弹层、触摸优先交互放到桌面会很别扭。Web 端要补 hover、focus、键盘操作和宽屏断点。
### SEO 和 PWA 能自动做好吗?
不能。Expo Router 能让 URL 更自然,但标题、描述、结构化内容、静态导出策略、manifest 和缓存策略仍要自己设计。
## 写段代码
```tsx
const padding = Platform.select({ web: 24, default: 16 });
```前端5月30日 19:40
Expo 开发应该用哪些调试工具排查问题?Expo 调试工具可以按问题类型来选:启动失败先看 Expo CLI 和 Metro 日志,页面状态异常用 React Native DevTools,真机连接问题看设备日志和网络环境,性能问题再上 Profiler。现在不建议把老的“远程 JS 调试”当默认方案,因为 Hermes、New Architecture 和新版 React Native DevTools 已经改变了调试路径。
## 追问
### Expo CLI 在调试里主要做什么?
Expo CLI 负责启动 Metro、生成二维码、打开模拟器、切换连接方式和清缓存。常用 `npx expo start`,遇到 bundle 或资源缓存异常,先试 `npx expo start -c`。
### React Native DevTools 怎么打开?
项目跑起来后,在终端按 `j` 通常就能打开。它可以看 Console、Sources、Network、Components 和 Profiler,比旧工具更贴近 Hermes 调试体验。
### Expo Go 和 Development Build 调试有什么区别?
Expo Go 适合验证纯 JS 和官方内置模块,启动快,但不能覆盖所有自定义原生能力。加了 config plugin、推送、深链或第三方 SDK,就应用 Development Build。
### 真机连不上开发服务器怎么排查?
先确认手机和电脑在同一网络,防火墙、代理、VPN、公司 Wi-Fi 隔离都可能导致失败。LAN 不行可以临时用 tunnel,但不适合判断性能。
### 性能问题应该看哪个工具?
先用 Profiler 看组件是否重复渲染,再看 Network 是否重复请求或接口太慢。滚动、动画、输入卡顿一定要在真机或接近生产的 Development Build 里测。
## 写段代码
```tsx
if (__DEV__) { console.group('user'); console.log(user.id, user.role); console.groupEnd(); }
debugger;
```前端5月30日 19:40
Expo 动画该用 Animated、Reanimated 还是 Lottie?Expo 里做动画,先看动画的“控制权”在哪里:只是按钮淡入、卡片位移、骨架屏闪一下,用 React Native 自带 Animated 就够;动画要跟手势实时绑定、拖拽时不能掉帧,优先用 React Native Reanimated;如果设计师已经从 After Effects 导出了复杂插画,就用 Lottie。别把三者理解成替代关系,它们更像三种入口:Animated 轻、Reanimated 稳、Lottie 还原设计稿快。
## 追问
### Animated 和 Reanimated 到底差在哪?
Animated 适合透明度、缩放、平移这类常规过渡,API 学习成本低。Reanimated 的共享值和 worklet 能跑在 UI 线程,手势拖拽、底部抽屉、列表联动更稳,但写法和调试成本也更高。
### Lottie 适合替代手写动画吗?
不适合全部替代。Lottie 很适合加载、空状态、品牌动效,因为它能最大程度还原设计资源;但它不适合承载复杂业务状态。
### Expo 项目里需要额外配置什么?
依赖建议用 `npx expo install react-native-reanimated lottie-react-native`,版本会跟当前 SDK 对齐。Reanimated 还要确认 Babel 插件放在 plugins 最后。
### 动画卡顿时先排查哪里?
先看动画是不是被 JS 线程阻塞,比如大量 setState、日志、JSON 解析或列表重渲染。能用 transform 就别改布局尺寸。
### 实战选型有什么取舍?
简单动效别强行上 Reanimated,后续维护成本更高;设计师交付 Lottie JSON 时优先 Lottie;和手势强绑定再用 Reanimated。
## 写段代码
```tsx
const x = useSharedValue(0);
const style = useAnimatedStyle(() => ({ transform: [{ translateX: withSpring(x.value) }] }));
```前端5月30日 15:19
Expo 应用如何实现可访问性?有哪些最佳实践?Expo 的可访问性主要靠 React Native 的 accessibility* 属性实现:给可交互元素补 label、role、hint,用 state/value 表达状态和值,动态变化用 AccessibilityInfo 或 liveRegion 通知读屏,并在 iOS VoiceOver、Android TalkBack 上真机测试。优先保证按钮、图片、表单、弹窗、列表项能被读屏准确读出、能被键盘或辅助触控操作,别只靠颜色传达信息。
## 追问
### accessibilityLabel、Hint、Role 分别做什么?
label 说明“这是什么”,role 说明“它是什么控件”,hint 说明“触发后会发生什么”。
### 状态和值怎么告诉屏幕阅读器?
复选框、开关用 accessibilityState;滑块、进度条用 accessibilityValue,提供 min、max、now。
### 实际项目怎么验收?
iOS 开 VoiceOver、Android 开 TalkBack,按完整流程走一遍:登录、表单报错、列表操作、弹窗关闭、深色/高对比、减少动画都要测。
## 写段代码
```tsx
<Pressable accessibilityRole="button" accessibilityLabel="删除订单" accessibilityHint="删除后无法恢复">
<Text>删除</Text>
</Pressable>
```服务端5月28日 05:39
Expo Development Build和Prebuild分别是什么?还需要Eject吗?在Expo项目中,当你需要引入自定义原生代码或第三方原生SDK时,就会碰到一个核心问题:Expo Go无法加载自定义原生模块。这时你有两个现代方案——Development Build和Prebuild(CNG),而曾经常见的Eject已经在SDK 46中被正式废弃。
## Expo Go的局限性
Expo Go是一个预打包的沙箱应用,内置了标准Expo SDK的所有模块。它的优势是开箱即用,但也意味着你只能使用SDK包含的原生功能,无法添加任何自定义原生代码。当你需要使用微信支付、极光推送、或者自己编写的原生模块时,Expo Go就不够用了。
## Development Build:保留Expo体验的同时扩展原生能力
Development Build是Expo官方推荐的扩展方式。简单理解,它就是为你当前应用量身定制的"Expo Go"——包含了你项目所需的所有原生依赖,同时完整保留了Expo的开发体验。
### 核心优势
- 保留热更新(OTA)和EAS全家桶能力
- 支持任意第三方原生库和自定义原生模块
- 可通过Expo Modules API用Swift/Kotlin编写原生模块
- 升级SDK时原生层自动跟随,无需手动维护
### 创建Development Build
```bash
# 安装EAS CLI
npm install -g eas-cli
# 登录并配置
eas login
eas build:configure
# 构建开发版本
eas build --profile development --platform android
```
在`eas.json`中配置development profile:
```json
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
}
}
}
```
构建完成后,你会在EAS控制台拿到一个安装链接,安装到设备上即可像Expo Go一样通过扫码加载开发服务器。
### 添加自定义原生模块
使用Expo Modules API创建原生模块,比传统React Native桥接方式简洁得多:
**Kotlin(Android):**
```kotlin
package expo.modules.custom
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class CustomModule : Module() {
override fun definition() = ModuleDefinition {
Name("CustomModule")
AsyncFunction("customMethod") { promise: Promise ->
try {
promise.resolve("Success")
} catch (e: Exception) {
promise.reject(CodedException("ERR_CUSTOM", e.message, e))
}
}
}
}
```
**Swift(iOS):**
```swift
import ExpoModulesCore
public class CustomModule: Module {
public func definition() -> ModuleDefinition {
Name("CustomModule")
AsyncFunction("customMethod") { (promise: Promise) in
promise.resolve("Success")
}
}
}
```
**TypeScript调用:**
```typescript
import CustomModule from "./src/CustomModule";
CustomModule.customMethod()
.then(result => console.log(result))
.catch(error => console.error(error));
```
相比旧式React Native桥接(Java + Objective-C),Expo Modules API统一用Kotlin和Swift,代码量更少,类型更安全。
## Prebuild与CNG:取代Eject的新范式
### Eject为什么被废弃
`expo eject`在SDK 46(2022年8月)中已被移除。Eject的核心问题是它把原生目录变成一次性生成且需要手动维护的代码——一旦eject,你就得自己处理原生依赖升级、版本兼容、构建配置等麻烦事,而且无法回退。
### Prebuild + CNG的工作方式
Prebuild用持续原生生成(Continuous Native Generation,CNG)替代了Eject的一次性生成:
```bash
# 生成原生项目(可反复执行)
npx expo prebuild
# 清理后重新生成
npx expo prebuild --clean
```
CNG的关键区别在于:
- `android/`和`ios/`目录加入`.gitignore`,不纳入版本控制
- 原生代码每次根据`app.json`和已安装的npm包自动生成
- 修改原生配置通过Config Plugin而非手动编辑原生文件
- 升级时只需更新npm依赖再重新prebuild,无需手动合并
### Config Plugin:声明式修改原生配置
当你需要修改原生项目的Info.plist、AndroidManifest.xml等配置时,不再手动编辑,而是通过Config Plugin:
```typescript
// app.config.ts中使用config plugin
import type { ConfigPlugin } from "expo/config-plugins";
const withCustomConfig: ConfigPlugin = (config) => {
// 修改Android配置
config.android = {
...config.android,
// 自定义配置
};
return config;
};
export default withCustomConfig;
```
许多常用库已经提供了自己的Config Plugin,安装后自动配置原生层。对于没有官方Plugin的库,你也可以编写本地Plugin。
## Development Build vs Prebuild vs Eject对比
| 特性 | Development Build | Prebuild(CNG) | Eject(已废弃) |
|------|-------------------|---------------|--------------|
| 保留Expo开发体验 | 是 | 部分 | 否 |
| 支持OTA热更新 | 是 | 需配合EAS | 否 |
| 自定义原生模块 | 支持 | 支持 | 支持 |
| 原生目录维护 | 自动 | 自动(可重复生成) | 手动(一次性) |
| SDK升级难度 | 低 | 低 | 高 |
| 可回退性 | 高 | 高 | 低 |
实际开发中,Development Build和Prebuild并不互斥,而是配合使用:Prebuild负责生成原生项目,Development Build在此基础上构建可调试的开发版本。
## 什么时候该怎么做
**还在用Expo Go且功能够用**:继续用Expo Go即可,无需任何改动。
**需要第三方原生库但不需要自己写原生代码**:安装库后创建Development Build,大多数场景到此就够了。
**需要自定义原生模块**:用Expo Modules API编写模块,然后通过Development Build构建。原生模块用Swift/Kotlin,不再需要Objective-C。
**旧项目已经eject过**:参考Expo官方的Adopt Prebuild指南逐步迁移到CNG工作流。核心步骤是确保入口文件使用`registerRootComponent`,然后执行`npx expo prebuild --clean`重新生成原生目录,将手动修改迁移为Config Plugin。
**团队有深厚原生开发经验且不需要Expo服务**:这种情况下可以脱离Expo管理,直接使用React Native CLI。但这不等于eject,而是从一开始就选择bare工作流。
## 常见问题
**Development Build构建太慢怎么办?**
首次构建需要编译整个原生项目,确实较慢。后续增量构建会快很多。如果主要在iOS开发,可以考虑在本地用`npx expo run:ios`代替EAS Build,避免排队等待。
**Config Plugin能不能修改任意原生文件?**
理论上可以,但不推荐。Config Plugin适合处理配置层面的修改(权限、URL Scheme、字体等)。大规模原生代码改动应该用Expo Modules API写成独立模块。
**Prebuild会覆盖我手动改的原生文件吗?**
会。这也是CNG的设计意图——原生目录是可丢弃的。如果你有手动修改,必须迁移为Config Plugin,否则下次prebuild就会丢失。
总的来说,2026年的Expo生态中,Eject已成为历史名词。面对原生扩展需求,Development Build + Prebuild(CNG)是唯一的推荐路径,它既保留了Expo的开发效率,又获得了完整的原生能力。前端5月28日 05:25
Expo 应用如何实现国际化?i18next 配置与 RTL 处理**Expo 国际化用 i18next + expo-localization,不要选 react-native-localize——它在 Expo Go 里直接报错,必须 eject 才能用。i18next 管翻译引擎(资源加载、变量插值、复数、语言切换),expo-localization 读设备语言和时区,两个配合才是正解。**
核心流程:`getLocales()` 拿设备语言 → i18next 加载翻译资源 → `useTranslation()` 的 `t()` 渲染文本。切换语言调 `i18n.changeLanguage()`,AsyncStorage 持久化偏好。i18next 的 `init` 必须在根组件渲染前执行——入口文件顶部 import 配置即可,否则子组件拿不到翻译,这是新手最常见的坑。
## 追问
### i18next 和 expo-localization 分工是什么?能只用一个吗?
不能替代。expo-localization 是只读工具——告诉你设备语言是 `zh-Hans`、时区是 `Asia/Shanghai`,不碰翻译。i18next 才是翻译引擎:翻译资源管理、`{{变量}}` 插值、单复数(`one item` / `{{count}} items`)、命名空间拆分、运行时语言切换全归它管。一个读信息,一个做翻译,职责不重叠。
### react-native-localize 比 expo-localization 好在哪?为什么不推荐?
react-native-localize 能拿更多信息:日历类型、温度单位、24 小时制开关、度量衡。但代价是依赖原生模块——Expo Go 拒绝加载自定义原生代码,import 就报错,必须 `npx expo run:ios` 跑开发构建或 eject 到 bare workflow。还在 Expo Go 阶段的项目别碰它;bare workflow 项目两个随便选。
### Expo Router 里怎么做国际化?
根 layout 用 `useTranslation`,`t()` 放在 `options.tabBarLabel` 和 `options.title` 里,切换语言后组件重渲染、标签名自动变。关键约束:Expo Router 基于文件系统路由,路径名不能动态改,所以路由文件名保持英文(`app/settings.tsx`),展示文本走 `t()` 翻译。`useSegments()` 拿当前路由做翻译 key 映射也是常见做法。
### RTL 语言(阿拉伯语、希伯来语)怎么办?
`I18nManager.forceRTL(true)` 开启 RTL,但要重启才生效——调 `Updates.reloadAsync()` 即可。样式必须用逻辑属性:`marginStart`/`marginEnd` 替代 `marginLeft`/`marginRight`,`paddingStart`/`paddingEnd` 替代左右内边距,`textAlign` 用 `'start'` 不用 `'left'`。RTL 模式下布局自动翻转,零额外代码。忘了用逻辑属性的后果:文本翻了布局没翻,界面乱套。
### 翻译资源多了怎么组织?
5 种语言以内 JSON 文件放 `locales/en.json`,按功能分 key(`auth.login`、`settings.theme`)。语言多了用 i18next 命名空间拆分:`common`、`auth`、`settings` 各一个 namespace,懒加载减少首屏体积。改文案不发版的场景上 `i18next-http-backend` 从 CDN 拉翻译 JSON,AsyncStorage 缓存离线兜底。翻译量大需要协作时,Lokalise 或 Crowdin 配合 i18next 官方同步插件。开发阶段开 `saveMissing: true`,缺失 key 自动打 console 警告;上线后 `i18next-scanner` 扫代码提取 key,和翻译文件做 diff 排查遗漏。
## 写段代码
```typescript
// i18n.ts — 入口文件顶部 import
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { getLocales } from 'expo-localization';
i18n.use(initReactI18next).init({
resources: {
en: { translation: { welcome: 'Welcome', hello: 'Hello, {{name}}!' } },
zh: { translation: { welcome: '欢迎', hello: '你好,{{name}}!' } },
},
lng: getLocales()[0]?.languageCode ?? 'en',
fallbackLng: 'en',
saveMissing: __DEV__,
interpolation: { escapeValue: false },
});
// 组件
const { t, i18n } = useTranslation();
<Text>{t('hello', { name: '用户' })}</Text>
<Button onPress={() => {
i18n.changeLanguage('zh');
AsyncStorage.setItem('lang', 'zh');
}} title="中文" />
```前端5月28日 05:24
Expo OTA更新怎么工作?EAS Update怎么用?Expo的OTA(Over-the-Air)更新让开发者绕过应用商店审核流程,直接向用户推送JavaScript和资源文件的更新。这项能力来自EAS Update服务,配合expo-updates原生模块在客户端完成检查、下载和应用更新的全流程。
## OTA更新的底层机制
一次OTA更新涉及三个核心概念:**分支(Branch)**、**通道(Channel)**和**运行时版本(Runtime Version)**。
分支是更新在服务端的组织方式。每次执行`eas update`,Expo会将打包后的JavaScript bundle和资源文件上传到指定分支,分支上的最新更新即为活跃更新。通道则是客户端与分支之间的桥梁——客户端通过app.json中配置的通道名称连接到对应分支,从而获取更新。运行时版本是兼容性的守门员:只有运行时版本匹配的更新才会被下载,防止含原生依赖变更的更新在不兼容的二进制上运行导致崩溃。
更新下载分两个阶段进行。应用启动时,expo-updates先请求最新的更新清单(manifest),清单包含更新元数据和所需资源列表。接着只下载当前缓存中缺失的资源文件,已缓存的部分直接复用。SDK 55引入的bundle diffing机制进一步将更新体积缩小60%到80%——客户端只需下载新旧bundle之间的差异部分,而非整个bundle。
## EAS Update的完整配置流程
### 1. 安装依赖并初始化
```bash
npx expo install expo-updates
npm install -g eas-cli
eas login
eas init
```
`eas init`会在app.json中写入EAS项目ID,`expo-updates`则是客户端检查和下载更新所依赖的原生模块。
### 2. 配置app.json
```json
{
"expo": {
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/your-project-id",
"enabled": true,
"fallbackToCacheTimeout": 0,
"checkAutomatically": "ON_LOAD"
}
}
}
```
`checkAutomatically`控制更新检查时机,`ON_LOAD`表示应用启动时自动检查,`WIFI_ONLY`仅在WiFi下检查。`fallbackToCacheTimeout`设为0表示不等待更新下载完成,直接加载缓存版本。
### 3. 构建支持更新的二进制
OTA更新只在production或preview构建中生效,Expo Go不支持:
```bash
eas build --platform all --profile production
```
构建时expo-updates被编译进二进制,并嵌入了构建时的初始更新作为回退版本。
### 4. 发布更新
```bash
# 向production通道发布
eas update --branch production --message "修复登录按钮样式"
# 指定运行时版本
eas update --branch production --runtime-version 1.0.0
# 向preview通道发布用于测试
eas update --branch preview --message "测试新功能"
```
### 5. 查看和回滚更新
```bash
# 列出所有更新
eas update:list
# 查看指定分支的更新
eas update:list --branch production
# 回滚到上一版本
eas update:rollback --channel production
# 回滚到特定版本
eas update:rollback --channel production --target-message "上一个稳定版本"
```
## 运行时版本策略选择
三种策略各有适用场景:
- **appVersion**(推荐):运行时版本跟随app.json中的version字段。每次改了原生依赖就升version,简单可靠,适合大多数团队。
- **nativeVersion**:跟随原生构建号,比appVersion更细粒度,适合频繁发版但原生变更不多的场景。
- **自定义版本字符串**:完全手动控制,灵活性最高但需要团队约定版本命名规范。
关键原则:只要添加、删除或升级了原生依赖,就必须同步更新运行时版本,否则OTA更新可能导致原生模块找不到而崩溃。
## 客户端程序化控制更新
手动控制更新检查和应用的场景很常见,比如在设置页提供"检查更新"按钮,或在关键操作前确保代码是最新的:
```typescript
import * as Updates from 'expo-updates';
async function checkAndApplyUpdate() {
if (__DEV__) return; // 开发模式不支持OTA
try {
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
const result = await Updates.fetchUpdateAsync();
if (result.isNew) {
// 立即重载应用新版本
await Updates.reloadAsync();
}
}
} catch (error) {
// 更新失败不影响正常使用,静默处理或上报
console.warn('更新检查失败:', error.message);
}
}
```
监听更新事件可以在后台下载完成时通知用户:
```typescript
useEffect(() => {
if (__DEV__) return;
const subscription = Updates.addListener((event) => {
if (event.type === Updates.UpdateEventType.DOWNLOAD_FINISHED) {
// 提示用户下次启动将使用新版本
Alert.alert('更新已就绪', '重启应用以使用最新版本', [
{ text: '稍后', style: 'cancel' },
{ text: '立即重启', onPress: () => Updates.reloadAsync() },
]);
}
if (event.type === Updates.UpdateEventType.ERROR) {
console.warn('更新下载出错:', event.message);
}
});
return () => subscription.remove();
}, []);
```
## OTA更新的边界与限制
OTA能更新的是JavaScript业务逻辑、React组件树、样式、静态资源和导航结构。以下变更必须发新构建:新增或删除原生依赖、修改app.json中的原生字段(权限、URL Scheme等)、升级Expo SDK大版本、更换应用图标或启动屏。
苹果和谷歌对OTA更新的政策有明确要求:更新不得改变应用的核心功能定位,不得绕过应用商店审核引入付费功能或隐私敏感变更。实际操作中,大多数UI修复和小功能调整都符合政策。
## 通道与分支的运维实践
通道和分支的典型组合:
| 环境 | 分支 | 通道 | 用途 |
|------|------|------|------|
| 生产 | production | production | 面向所有用户 |
| 预发 | staging | staging | 内部测试验证 |
| 预览 | preview | preview | 功能预览 |
通道还支持按比例灰度发布。通过`eas channel:rollout`命令可以逐步将新更新推送给一定比例的用户,观察错误率后再全量发布:
```bash
# 先向20%用户推送
eas channel:rollout production --percent 20
# 确认无问题后扩大到50%
eas channel:rollout production --percent 50
# 全量发布
eas channel:rollout production --percent 100
```
## 错误恢复机制
expo-updates内置了自动错误恢复:如果更新后的应用在启动时连续崩溃,模块会自动回退到上一个已缓存的可用版本。这为线上事故提供了兜底,但仍建议在发布前通过preview通道充分测试。
手动回滚同样简单。EAS Update保留每个通道的完整更新历史,回滚只是将活跃指针指向上一个版本,客户端会在下次启动时下载并切换。
## 面试追问
**OTA更新和发新版本各自的适用场景?** JavaScript层面的bug修复、UI调整、文案改动适合OTA;新增原生模块、修改权限声明、升级SDK大版本必须发新构建。判断依据是变更是否涉及原生代码。
**运行时版本不匹配会发生什么?** 客户端会忽略不匹配的更新,继续运行当前缓存版本。这保护了应用不会因缺少原生模块而崩溃,但也意味着如果忘记同步运行时版本,用户将收不到更新。
**如何保证OTA更新的安全性?** EAS Update默认通过HTTPS传输更新包,expo-updates在加载前会校验更新签名。自托管更新服务器时需确保同样启用HTTPS和签名验证。前端5月27日 20:12
Expo CLI和Expo Go有什么区别?它们如何协同工作?## 直接回答
Expo CLI 是命令行开发工具,负责创建项目、启动服务器、构建发布;Expo Go 是手机上的沙箱应用,负责扫码连接开发服务器并实时预览。两者不是替代关系,而是前后端协作:CLI 生成二维码和开发服务,Go 扫码加载并运行代码。
## Expo CLI 核心职责
CLI 是整个开发流程的控制中心:
- **项目初始化**:`npx create-expo-app` 一键创建项目,支持 TypeScript 模板
- **开发服务器**:`npx expo start` 启动本地服务,提供热重载和 QR 码
- **构建发布**:通过 EAS Build 生成 APK、IPA 或 OTA 更新包
- **依赖管理**:自动安装与当前 SDK 版本兼容的 Expo 包
CLI 本身不运行代码,它搭建环境、编译资源、推送更新到客户端。
## Expo Go 核心职责
Go 是预装了完整 Expo SDK 的沙箱 App:
- **实时预览**:扫描 QR 码即可在真机上看到代码效果
- **零构建开发**:开发阶段无需编译原生代码,修改即生效
- **跨设备测试**:多台手机同时连接同一开发服务器
关键限制:Go 只包含 SDK 预装模块,**无法运行自定义原生代码**。需要蓝牙、后台任务、自定义原生模块时,必须改用 Development Build。
## 协同工作流
1. CLI 创建项目并启动开发服务器 → 生成连接 URL 和 QR 码
2. Go 扫码连接服务器 → 加载 JavaScript Bundle 并执行
3. 代码修改触发热重载 → Go 实时刷新界面
4. 开发完成后,CLI 调用 EAS Build 构建生产包 → Go 不参与发布流程
## 何时从 Go 切换到 Development Build
- 项目需要自定义原生模块或第三方原生 SDK
- 需要推送通知、深度链接等 Go 不支持的能力
- 需要接近生产环境的运行时行为验证
Go 适合原型验证和学习阶段,项目进入正式开发后建议尽早切换到 Development Build。
## 追问方向
- Expo Go 和 Development Build 的运行时差异是什么?
- EAS Build 的托管构建和本地构建如何选择?
- Expo 的 OTA 更新机制(Updates API)如何工作?前端5月27日 18:05
Expo应用中如何管理权限?有哪些最佳实践?## Expo 权限管理机制
Expo 应用中,权限管理是调用相机、定位、通知等敏感能力的前提。从 Expo SDK 43 起,统一的 `expo-permissions` 包已被弃用,改为各模块自带的权限方法。理解这套机制并正确处理权限流转,是避免 App Store 审核被拒、提升用户体验的关键。
## 权限请求的基本流程
每个需要权限的 Expo 模块都提供了两个核心方法:
- `getPermissionsAsync()` —— 查询当前权限状态,不会弹窗
- `requestPermissionsAsync()` —— 向用户请求权限,会弹出系统授权对话框
返回值是一个包含以下字段的对象:
```typescript
{
status: 'granted' | 'denied' | 'undetermined' | 'limited',
granted: boolean, // status === 'granted' 的快捷判断
canAskAgain: boolean, // 用户拒绝后是否还能再次弹出
expires: 'never' | number
}
```
其中 `limited` 是 iOS 14+ 的"有限访问"状态,用户只授权了部分照片或联系人。
## 常用权限模块及用法
### 相机与麦克风权限
```typescript
import { Camera } from 'expo-camera';
// 请求相机权限
const { status, granted } = await Camera.requestCameraPermissionsAsync();
// 请求麦克风权限(视频录制场景)
const { status: audioStatus } = await Camera.requestMicrophonePermissionsAsync();
// 仅查询权限状态,不弹窗
const { status: currentStatus } = await Camera.getCameraPermissionsAsync();
```
### 位置权限
位置权限区分前台和后台,这是一个容易踩坑的点:
```typescript
import * as Location from 'expo-location';
// 前台定位
const { status } = await Location.requestForegroundPermissionsAsync();
// 后台定位(需要额外配置,审核也更严格)
const { status: bgStatus } = await Location.requestBackgroundPermissionsAsync();
// 获取当前位置
if (status === 'granted') {
const location = await Location.getCurrentPositionAsync({});
}
```
后台位置权限在 iOS 上需要在 Info.plist 中添加 `UIBackgroundModes`,且 Apple 审核时会要求你说明为什么前台定位不够用。
### 通知权限
```typescript
import * as Notifications from 'expo-notifications';
const { status } = await Notifications.requestPermissionsAsync();
// 配置通知的前台展示行为
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
```
### 媒体库权限
```typescript
import * as MediaLibrary from 'expo-media-library';
const { status } = await MediaLibrary.requestPermissionsAsync();
// Android 13+ 支持细粒度媒体权限
const { status: granularStatus } = await MediaLibrary.requestPermissionsAsync({
granularPermissions: true,
});
// 保存图片到相册
const asset = await MediaLibrary.createAssetAsync(localUri);
```
Android 13 引入了 `READ_MEDIA_IMAGES`、`READ_MEDIA_VIDEO` 等细粒度权限,替代了旧的 `READ_EXTERNAL_STORAGE`。通过 `granularPermissions` 选项可以让 Expo 自动处理这个差异。
### 联系人权限
```typescript
import * as Contacts from 'expo-contacts';
const { status } = await Contacts.requestPermissionsAsync();
if (status === 'granted') {
const { data } = await Contacts.getContactsAsync({
fields: [Contacts.Fields.PhoneNumbers],
});
}
```
### 日历权限
```typescript
import * as Calendar from 'expo-calendar';
const { status } = await Calendar.requestCalendarPermissionsAsync();
if (status === 'granted') {
const calendars = await Calendar.getCalendarsAsync();
const eventId = await Calendar.createEventAsync(calendarId, {
title: '会议',
startDate: new Date(),
endDate: new Date(Date.now() + 3600000),
});
}
```
## 平台配置
权限代码写对了还不够,还需要在配置文件中声明。这一步如果遗漏,iOS 上会直接崩溃,Android 上则权限永远无法授予。
### iOS 配置
在 `app.json`(或 `app.config.js`)中通过 `infoPlist` 声明权限用途描述,这是 App Store 审核的必填项:
```json
{
"expo": {
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "需要相机权限来扫描二维码",
"NSLocationWhenInUseUsageDescription": "需要位置权限来推荐附近门店",
"NSMicrophoneUsageDescription": "需要麦克风权限来录制语音消息"
}
}
}
}
```
描述文案应当具体说明用途,写"需要此权限以提供功能"这类泛泛的描述可能导致审核被拒。
### Android 配置
Android 权限通过 `permissions` 数组声明,同时支持 `blockedPermissions` 来排除被第三方库自动引入但不使用的权限:
```json
{
"expo": {
"android": {
"permissions": [
"CAMERA",
"ACCESS_FINE_LOCATION",
"RECORD_AUDIO"
],
"blockedPermissions": [
"android.permission.READ_EXTERNAL_STORAGE"
]
}
}
}
```
`blockedPermissions` 非常实用——某些 Expo 库会自动注入权限声明,如果你不使用相关功能,可以通过这个字段移除它们,避免 Play Store 因"权限与功能不匹配"而拒审。
### Config Plugins 处理库权限
某些 Expo 库通过 Config Plugin 自动注入权限配置,无需手动声明:
```json
{
"expo": {
"plugins": [
[
"expo-image-picker",
{
"photosPermission": "需要访问相册以选择图片",
"cameraPermission": "需要相机以拍摄照片"
}
]
]
}
}
```
使用 Config Plugin 时,权限描述写在插件配置中而非 `infoPlist` 里,两者不要重复声明。
## 权限请求的最佳实践
### 按需请求,不要提前索取
用户一打开 App 就弹出三四个权限请求,体验非常差。应该在用户真正需要使用某个功能时再请求对应权限:
```typescript
function CameraScreen() {
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
useEffect(() => {
(async () => {
const { status } = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === 'granted');
})();
}, []);
if (hasPermission === null) {
return <Text>正在检查权限...</Text>;
}
if (hasPermission === false) {
return <NoPermissionFallback />;
}
return <CameraView />;
}
```
### 区分"首次拒绝"和"永久拒绝"
这是权限处理中最容易被忽略的逻辑。用户首次拒绝后 `canAskAgain` 为 true,你还可以再次请求;但如果用户选择了"不再询问",`canAskAgain` 变为 false,此时只能引导用户去系统设置手动开启:
```typescript
import { Linking, Alert } from 'react-native';
async function requestPermissionWithRationale() {
const { status, canAskAgain } = await Camera.requestCameraPermissionsAsync();
if (status === 'granted') return true;
if (canAskAgain) {
// 用户点了"拒绝",但还可以再问
Alert.alert('需要相机权限', '扫码功能需要相机权限才能使用', [
{ text: '取消', style: 'cancel' },
{ text: '重新授权', onPress: () => requestPermissionWithRationale() },
]);
} else {
// 用户选了"不再询问",只能去设置页
Alert.alert('权限被拒绝', '请在系统设置中手动开启相机权限', [
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() },
]);
}
return false;
}
```
`Linking.openSettings()` 是 React Native 提供的 API,会直接跳转到当前应用的系统设置页,iOS 和 Android 都支持。
### 封装权限 Hook
在多个组件中复用权限逻辑时,封装成自定义 Hook 可以避免重复代码:
```typescript
import { useState, useEffect } from 'react';
type PermissionStatus = 'granted' | 'denied' | 'undetermined' | 'limited';
function usePermission(
getPermission: () => Promise<{ status: PermissionStatus; canAskAgain: boolean }>
) {
const [status, setStatus] = useState<PermissionStatus>('undetermined');
const [canAskAgain, setCanAskAgain] = useState(true);
useEffect(() => {
getPermission().then(({ status, canAskAgain }) => {
setStatus(status);
setCanAskAgain(canAskAgain);
});
}, [getPermission]);
return { status, canAskAgain };
}
// 使用
const { status: cameraStatus } = usePermission(
() => Camera.getCameraPermissionsAsync()
);
```
Expo 部分模块也内置了 `usePermissions` Hook,比如 `MediaLibrary.usePermissions()`,优先使用官方提供的。
## 平台差异与注意事项
### iOS
- 权限用途描述(Usage Description)为必填项,缺失会导致崩溃
- 用户可以在"设置"中随时修改权限状态,App 从后台返回前台时应重新检查
- 后台定位、后台音频等权限需要在 `UIBackgroundModes` 中额外声明
- 某些权限(如通讯录)在 iOS 14+ 支持 `limited` 状态,需要专门处理
### Android
- 运行时权限从 Android 6.0 开始,之前的版本在安装时自动授予
- Android 13+ 对媒体权限做了细分(`READ_MEDIA_IMAGES`、`READ_MEDIA_VIDEO`、`READ_MEDIA_AUDIO`)
- 可以多次弹出权限请求,但用户体验上应遵循"解释后请求"的原则
- 某些权限(如 `SYSTEM_ALERT_WINDOW`、`WRITE_SETTINGS`)需要跳转到特殊设置页,不能通过标准 API 请求
### 通用注意
- 权限声明的变更需要重新构建原生代码,不能通过 OTA 更新生效
- 开发阶段测试权限拒绝场景时,可能需要卸载重装 App 来重置权限状态
- Expo Go 中部分权限行为与独立构建不同,正式测试应使用 Development Build
## 常见问题排查
### 权限请求没有弹窗
可能的原因:配置文件中未声明对应权限(iOS 会崩溃,Android 静默失败),或用户已永久拒绝且 `canAskAgain` 为 false。检查 `app.json` 中的权限声明和 `canAskAgain` 状态。
### iOS 权限描述审核被拒
Apple 要求描述必须具体说明权限用途。不要写"用于提供更好的体验",而要写"用于拍摄头像照片"或"用于扫描二维码"。
### 后台定位权限审核被拒
Apple 对后台定位审核很严格。需要在提交时说明为什么前台定位不满足需求,同时在 App 中提供明显的定位使用指示(如蓝色状态栏)。
### Android 上出现未声明的权限
某些第三方库会在 Manifest 中自动合并权限。使用 `blockedPermissions` 来排除不需要的权限,或者在 `android/app/src/main/AndroidManifest.xml` 中用 `tools:node="remove"` 移除。
### 权限状态不一致
App 从后台返回时,用户可能已经在系统设置中修改了权限。应在 `AppState` 的 `change` 事件中重新检查权限状态:
```typescript
import { AppState } from 'react-native';
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState) => {
if (nextState === 'active') {
// 重新检查权限状态
Camera.getCameraPermissionsAsync().then(({ status }) => {
setHasPermission(status === 'granted');
});
}
});
return () => subscription.remove();
}, []);
```
## 安全设计原则
权限管理不仅是技术问题,也关系到用户信任和应用合规:
- **最小权限原则**:只请求功能所必需的权限,不要为了"将来可能用到"而提前索取
- **透明说明**:每次请求权限时,向用户解释为什么需要这个权限,特别是首次请求前
- **优雅降级**:权限被拒绝时提供替代方案,而不是让功能完全不可用
- **状态同步**:监听权限变化,及时更新 UI 状态,避免出现功能已不可用但界面未更新的情况
前端5月27日 18:04
如何优化Expo应用的性能?有哪些常见的性能问题?## 组件渲染优化
React Native 中最常见性能问题就是不必要渲染。通过 React.memo、useMemo 和 useCallback 三个核心 API 可以有效控制渲染范围。
```typescript
// React.memo:对 props 做浅比较,避免父组件更新时子组件跟随重渲染
const ListItem = React.memo<{ item: Item }>(({ item }) => {
return <Text>{item.title}</Text>;
});
// useMemo:缓存计算结果,避免每次渲染重复执行昂贵运算
function SortedList({ items }: { items: Item[] }) {
const sorted = useMemo(
() => [...items].sort((a, b) => a.priority - b.priority),
[items]
);
return <FlatList data={sorted} renderItem={({ item }) => <ListItem item={item} />} />;
}
// useCallback:稳定函数引用,避免因函数重建导致子组件重渲染
function Parent() {
const [count, setCount] = useState(0);
const handlePress = useCallback(() => {
setCount((c) => c + 1);
}, []);
return <Child onPress={handlePress} />;
}
```
需要注意:memo 和 useMemo 不是越多越好。对于 props 简单或渲染成本低的组件,浅比较本身的开销可能反而更高。建议先用 React DevTools Profiler 定位瓶颈,再针对性优化。
## 列表渲染优化
### FlashList 替代 FlatList
FlatList 是 React Native 内置的虚拟化列表组件,但在长列表场景下性能不够理想。Shopify 开源的 FlashList 提供了约 10 倍的列表渲染性能提升,已成为 2026 年的推荐选择。
```typescript
import { FlashList } from '@shopify/flash-list';
<FlashList
data={items}
renderItem={({ item }) => <ListItem item={item} />}
estimatedItemSize={64} // 必填:提供预估行高,用于滚动条计算
keyExtractor={(item) => item.id}
/>
```
### 如果仍使用 FlatList
某些场景下 FlatList 仍有其适用性,关键优化属性如下:
```typescript
<FlatList
data={items}
renderItem={({ item }) => <ListItem item={item} />}
keyExtractor={(item) => item.id}
removeClippedSubviews={true} // 移除屏幕外原生视图,降低内存
maxToRenderPerBatch={10} // 每批渲染数量,越小越不容易卡顿
windowSize={10} // 渲染窗口倍数,默认 21
initialNumToRender={10} // 首屏渲染数量
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
/>
```
`getItemLayout` 是最容易被忽略但效果最显著的优化项。当列表项高度固定时,提供该属性可以让 FlatList 跳过异步布局计算,直接定位滚动位置,滚动性能提升明显。
## 启用新架构(New Architecture)
新架构是 React Native 近年来最重要的架构升级,包含 Fabric(新渲染系统)、TurboModules(新原生模块系统)和 JSI(JavaScript Interface)三个核心组件。Expo SDK 50+ 已默认支持。
**性能提升数据:**
- 冷启动速度提升约 40%
- 渲染速度提升约 35%
- 内存占用降低约 25%
- JS 调用原生方法的延迟降低约 40 倍
截至 2026 年初,超过 83% 的 EAS Build 项目已使用新架构。
```typescript
// app.json 中启用新架构
{
"expo": {
"newArchEnabled": true
}
}
```
迁移前建议使用 `npx expo-doctor` 检查第三方库兼容性,逐步升级避免一次性大改造带来的风险。
## Hermes 引擎优化
Hermes 是 React Native 0.70+ 的默认 JavaScript 引擎,相比旧版 JSC 有显著性能优势:
- 启动速度提升约 40%(预编译字节码)
- 内存占用降低约 30%
- 包体积减小约 40%
```typescript
// app.json 确认 Hermes 已启用(0.70+ 默认开启)
{
"expo": {
"jsEngine": "hermes"
}
}
```
Expo SDK 55+ 集成了 Hermes V1,进一步改善了 GC 表现和调试体验。如果项目仍在使用旧版 SDK,升级到 SDK 55+ 是最低成本的启动优化方案。
## 图片优化
expo-image 是 Expo 官方推荐的高性能图片组件,内置内存和磁盘缓存、占位符、渐变过渡等功能,相比 React Native 内置 Image 组件优势明显。
```typescript
import { Image } from 'expo-image';
<Image
source={{ uri: 'https://example.com/photo.webp' }}
style={{ width: 200, height: 200 }}
cachePolicy="memory-disk" // 内存 + 磁盘二级缓存
contentFit="cover"
transition={200} // 淡入动画时长
placeholder={blurhash} // 可选:加载前显示模糊占位
/>
```
**关键优化点:**
| 策略 | 效果 | 实施难度 |
|------|------|---------|
| 使用 WebP 格式 | 带宽减少约 70% | 低 |
| 启用缓存策略 | 重复加载耗时接近 0 | 低 |
| 按需加载尺寸 | 避免加载 4K 图显示缩略图 | 低 |
| 懒加载列表图片 | 减少首屏请求数 | 中 |
## 网络请求优化
### 缓存与去重
使用 TanStack Query(原 React Query)可以统一管理请求缓存、去重和状态,减少约 80% 的冗余 API 调用。
```typescript
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // 5 分钟内视为新鲜数据
gcTime: 10 * 60 * 1000, // 10 分钟后清除缓存(原 cacheTime)
});
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return <Text>{data.name}</Text>;
}
```
### 其他网络优化手段
- **请求批处理**:将多个独立请求合并为一个批量接口,减少网络往返
- **响应压缩**:服务端启用 Gzip/Brotli,响应体积减少 60%–80%
- **断点续传**:大文件下载支持恢复,避免因网络波动从头开始
## 动画优化
React Native Reanimated 是高性能动画的标准方案,其 worklet 机制将动画计算从 JS 线程转移到 UI 线程,彻底消除动画卡顿。
```typescript
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withSpring,
} from 'react-native-reanimated';
function FadeInView({ children }: { children: React.ReactNode }) {
const opacity = useSharedValue(0);
const translateY = useSharedValue(20);
const animatedStyle = useAnimatedStyle(() => ({
opacity: withTiming(opacity.value, { duration: 500 }),
transform: [{ translateY: withSpring(translateY.value) }],
}));
useEffect(() => {
opacity.value = 1;
translateY.value = 0;
}, []);
return <Animated.View style={animatedStyle}>{children}</Animated.View>;
}
```
核心原则:凡是影响视觉流畅度的动画(拖拽、滑动、转场),都应该使用 Reanimated 的 worklet 跑在 UI 线程,而非通过 JS 线程的 `Animated` 驱动。
## 导航性能
使用 `@react-navigation/native-stack` 替代 JS 实现的 stack 导航器。native-stack 直接使用 iOS 的 `UINavigationController` 和 Android 的 `Fragment` 过渡动画,导航过程中完全不需要 JS 线程参与,页面切换延迟从数十毫秒降至接近原生水平。
```typescript
import { createNativeStackNavigator } from '@react-navigation/native-stack';
const Stack = createNativeStackNavigator();
function App() {
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Detail" component={DetailScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}
```
## 内存管理
内存泄漏是 React Native 应用性能劣化的常见原因,主要集中在三个场景:未清理的订阅、未清除的定时器、未取消的网络请求。
```typescript
// 取消网络请求
useEffect(() => {
const controller = new AbortController();
fetchUser(userId, { signal: controller.signal });
return () => controller.abort();
}, [userId]);
// 清理定时器
useEffect(() => {
const timer = setInterval(() => syncData(), 30000);
return () => clearInterval(timer);
}, []);
// 清理事件订阅
useEffect(() => {
const subscription = EventEmitter.addListener('onUpdate', handleUpdate);
return () => subscription.remove();
}, []);
```
对于需要延迟执行的繁重任务,使用 `InteractionManager` 等待交互完成后再执行:
```typescript
import { InteractionManager } from 'react-native';
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
// 用户交互完成后再执行耗时操作
processHeavyData();
});
return () => task.cancel();
}, []);
```
**内存检测工具:**
- 开发环境:Flipper + React DevTools
- iOS:Xcode Instruments(Allocations / Leaks)
- Android:Android Studio Profiler
- 生产环境:Sentry 性能监控
## Bundle 优化
减少应用包体积直接影响下载转化率和启动速度。
```typescript
// 移除生产环境 console 输出
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
env: {
production: {
plugins: ['transform-remove-console'],
},
},
};
};
```
**其他 Bundle 优化策略:**
- **Metro tree-shaking**:确保未使用的导出被移除(Expo SDK 50+ 默认启用)
- **Hermes 字节码预编译**:构建时将 JS 编译为字节码,跳过运行时解析
- **审查依赖体积**:使用 `npx react-native-bundle-visualizer` 分析各模块占比,移除或替换体积过大的包
- **按需加载原生模块**:Expo 的模块自动链接机制会引入所有已安装的原生模块,定期清理 `package.json` 中未使用的依赖
## 性能监控与分析工具
| 工具 | 用途 | 适用阶段 |
|------|------|---------|
| React DevTools Profiler | 分析组件渲染次数和耗时 | 开发 |
| Flipper | 网络监控、布局检查、内存分析 | 开发 |
| Expo DevTools (SDK 52+) | 实时性能检查、Bundle 分析、加载追踪 | 开发 |
| Hermes Sampling Profiler | JS 函数级耗时分析 | 开发 |
| Sentry | 生产环境性能监控和错误追踪 | 生产 |
| Firebase Performance | 生产环境启动时间、网络延迟监控 | 生产 |
```typescript
// Expo SDK 52+ 内置性能检查
// 在开发菜单中启用 "Performance Monitor" 即可查看:
// - FPS 和帧时间
// - JS 线程和 UI 线程的 CPU 占用
// - 内存使用趋势
```
## 常见性能问题与解决方案
### 列表滚动卡顿
- 使用 FlashList 替代 FlatList(首选方案)
- 如果必须用 FlatList,提供 `getItemLayout` 并启用 `removeClippedSubviews`
- 列表项组件用 `React.memo` 包裹,确保 `renderItem` 函数引用稳定
### 应用启动慢
- 确认已启用新架构和 Hermes 引擎
- 移除启动阶段不必要的同步初始化代码
- 使用 `InteractionManager.runAfterInteractions` 延迟非关键任务
- 生产环境移除 `console.log`(babel 插件)
### 图片加载慢
- 使用 expo-image 并开启 `cachePolicy="memory-disk"`
- 服务端提供 WebP 格式和多种尺寸
- 列表中的图片使用懒加载
### 内存泄漏
- useEffect 返回清理函数,取消网络请求、定时器和订阅
- 使用 `InteractionManager` 延迟非关键任务
- 定期使用 Xcode Instruments 或 Android Profiler 检查内存趋势
### 导航切换延迟
- 使用 `native-stack` 替代 JS stack 导航器
- 减少导航页面中的 `useEffect` 同步操作
- 页面组件使用 `React.memo` 避免路由变化时无关组件重渲染
## 性能优化参考指标
建立可量化的性能目标,避免凭感觉优化:
| 指标 | 目标值(中端 Android) | 目标值(iPhone 13+) |
|------|----------------------|---------------------|
| 冷启动时间 | < 2.0s | < 1.2s |
| 持续滚动 FPS | >= 58 | >= 59 |
| 交互响应延迟 | < 100ms | < 50ms |
| JS 堆内存 | < 180MB | < 150MB |
| 安装包体积 | < 30MB | < 30MB |
优化前先测量,优化后对比验证。没有量化数据的优化只是在猜测。服务端5月27日 16:46
Expo应用安全与数据保护有哪些最佳实践?Expo应用的安全性和数据保护是移动开发中不可回避的核心问题。从敏感数据存储到网络通信加密,从身份验证到代码防护,Expo提供了一套完整的安全工具链。本文围绕实际开发场景,逐一拆解Expo应用安全的关键环节和对应方案。
## 敏感数据的安全存储
移动应用中最常见的安全风险就是敏感数据明文存储。密码、Token、API密钥等信息一旦被提取,后果严重。Expo提供了`expo-secure-store`作为一线方案。
### SecureStore 基本用法
```typescript
import * as SecureStore from 'expo-secure-store';
// 保存敏感数据
async function saveToken(token: string) {
try {
await SecureStore.setItemAsync('userToken', token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED,
});
} catch (error) {
console.error('Failed to save token:', error);
}
}
// 读取敏感数据
async function getToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync('userToken');
} catch (error) {
console.error('Failed to get token:', error);
return null;
}
}
// 删除敏感数据
async function deleteToken() {
try {
await SecureStore.deleteItemAsync('userToken');
} catch (error) {
console.error('Failed to delete token:', error);
}
}
```
### SecureStore 的平台差异与边界
SecureStore底层依赖平台原生加密机制——iOS使用Keychain,Android使用Keystore。两者行为存在关键差异:
- **iOS**:卸载后重装同bundle ID的应用,Keychain数据仍可恢复
- **Android**:卸载应用时Keystore数据会被清除
另外,`requireAuthentication: true`选项可要求生物识别后才能访问数据,但如果用户更改了生物识别设置,已保护的数据将无法访问。因此,SecureStore不应作为不可替代数据的唯一存储位置,关键数据需要有服务端备份方案。
### 非敏感数据的存储选择
对于非敏感配置信息,`AsyncStorage`即可满足需求,不需要引入SecureStore的加密开销。判断标准很简单:如果数据泄露不会造成安全风险,就用AsyncStorage。
## API密钥与敏感配置的管理
API密钥硬编码在客户端代码中是最常见的安全漏洞之一,无论代码混淆多强,逆向工程都能提取出来。
### 环境变量方案
Expo支持通过`EXPO_PUBLIC_`前缀的环境变量在客户端访问配置值:
```typescript
const API_URL = process.env.EXPO_PUBLIC_API_URL;
const API_KEY = process.env.EXPO_PUBLIC_API_KEY;
```
但要注意:`EXPO_PUBLIC_`前缀的变量会打包进客户端bundle,本质上仍然是客户端可见的。它们适合存放不同环境的配置URL,而不是真正的密钥。
### EAS Secrets 方案
对于构建时需要的真正密钥(如签名密钥、第三方服务Secret Key),应使用EAS Secrets:
```bash
# 添加构建密钥
eas secret:create --name STRIPE_SECRET_KEY --value "sk_test_xxx"
# 按环境区分
eas secret:create --name GOOGLE_MAPS_API_KEY --value "AIzaSyxxx" --scope production
```
EAS Secrets仅在EAS Build服务器上解密使用,不会打包进客户端。
### 后端代理方案
对于运行时需要使用的密钥,最安全的做法是通过后端代理转发请求:
```typescript
const fetchSecureData = async () => {
const response = await fetch('https://api.example.com/data', {
headers: {
'Authorization': `Bearer ${await getToken()}`,
},
});
return response.json();
};
```
客户端只持有用户认证Token,所有需要API密钥的请求都由后端处理。
## 身份验证与授权
### JWT Token 管理
JWT是最常见的移动端认证方案。完整的Token管理需要处理保存、刷新、过期三个环节:
```typescript
import * as SecureStore from 'expo-secure-store';
async function saveAuthToken(token: string) {
await SecureStore.setItemAsync('authToken', token);
}
async function getAuthToken(): Promise<string | null> {
return await SecureStore.getItemAsync('authToken');
}
async function refreshToken(): Promise<string> {
const refreshToken = await SecureStore.getItemAsync('refreshToken');
const response = await fetch('https://api.example.com/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
const { token } = await response.json();
await saveAuthToken(token);
return token;
}
```
Token刷新策略建议:在请求拦截器中检测401响应,自动触发刷新并重试原始请求,避免用户感知到Token过期。
### OAuth 集成
Expo通过`expo-auth-session`提供了标准OAuth流程支持:
```typescript
import * as AuthSession from 'expo-auth-session';
const discovery = {
authorizationEndpoint: 'https://auth.example.com/authorize',
tokenEndpoint: 'https://auth.example.com/token',
};
async function authenticate() {
const request = new AuthSession.AuthRequest({
clientId: 'your-client-id',
scopes: ['openid', 'profile'],
redirectUri: AuthSession.makeRedirectUri({
scheme: 'myapp',
}),
});
const result = await request.promptAsync(discovery);
if (result.type === 'success') {
const { accessToken } = result.params;
await saveAuthToken(accessToken);
return accessToken;
}
}
```
注意`clientId`应通过环境变量注入,不要硬编码在代码中。
## 网络安全防护
### HTTPS 强制
所有网络请求必须走HTTPS。在iOS上可通过App Transport Security配置强制执行:
```json
{
"expo": {
"ios": {
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": false
}
}
}
}
}
```
`NSAllowsArbitraryLoads: false`会阻止所有HTTP请求,确保通信加密。
在代码层面也可以加一层防护:
```typescript
const secureFetch = async (url: string, options?: RequestInit) => {
if (!url.startsWith('https://')) {
throw new Error('Only HTTPS requests are allowed');
}
return fetch(url, options);
};
```
### 响应数据验证
不要信任服务端返回的任何数据结构,前端必须做类型校验:
```typescript
interface ApiResponse<T> {
data: T;
success: boolean;
message?: string;
}
async function fetchValidatedData<T>(url: string): Promise<T> {
const response = await fetch(url);
const data: ApiResponse<T> = await response.json();
if (!data.success) {
throw new Error(data.message || 'Request failed');
}
return data.data;
}
```
### 防御CSRF攻击
对于涉及状态变更的请求,使用CSRF Token进行验证:
```typescript
async function fetchWithCSRF(url: string, options?: RequestInit) {
const csrfToken = await SecureStore.getItemAsync('csrfToken');
return fetch(url, {
...options,
headers: {
...options?.headers,
'X-CSRF-Token': csrfToken || '',
},
});
}
```
## 输入验证与XSS防护
### 表单输入校验
用户输入是攻击的主要入口,必须在客户端和服务端同时验证:
```typescript
// 验证邮箱格式
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
// 验证密码强度:至少8字符,含大小写字母和数字
const validatePassword = (password: string): boolean => {
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/;
return passwordRegex.test(password);
};
// 验证手机号
const validatePhone = (phone: string): boolean => {
const phoneRegex = /^1[3-9]\d{9}$/;
return phoneRegex.test(phone);
};
```
### XSS 防护
React Native的`<Text>`组件默认不解析HTML,因此XSS风险主要出现在使用`WebView`渲染用户内容的场景。如果确实需要渲染用户输入的HTML,必须转义特殊字符:
```typescript
const escapeHtml = (unsafe: string): string => {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
};
```
## 应用安全配置
### app.json 安全配置
```json
{
"expo": {
"ios": {
"bundleIdentifier": "com.yourcompany.yourapp",
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": false
}
}
},
"android": {
"package": "com.yourcompany.yourapp",
"permissions": []
}
}
}
```
关键配置项:
- `NSAllowsArbitraryLoads: false`:iOS禁止HTTP请求
- `permissions: []`:Android最小权限原则,不申请不需要的权限
- `bundleIdentifier`/`package`:使用反向域名格式,避免与其它应用冲突
### 权限最小化
只在确实需要时才请求权限,并且向用户说明用途。Expo中可以在`app.json`声明所需权限,未声明的权限不会被打包。
## 日志与安全监控
### 错误追踪
生产环境必须接入错误监控服务(如Sentry),实时捕获异常:
```typescript
import * as Sentry from '@sentry/react-native';
Sentry.init({
dsn: 'your-sentry-dsn',
environment: __DEV__ ? 'development' : 'production',
});
try {
// 业务代码
} catch (error) {
Sentry.captureException(error);
}
```
### 安全事件记录
对关键安全事件(登录失败、权限变更、数据导出等)进行审计日志记录:
```typescript
const logSecurityEvent = async (event: string, details: any) => {
if (!__DEV__) {
await fetch('https://logs.example.com/security', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event, details, timestamp: Date.now() }),
});
}
};
```
## 常见安全威胁与对应方案
| 威胁类型 | 攻击原理 | 防御方案 |
|---------|---------|---------|
| 中间人攻击 | 拦截客户端与服务端之间的通信 | HTTPS + ATS强制加密 |
| 数据泄露 | 设备丢失或被root/越狱后数据被提取 | SecureStore加密存储 + 不持久化非必要数据 |
| 逆向工程 | 反编译APK/IPA提取代码逻辑 | EAS Build代码混淆 + 密钥后端托管 |
| 重放攻击 | 截获合法请求并重复发送 | 请求签名 + 时间戳 + nonce |
| SQL注入 | 通过输入字段拼接恶意SQL | 参数化查询 + ORM框架 |
| 凭证填充 | 利用泄露的账号密码批量尝试登录 | 限流 + 多因素认证 + 异常检测 |
## 安全审计清单
上线前对照以下清单逐项检查:
- 所有敏感数据通过SecureStore存储,未使用AsyncStorage或明文存储
- API密钥和Secret Key通过EAS Secrets或后端代理管理,未硬编码在客户端
- 所有网络请求使用HTTPS,iOS已配置ATS
- JWT Token有刷新机制和过期处理
- 用户输入在客户端和服务端均做了校验
- 权限声明遵循最小化原则,无多余权限
- 已接入错误监控服务,关键安全事件有审计日志
- Android权限和iOS权限声明已审查服务端5月27日 15:42
Expo应用中如何选择状态管理方案?Zustand、Redux Toolkit、Jotai实战对比Expo应用的状态管理选型直接影响项目可维护性和开发效率。目前社区主流方案有Zustand、Redux Toolkit、Jotai和Context API,它们各有适用场景。下面从实际项目出发,逐一分析各方案的用法、优劣势和集成方式。
## 方案概览与选型依据
| 方案 | 包大小(gzip) | 心智模型 | 是否需要Provider | 适合规模 |
|------|-------------|---------|----------------|---------|
| Context API | 0(内置) | 树形共享 | 是 | 小型 |
| Zustand | ~3KB | 集中式Store | 否 | 中小型 |
| Redux Toolkit | ~15KB | 集中式Store | 是 | 大型 |
| Jotai | ~4KB | 原子化 | 否 | 中型 |
选型的核心判断依据:项目有多少全局状态、团队规模、是否需要时间旅行调试、以及包大小是否敏感。
## Context API:小项目的零依赖方案
Context API适合主题切换、语言设置、用户登录信息等少量全局状态。不需要引入任何第三方库,但性能隐患在于:Context值变化时,所有消费该Context的组件都会重新渲染。
```typescript
import { createContext, useContext, useState, useMemo, useCallback } from 'react';
import { Text, View, Button } from 'react-native';
type UserState = {
name: string;
isLoggedIn: boolean;
};
type UserContextType = {
user: UserState;
login: (name: string) => void;
logout: () => void;
};
const UserContext = createContext<UserContextType | null>(null);
export function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<UserState>({ name: '', isLoggedIn: false });
const login = useCallback((name: string) => {
setUser({ name, isLoggedIn: true });
}, []);
const logout = useCallback(() => {
setUser({ name: '', isLoggedIn: false });
}, []);
const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
// 自定义hook封装,避免组件直接依赖Context
export function useUser() {
const ctx = useContext(UserContext);
if (!ctx) throw new Error('useUser must be used within UserProvider');
return ctx;
}
```
关键点:用`useMemo`和`useCallback`避免不必要的重渲染,用自定义hook封装Context访问并添加错误提示。当全局状态超过3-4个时,建议切换到Zustand。
## Zustand:Expo项目首选方案
Zustand是当前Expo社区推荐度最高的状态管理库。2025年React状态管理调查中,Zustand的"保留率"和"兴趣度"均排名第一。它体积小、API简洁、无需Provider包裹、原生支持React Native。
### 基础用法
```typescript
import { create } from 'zustand';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
totalPrice: () => number;
}
const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, item] };
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
totalPrice: () =>
get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}));
```
### 在组件中使用
```typescript
function CartScreen() {
// 用selector精确订阅,避免不必要的重渲染
const items = useCartStore((s) => s.items);
const addItem = useCartStore((s) => s.addItem);
const totalPrice = useCartStore((s) => s.totalPrice);
return (
<View>
{items.map((item) => (
<Text key={item.id}>{item.name} x{item.quantity}</Text>
))}
<Text>总计: {totalPrice()}</Text>
</View>
);
}
```
### 持久化状态
Expo应用中经常需要将用户偏好、登录状态等持久化到本地。Zustand提供了`persist`中间件,配合`expo-secure-store`使用:
```typescript
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import * as SecureStore from 'expo-secure-store';
// 封装SecureStore适配器
const secureStorage = {
getItem: async (name: string) => {
return await SecureStore.getItemAsync(name);
},
setItem: async (name: string, value: string) => {
await SecureStore.setItemAsync(name, value);
},
removeItem: async (name: string) => {
await SecureStore.deleteItemAsync(name);
},
};
interface SettingsStore {
theme: 'light' | 'dark';
locale: string;
setTheme: (theme: 'light' | 'dark') => void;
setLocale: (locale: string) => void;
}
const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: 'light',
locale: 'zh',
setTheme: (theme) => set({ theme }),
setLocale: (locale) => set({ locale }),
}),
{
name: 'app-settings',
storage: createJSONStorage(() => secureStorage),
}
)
);
```
### 调试技巧
Zustand可以通过`devtools`中间件连接Redux DevTools。在Expo开发构建中,有社区插件可以直接在Expo Go中调试Zustand store。
## Redux Toolkit:大型团队的结构化选择
Redux Toolkit适合10人以上团队、50+个store模块的大型应用。它的强项在于严格的代码规范和强大的调试工具,但样板代码多、学习成本高。
### Store配置与Slice
```typescript
import { createSlice, createAsyncThunk, configureStore } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';
// 异步action:处理API请求
export const fetchProducts = createAsyncThunk(
'products/fetch',
async (category: string) => {
const response = await fetch(`/api/products?category=${category}`);
return response.json();
}
);
const productsSlice = createSlice({
name: 'products',
initialState: {
items: [] as Product[],
loading: false,
error: string | null,
},
reducers: {
clearProducts: (state) => {
state.items = [];
},
},
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchProducts.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchProducts.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message ?? 'Unknown error';
});
},
});
// 在Expo应用根组件包裹Provider
const store = configureStore({
reducer: {
products: productsSlice.reducer,
},
});
function App() {
return (
<Provider store={store}>
<RootLayout />
</Provider>
);
}
```
Redux Toolkit的优势在于团队协作:严格的单向数据流和action日志让多人协作时状态变更可追溯。如果你的团队已经在用Redux且没有明显痛点,不必迁移。
## Jotai:细粒度原子化状态
Jotai的原子化模型适合组件间有复杂依赖关系的场景,比如表单构建器、数据看板等。每个atom独立存在,只有订阅了该atom的组件才会在其变化时重新渲染。
```typescript
import { atom, useAtom } from 'jotai';
// 基础atom
const filterAtom = atom('all');
const searchQueryAtom = atom('');
// 派生atom:依赖其他atom,自动缓存计算结果
const filteredItemsAtom = atom((get) => {
const filter = get(filterAtom);
const query = get(searchQueryAtom);
return allItems.filter((item) => {
const matchFilter = filter === 'all' || item.category === filter;
const matchQuery = item.name.toLowerCase().includes(query.toLowerCase());
return matchFilter && matchQuery;
});
});
// 可写派生atom:同时读写
const toggleFilterAtom = atom(
(get) => get(filterAtom),
(get, set, newFilter: string) => {
set(filterAtom, newFilter);
set(searchQueryAtom, ''); // 切换分类时清空搜索
}
);
function FilterBar() {
const [filter, setFilter] = useAtom(toggleFilterAtom);
const [query, setQuery] = useAtom(searchQueryAtom);
return (
<View>
<TextInput value={query} onChangeText={setQuery} placeholder="搜索..." />
<Picker selectedValue={filter} onValueChange={setFilter}>
<Picker.Item label="全部" value="all" />
<Picker.Item label="电子产品" value="electronics" />
</Picker>
</View>
);
}
function ItemList() {
const [items] = useAtom(filteredItemsAtom);
return (
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text>{item.name}</Text>}
/>
);
}
```
Jotai的派生atom机制让状态依赖关系清晰可读,但atom数量多时管理成本上升,适合状态依赖复杂但总量可控的场景。
## 服务端状态:别忘了TanStack Query
以上方案管理的是客户端状态。对于API请求、缓存、后台刷新等服务端状态,应该使用TanStack Query(React Query)。它和任何客户端状态管理库可以并存:
```typescript
import { useQuery } from '@tanstack/react-query';
function ProductList({ category }: { category: string }) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['products', category],
queryFn: () => fetchProducts(category),
staleTime: 5 * 60 * 1000, // 5分钟内不重新请求
});
if (isLoading) return <ActivityIndicator />;
if (error) return <Text>加载失败</Text>;
return (
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ProductCard product={item} />}
refreshing={false}
onRefresh={refetch}
/>
);
}
```
将服务端状态和客户端状态分离是Expo应用架构的重要原则:TanStack Query管API数据,Zustand或Jotai管UI状态。
## 实战选型建议
**1-5个页面的个人项目**:Context API足够,不用引入额外依赖。
**5-20个页面的中型项目**:Zustand。API简单,3KB体积对移动端友好,持久化中间件开箱即用。
**20+页面、多人协作的大型项目**:Redux Toolkit。结构化规范减少沟通成本,DevTools让问题排查效率翻倍。
**状态依赖复杂的表单/看板类应用**:Jotai。派生atom让计算逻辑内聚,避免props层层传递。
**有大量API交互的项目**:Zustand + TanStack Query组合。Zustand管UI和导航状态,TanStack Query管服务端缓存和同步。
无论选择哪个方案,注意三条原则:优先使用局部状态而非全局状态;用selector精确订阅,避免整棵状态树触发重渲染;移动端对包大小敏感,能不引入的依赖就不引入。服务端5月27日 15:35
Expo应用的测试策略有哪些?如何进行单元测试和端到端测试?Expo应用的测试是保障代码质量和团队协作效率的基石。实际项目中,测试策略的选择直接影响迭代速度和线上稳定性。本文从单元测试、组件测试、端到端测试三个层面,梳理 Expo 项目中经过实战验证的测试方案和踩坑经验。
## Jest 单元测试:从工具函数到 Hooks
Jest 是 Expo 官方推荐的测试框架,配合 jest-expo preset 可以开箱即用,无需手动配置复杂的 transform 规则。
**安装依赖:**
```bash
npx expo install jest-expo jest --dev
```
在 `package.json` 中添加脚本和配置:
```json
{
"scripts": {
"test": "jest",
"test:ci": "jest --coverage --ci"
},
"jest": {
"preset": "jest-expo"
}
}
```
**工具函数测试**是最直接的切入点,输入输出明确,不依赖任何 UI 渲染:
```typescript
// utils/format.test.ts
import { formatDate, calculateTotal } from '../format';
describe('formatDate', () => {
it('格式化有效日期', () => {
expect(formatDate(new Date('2024-01-15'))).toBe('2024-01-15');
});
it('空值返回空字符串', () => {
expect(formatDate(null)).toBe('');
});
it('非法输入抛出错误', () => {
expect(() => formatDate('invalid')).toThrow();
});
});
```
**自定义 Hooks 测试**需要 `@testing-library/react-hooks`,它在测试环境中模拟 React 的渲染周期:
```typescript
// hooks/useUser.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
import { useUser } from './useUser';
describe('useUser', () => {
it('加载用户数据', async () => {
const { result, waitFor } = renderHook(() => useUser('123'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.user.name).toBe('John');
});
it('网络错误时设置 error 状态', async () => {
jest.spyOn(global, 'fetch').mockRejectedValue(new Error('Network'));
const { result, waitFor } = renderHook(() => useUser('999'));
await waitFor(() => expect(result.current.error).toBeTruthy());
});
});
```
**踩坑提醒:** 如果项目使用了 `react-native-reanimated`、`expo-linear-gradient` 等原生模块,Jest 运行时会报模块找不到的错误。解决方式是在 `jest.config.js` 中统一 mock:
```javascript
module.exports = {
preset: 'jest-expo',
setupFilesAfterSetup: ['./jest.setup.js'],
};
```
```javascript
// jest.setup.js
jest.mock('react-native-reanimated', () =>
require('react-native-reanimated/mock')
);
jest.mock('expo-linear-gradient', () => {
const { View } = require('react-native');
return { LinearGradient: View };
});
```
## React Native Testing Library:面向用户行为的组件测试
组件测试的核心原则是「测试用户能看到什么、能做什么」,而不是测试组件内部状态和方法。`@testing-library/react-native` 正是基于这个理念设计的。
**安装:**
```bash
npx expo install @testing-library/react-native --dev
```
**交互测试示例——登录表单:**
```typescript
// components/LoginForm.test.tsx
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
it('输入合法数据后提交', async () => {
const onSubmit = jest.fn();
const { getByPlaceholderText, getByText } = render(
<LoginForm onSubmit={onSubmit} />
);
fireEvent.changeText(getByPlaceholderText('邮箱'), 'user@example.com');
fireEvent.changeText(getByPlaceholderText('密码'), 'password123');
fireEvent.press(getByText('登录'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
});
});
});
it('空邮箱时显示错误提示', () => {
const { getByText, queryByText } = render(<LoginForm onSubmit={jest.fn()} />);
fireEvent.press(getByText('登录'));
expect(getByText('请输入邮箱')).toBeTruthy();
expect(queryByText('请输入密码')).toBeNull();
});
});
```
**导航测试**是 Expo 项目中的高频场景。组件内如果用了 `useNavigation`,测试时需要包裹 `NavigationContainer`:
```typescript
import { NavigationContainer } from '@react-navigation/native';
const renderWithNavigation = (ui: React.ReactElement) => {
return render(<NavigationContainer>{ui}</NavigationContainer>);
};
```
**AsyncSelect / 搜索框测试**要注意 `waitFor` 的使用,避免断言在异步操作完成前执行:
```typescript
it('搜索时展示建议列表', async () => {
const { getByPlaceholderText, findAllByText } = render(<SearchSelect />);
fireEvent.changeText(getByPlaceholderText('搜索'), 'React');
const items = await findAllByText(/React/);
expect(items.length).toBeGreaterThan(0);
});
```
## 端到端测试:Maestro vs Detox
端到端测试模拟真实用户操作,验证从启动到完成某个流程的完整链路。Expo 项目中目前有两个主流选择。
### Maestro:轻量高效的 E2E 方案
Maestro 是近年来在 React Native 社区快速崛起的 E2E 工具,配置简单,YAML 驱动,适合快速上手。
**安装:**
```bash
curl -Ls "https://get.maestro.mobile.dev" | bash
```
**编写测试用例(YAML 格式):**
```yaml
# .maestro/login.yaml
appId: com.example.myapp
---
- launchApp
- assertVisible: "邮箱"
- inputText: "user@example.com"
id: "email-input"
- inputText: "password123"
id: "password-input"
- tapOn: "登录"
- assertVisible: "欢迎回来"
```
**运行:**
```bash
maestro test .maestro/login.yaml
```
Maestro 的优势在于不需要写原生构建配置,测试用例可读性强,非开发人员也能理解和维护。对于 Expo 项目,配合 `eas build` 生成的开发构建即可运行测试。
### Detox:灰盒测试的经典方案
Detox 由 Wix 团队开发,在 React Native 生态中使用广泛。它的「灰盒」机制能同步等待异步操作完成,减少 flaky test。
**安装和初始化:**
```bash
npm install --save-dev detox detox-cli
detox init -r jest
```
Detox 要求先生成原生代码,所以需要先执行 `npx expo prebuild`。
**配置文件 `.detoxrc.js`:**
```javascript
module.exports = {
testRunner: {
args: { '$0': 'jest', config: 'e2e/jest.config.js' },
},
apps: {
'ios.debug': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/MyApp.app',
build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
},
},
devices: {
simulator: { type: 'ios.simulator', device: { type: 'iPhone 15' } },
},
configurations: {
'ios.sim.debug': { device: 'simulator', app: 'ios.debug' },
},
};
```
**E2E 测试用例:**
```typescript
// e2e/login.e2e.ts
describe('登录流程', () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true });
});
it('合法凭据登录成功', async () => {
await element(by.id('email-input')).typeText('user@example.com');
await element(by.id('password-input')).typeText('password123');
await element(by.id('login-button')).tap();
await expect(element(by.id('welcome-screen'))).toBeVisible();
});
it('错误凭据显示提示', async () => {
await element(by.id('email-input')).typeText('wrong@example.com');
await element(by.id('password-input')).typeText('wrong');
await element(by.id('login-button')).tap();
await expect(element(by.text('账号或密码错误'))).toBeVisible();
});
});
```
**选型建议:** 新项目或团队 E2E 经验不多,优先选 Maestro,学习成本低、维护简单;已有 Detox 基础设施或需要细粒度同步控制的团队,继续用 Detox。
## 测试金字塔与覆盖率策略
测试不是越多越好,投入产出比最高的分布是:
- **单元测试占 70%**——覆盖工具函数、Hooks、状态管理逻辑,运行快、维护成本低
- **组件测试占 20%**——覆盖关键交互流程(表单提交、列表筛选、弹窗关闭等)
- **E2E 测试占 10%**——只覆盖核心业务链路(注册、登录、支付、下单),每个用例运行耗时是单元测试的 50-100 倍
**覆盖率配置:**
```json
{
"collectCoverage": true,
"coverageReporters": ["text", "lcov"],
"coverageThreshold": {
"global": {
"branches": 70,
"functions": 70,
"lines": 70,
"statements": 70
}
}
}
```
注意:覆盖率达到 70% 即可,追求 100% 会导致大量测试代码维护负担,反而拖慢迭代速度。
## CI/CD 集成
将测试接入 CI 是保证每次提交质量的关键一步。
**GitHub Actions 配置:**
```yaml
name: Test
on: [push, pull_request]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run test:ci
e2e-test:
runs-on: macos-latest
needs: unit-test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx expo prebuild
- run: npm run e2e:ios
```
单元测试和 E2E 测试分两个 Job,单元测试先跑,通过后才触发耗时的 E2E 测试。
## Mock 策略与常见陷阱
测试中的 Mock 是一把双刃剑,过度 Mock 会让测试失去意义。
**应该 Mock 的:**
- 网络请求(用 `jest.spyOn` 或 `msw`)
- 第三方 SDK 的初始化和调用
- `AsyncStorage`、`SecureStore` 等持久化存储
**不应该 Mock 的:**
- 被测组件自身的子组件(这属于内部实现细节)
- React 的 hooks(如 `useState`、`useEffect`)
**网络请求 Mock 示例:**
```typescript
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/user', (_, res, ctx) =>
res(ctx.json({ id: 1, name: 'John' }))
)
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```
**快照测试的取舍:** 快照测试适合稳定的 UI 组件(如 Button、Card 等基础组件),但对频繁变动的业务页面,快照测试几乎每次都会失败,维护成本高于收益。建议只在组件库中使用快照测试。
## 实战经验总结
1. **先测工具函数,再测组件,最后补 E2E**——这条路径学习曲线最平缓,产出最快
2. **E2E 测试只覆盖主流程**——登录、支付、核心操作各一条用例足够,不要试图用 E2E 覆盖所有边界情况
3. **测试代码也是代码**——保持测试文件的命名、结构和复用性,抽取公共的 renderWithProvider 工具函数
4. **CI 中跑测试,本地不强制**——开发时快速迭代,提交时由 CI 兜底,避免测试成为开发的阻碍
5. **关注测试失败的原因,而非数量**——一个经常 flaky 的 E2E 测试比没有测试更糟糕,遇到不稳定用例优先修复或删除服务端5月27日 15:25
Expo SDK 升级怎么做?版本管理流程与避坑要点Expo SDK 大约每四周发布一个新版本,每个版本绑定特定的 React Native 版本和原生依赖。升级做得好,项目稳定推进;升错了,可能卡在依赖冲突里半天出不来。这篇文章把版本管理的核心流程和踩坑经验讲清楚。
## Expo SDK 的版本号规则
Expo SDK 采用语义化版本号(Major.Minor.Patch),但和普通 npm 包不同,SDK 的主版本号是真正意义上的大版本——每个 Major 版本对应一组固定的 React Native 版本、原生编译工具链和支持的最低操作系统版本。
举几个实际例子:
- SDK 55 对应 React Native 0.83.1 + React 19.2.0,iOS 最低 15.1
- SDK 56 对应更新版本的 React Native,iOS 最低要求跳到 16.4,直接淘汰了 iPhone 6s、iPhone 7 和第一代 iPhone SE
这意味着升级 SDK 不只是改一个版本号,你需要确认项目支持的最低设备不会被新版本排除在外。
## 查看和管理当前 SDK 版本
最直接的方式是看 `package.json`:
```json
{
"dependencies": {
"expo": "~55.0.0"
}
}
```
`~55.0.0` 表示接受 Patch 级别的自动更新,但不会跨 Minor 版本。这个写法是 Expo 推荐的,能保证你拿到安全补丁而不会意外引入不兼容的变更。
用命令行查看当前安装的版本:
```bash
npx expo --version
```
## 升级 SDK 的完整流程
### 第一步:建分支,跑诊断
升级之前先创建一个专门的分支,方便出问题时直接回退。然后跑一遍诊断,了解当前项目的健康状况:
```bash
npx expo-doctor
```
这一步特别重要——`expo-doctor` 会列出所有版本不匹配的依赖、过期的配置和已废弃的 API。升级前解决这些问题,能避免升级后问题叠加。
### 第二步:安装新版本 SDK
```bash
npx expo install expo@latest
```
这里用 `npx expo install` 而不是 `npm install`,是因为 Expo 的安装命令会自动处理版本兼容性,确保安装的包版本和当前 SDK 匹配。手动改 `package.json` 容易引入版本冲突。
### 第三步:修复所有依赖
```bash
npx expo install --fix
```
这条命令会把所有 Expo 相关的依赖包升级到和新 SDK 兼容的版本。从 SDK 55 开始,Expo 统一了所有包的版本号——比如 SDK 55 下 `expo-camera` 的版本是 `^55.0.0`,不再各包各版本,管理起来清楚很多。
### 第四步:重新生成原生代码
如果你使用 Continuous Native Generation(CNG),直接删掉旧的 `android` 和 `ios` 目录,让 Expo 重新生成:
```bash
npx expo prebuild --clean
```
如果不用 CNG,有 `ios` 目录的话需要跑一下 pod install:
```bash
npx pod-install
```
然后分别在两个平台测试:
```bash
npx expo run:ios
npx expo run:android
```
## 升级中最容易踩的坑
### 不要跳版本升级
Expo 官方明确建议逐个版本升级。从 SDK 53 直接跳到 55 看似省事,但中间跨了两个 React Native 大版本,一旦出问题你根本分不清是哪个版本引入的。正确做法是 53→54,测试通过后再 54→55。
### 第三方库兼容性
升级后最常见的问题是第三方库还没适配新 SDK。特别是 SDK 55 强制启用了 New Architecture,很多老库如果不支持新架构就会直接崩溃。升级前先检查你依赖的关键库是否已经声明支持目标 SDK 版本。
### app.json 的废弃字段
SDK 55 把通知配置从 `app.json` 的 `notification` 字段移到了 `expo-notifications` 的 config plugin 里。如果你还在用旧的写法,升级后通知功能会失效。另外 `newArchEnabled` 这个配置项也被移除了——新架构默认开启,不需要手动声明。
### expo-av 被拆分
SDK 55 中 `expo-av` 从 Expo Go 里移除了,需要分别迁移到 `expo-video` 和 `expo-audio`。如果你的项目用了音视频播放,这是升级时必须处理的事项。
### Expo Go 的版本限制
Expo Go 只支持最新的 SDK 版本。旧版本在 iOS 上尤其严格——受平台限制,只有最新版本的 Expo Go 才能装到真机上。所以如果你的开发工作流依赖 Expo Go,就必须跟着最新 SDK 走。
生产应用建议使用 Development Build,EAS 服务对旧 SDK 版本的向后兼容通常能维持六个月左右,比 Expo Go 宽裕得多。
## 回退方案
升级后如果遇到解不了的问题,可以回退到之前的版本:
```bash
npx expo install expo@54.0.0
npx expo install --fix
npx expo prebuild --clean
```
回退时同样需要修复依赖和重新生成原生代码,流程和升级一样。所以前面说的建分支很重要——直接切回旧分支比手动回退靠谱得多。
## 实际项目中的版本管理建议
**定期升级,别攒大版本。** 每次只升一个版本,升完跑完测试再升下一个。攒了好几个版本再一起升,排查问题的成本会成倍增长。
**关注 Changelog。** 每个 SDK 版本的发布说明里列出了所有破坏性变更和弃用 API,升级前花十分钟看一遍能省掉后面几小时的调试时间。
**优先在开发环境验证。** 不要跳过双平台测试,iOS 和 Android 的原生层差异很大,一个平台跑通不代表另一个也没问题。
**记录升级日志。** 每次升级记录当前版本、目标版本、遇到的问题和解决方案,下次升级时有据可查。服务端5月27日 14:25
Expo应用怎么用EAS Build完成从构建到上架的全流程?## 从本地开发到用户手机,中间隔了几座山
写完一个Expo应用只是开始。要让用户真正用上它,你需要把JavaScript代码编译成原生二进制包,签名、提交应用商店、再走完审核流程——每一步都有可能卡住。Expo Application Services(EAS)就是Expo团队给出的答案:把构建、提交、更新这三件事分别交给EAS Build、EAS Submit、EAS Update来处理。
本文会从`eas.json`的每一行配置讲起,覆盖开发构建/预览构建/生产构建的区别、商店提交流程、OTA更新机制、环境变量管理、CI/CD集成,以及最常见的报错和排查思路。
## EAS Build的核心配置:eas.json
EAS的所有构建行为都由项目根目录下的`eas.json`控制。运行`eas build:configure`会自动生成一份基础配置,但实际项目通常需要自定义。
一个典型的`eas.json`长这样:
```json
{
"cli": {
"version": ">= 13.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development",
"env": {
"APP_ENV": "development"
}
},
"preview": {
"distribution": "internal",
"channel": "preview",
"env": {
"APP_ENV": "staging"
}
},
"production": {
"channel": "production",
"autoIncrement": true,
"env": {
"APP_ENV": "production"
}
}
},
"submit": {
"production": {}
}
}
```
几个关键字段的含义:
- **`cli.version`**:约束EAS CLI的最低版本,避免因CLI版本差异导致构建失败。
- **`cli.appVersionSource`**:设为`"remote"`时,版本号以EAS服务器记录为准,配合`autoIncrement`自动递增build number。
- **`autoIncrement`**:每次构建自动+1,防止因build number重复被应用商店拒绝。
- **`channel`**:绑定EAS Update的更新通道,决定这个构建能收到哪个通道的OTA推送。
- **`env`**:构建时注入的环境变量,注意这和运行时环境变量是两回事。
## 开发构建、预览构建、生产构建有什么区别
EAS Build的三种构建类型对应不同的使用场景,搞混了会浪费大量构建时间。
### 开发构建(Development Build)
```json
"development": {
"developmentClient": true,
"distribution": "internal"
}
```
`developmentClient: true`是关键标志——它会在包里内置Expo Dev Client,支持热重载和开发者菜单。这个构建只用于开发调试,体积比生产包大,性能也差。`distribution: "internal"`意味着包不经过商店,而是通过链接直接安装。
使用场景:开发者在真机上调试原生模块、测试推送通知等模拟器无法覆盖的功能。
### 预览构建(Preview Build)
```json
"preview": {
"distribution": "internal",
"channel": "preview"
}
```
没有`developmentClient`,所以是完整的 Release 模式运行,但分发方式仍然是内部的。QA团队和产品经理通常用这个构建来验收。它和生产的区别仅在于分发渠道——预览构建不提交商店,但运行时行为和生产一致。
### 生产构建(Production Build)
```json
"production": {
"channel": "production",
"autoIncrement": true
}
```
最终提交到App Store和Google Play的包。没有`developmentClient`,没有`distribution: "internal"`,走的是商店分发流程。
三者之间的核心差异:开发构建包含调试工具、体积大、运行慢;预览构建是生产代码但内部分发;生产构建就是上架的最终产物。构建时间上,生产构建因为要做代码混淆和优化,通常比开发构建多花2-5分钟。
## EAS Submit:怎么把构建提交到应用商店
构建完成后,下一步是提交商店。EAS Submit把这个过程简化成一行命令。
### iOS提交准备
1. 在[App Store Connect](https://appstoreconnect.apple.com)创建应用记录。
2. 生成App Store Connect API Key(角色选Admin或Developer),记下Issuer ID、Key ID和.p8文件。
3. 在EAS CLI中配置凭证:
```bash
eas credentials
```
4. 提交构建:
```bash
eas submit --platform ios --latest
```
`--latest`自动取最近一次成功构建。你也可以指定build ID:`eas submit --platform ios --id xxxx`。
### Android提交准备
1. 在[Google Play Console](https://play.google.com/console)创建应用。
2. 创建服务账号并下载JSON密钥文件。
3. 授予服务账号必要的权限(至少需要"发布到测试轨道"权限)。
4. 提交构建:
```bash
eas submit --platform android --latest
```
### 在eas.json中预配置提交参数
```json
"submit": {
"production": {
"ios": {
"ascAppId": "1234567890"
},
"android": {
"serviceAccountKeyPath": "./pc-api-key.json",
"track": "internal"
}
}
}
```
`ascAppId`是App Store Connect中的应用ID,填上后提交时不再需要手动输入。`track`控制发布轨道:`internal`(内部测试)、`alpha`(公开测试)、`beta`(公测)、`production`(正式发布)。
## EAS Update:如何做OTA更新
这是EAS最实用的功能之一。当你只改了JavaScript/TypeScript代码和资源文件,没有动原生依赖,就可以通过OTA直接把更新推到用户手机,跳过整个应用商店审核流程。
### 运行时版本策略
`app.json`中配置:
```json
"runtimeVersion": {
"policy": "appVersion"
}
```
这行配置的含义:每当`version`字段变化,runtime version跟着变。只有runtime version完全匹配时,OTA更新才会生效。这就保证了你改了原生代码后,旧版本的应用不会收到不兼容的JS更新。
另一种策略是`"fingerprint"`,它基于项目原生文件的内容生成指纹,精度更高但需要更频繁地构建新包。SDK 55及以后版本推荐使用fingerprint策略。
### 发布更新
```bash
eas update --channel production --message "修复登录页闪退问题"
```
`--channel`必须和构建时绑定的channel一致,否则用户收不到。`--message`是更新说明,方便回溯。
### 渐进式发布
```bash
eas update:rollout --channel production --percent 25
```
先推给25%的用户,观察错误率,再逐步扩大到50%、100%。这是生产环境必须有的安全网。
### 常见OTA问题
**更新不生效**:90%的原因是channel不匹配。用`eas channel:list`确认构建绑定的channel和发布时的`--channel`参数一致。
**"No compatible updates found"**:runtime version不匹配。检查构建的runtime version和更新的target runtime version是否相同。
**更新下载了但不生效**:需要确认`expo-updates`的`checkAutomatically`配置,以及是否在合适的时机调用了`Updates.reloadAsync()`。
## App Store和Google Play上架流程
### App Store上架
1. **构建**:`eas build --platform ios --profile production`
2. **提交**:`eas submit --platform ios --latest`,构建会自动上传到App Store Connect
3. **TestFlight测试**:上传后自动出现在TestFlight中,可以邀请内部/外部测试员
4. **填写上架信息**:在App Store Connect中完成应用描述、截图、隐私政策URL、审核信息等
5. **提交审核**:Apple审核周期通常24-48小时,首次审核可能更长
6. **发布**:审核通过后选择"手动发布"或"自动发布"
### Google Play上架
1. **构建**:`eas build --platform android --profile production`
2. **提交**:`eas submit --platform android --latest`,构建上传到Google Play Console
3. **内部测试轨道**:先推到internal track验证
4. **逐步升级轨道**:internal → alpha → beta → production
5. **填写商店信息**:应用描述、截图、内容分级等
6. **发布**:Google审核通常几小时到几天
两个商店的关键差异:Apple审核严格但可预期,Google审核快但有时会因不明原因拒绝。建议两个平台都先做内部测试再逐步开放。
## 环境变量和Secrets怎么管理
EAS提供了两层环境变量机制,搞混了会踩坑。
### 构建时环境变量
在`eas.json`的`env`字段中定义:
```json
"production": {
"env": {
"APP_ENV": "production",
"API_URL": "https://api.example.com"
}
}
```
这些变量在云端构建过程中可用,用于构建脚本。但注意:它们不会打包进最终的JS bundle。
### Secrets
在Expo后台(expo.dev → 项目 → Environment variables)中创建,勾选"Sensitive"标记。这些值不会出现在git记录中,适合存放API Key、签名密码等敏感信息。
### 运行时环境变量
要让JS代码在运行时读到环境变量,需要用`react-native-dotenv`或Babel宏在构建时把值内联到代码中。Expo SDK 49+推荐的做法是:
```js
// 在app.config.js或app.config.ts中读取环境变量
export default {
extra: {
apiUrl: process.env.API_URL,
sentryDsn: process.env.SENTRY_DSN,
},
};
```
然后在代码中通过`Constants.expoConfig.extra`访问。这比硬编码安全,也比运行时读取可靠。
### EAS Update的环境变量陷阱
`eas.json`中`env`定义的变量只在`eas build`时生效,`eas update`时不会读取。如果你在OTA更新中依赖某个环境变量,需要在`eas update`命令中显式传入:
```bash
eas update --channel production --env API_URL=https://api.example.com
```
## CI/CD集成:怎么把构建自动化
手动跑`eas build`容易忘步骤,接入CI/CD后一切自动化。
### GitHub Actions示例
```yaml
name: EAS Build and Submit
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx eas-cli build --platform all --profile production --non-interactive --no-wait
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
```
几个要点:
- **`EXPO_TOKEN`**:在expo.dev生成Personal Access Token,存到GitHub Secrets中。这是CI认证的唯一方式。
- **`--non-interactive`**:CI环境下必须加,否则命令会等待用户输入而挂起。
- **`--no-wait`**:不等待构建完成,EAS构建通常5-20分钟,加了这行CI立刻结束,省CI分钟数。如果需要等构建结果再执行后续步骤(比如自动提交),去掉这个flag。
### 自动提交商店
构建成功后自动提交:
```bash
npx eas-cli build --platform ios --profile production --auto-submit
```
`--auto-submit`会在构建完成后自动触发`eas submit`,使用`eas.json`中配置的submit profile。
### 自动OTA更新
对main分支的JS改动自动推送更新:
```yaml
- run: npx eas-cli update --channel production --message "${{ github.event.head_commit.message }}"
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
```
完整的CI/CD流程是:push到main → 自动构建 → 自动提交商店 → 自动推送OTA更新给已有用户。
## 常见报错和排查方法
### 构建失败:Gradle daemon disappeared unexpectedly
这是OOM(内存不足)的典型表现。Android构建特别容易触发。
解决方案:在`eas.json`中指定更大的资源规格:
```json
"production": {
"resourceClass": "large"
}
```
`large`规格提供更多内存,构建耗时略长但稳定性大幅提升。同时用Expo Atlas分析bundle体积,找出过大的依赖。
### 构建失败:None of the files exist
通常是文件名大小写问题。macOS文件系统默认不区分大小写,但EAS Build的Linux环境严格区分。本地能跑的import路径到了云端就报错。
排查方法:仔细检查import路径和实际文件名的大小写是否完全一致。
### 提交失败:Missing App Store Connect API Key
iOS提交需要App Store Connect API Key,而且Key的权限必须足够。常见错误是给了只读权限的Key。
解决:重新生成Key,角色选Admin,然后确认Issuer ID和Key ID没有填反。
### 提交失败:Google Play拒绝:Version code already used
每个版本号只能提交一次。如果之前提交了build number 1的包被拒绝,修改后再次提交必须递增build number。
解决:使用`autoIncrement: true`自动管理版本号,永远不要再手动填build number。
### OTA更新静默失败
用户报告没收到更新,但EAS后台显示发布成功。
排查步骤:
1. `eas channel:list`确认channel映射正确
2. 检查构建的runtime version和更新的target runtime version
3. 确认`expo-updates`配置了自动检查:`checkAutomatically: "ON_LOAD"`
4. 在代码中添加日志:`Updates.checkForUpdateAsync()`查看返回结果
### 构建速度太慢
EAS Build每次都从零开始安装依赖和编译。减少构建时间的方法:
1. 锁定依赖版本:用`npm ci`代替`npm install`
2. 指定构建镜像:`"image": "latest"`使用预装了常用工具的镜像
3. 减少原生依赖:每多一个原生模块就多一份编译时间
4. 合理使用`--no-wait`:CI中不阻塞等待
## 写在最后
Expo EAS把React Native应用从构建到上架的流程拆成了三个独立环节:Build负责编译打包,Submit负责商店提交,Update负责OTA推送。理解`eas.json`中build profile和channel的关系是掌握EAS的关键——它决定了你的构建类型、分发方式和更新通道。
对于刚上手的团队,建议从development构建开始验证原生功能,用preview构建给QA验收,确认无误后再跑production构建提交商店。接入CI/CD后,构建和提交变成代码推送的自动后续动作,开发者只需要关注代码本身。遇到问题时,先看EAS构建日志中`[stderr]`前缀的输出,大部分错误的根因都藏在那里。