标签

Expo

Expo是一个面向Android、iOS和网页应用的开源框架。Expo汇集了移动和网络的精华,为构建和扩展应用程序提供了许多重要的功能。Expo npm包为React Native应用程序提供了一套令人难以置信的功能。

Expo
前端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 ```
前端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月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, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#039;"); }; ``` ## 应用安全配置 ### 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]`前缀的输出,大部分错误的根因都藏在那里。