如果你跟我一样用 React Native 配合 Expo 做过 iOS 应用,应该对这一幕不陌生:模拟器里跑得行云流水,功能、动画、接口全部正常,结果一换真机,扫码后要么白屏,要么一直转圈,要么死活连不上 Metro。我第一次接触 Expo 项目时,差点被这套 iOS 特有的链路劝退——后来才发现,问题往往不在业务代码,而在几个非常具体的原生环节上。
这篇文章不是什么入门教程,而是我在 React Native + Expo 这条路上实打实攒下来的 iOS 开发经验。内容会覆盖环境版本、调试链路、真机网络、白屏问题、交互细节、上架签名和合规弹窗这些环节。适合正在开发 iOS 端、准备做真机调试或即将提审的朋友参考,尤其适合那些“模拟器正常但真机出问题”的典型场景。
1. Xcode版本、Node与Expo SDK之间的“三角关系”
很多项目的坑,其实在写第一行业务代码之前就埋下了。Expo 项目看起来好像只是依赖 Node,但当你要跑 iOS 时,背后还拖着 Xcode、CocoaPods、React Native 版本和原生编译链。这四者的版本一旦不匹配,报错会千奇百怪。
1.1 先确认版本组合,再新建项目
如果你刚开始接触 Expo,建议直接用官方脚手架新建项目,不要自己手工折腾版本:
bash复制npx create-expo-app@latest MyApp
cd MyApp
npx expo start
这套命令会帮你选好当前最新的稳定版 Expo SDK,而 SDK 又锁定了对应的 React Native 版本。Expo SDK 的版本迭代是整体推进的,例如 SDK 51 对应 React Native 0.74,SDK 52 切到 0.76,SDK 53 则对应 0.79。不同 SDK 对 Xcode 的最低版本和 iOS 部署目标也有要求,越新的 SDK 通常要求越高,比如部分新版本只支持 Xcode 15 以上。
这里的教训是:不要用 npm install react-native@latest 这种思路去“升级”Expo 项目里的 React Native 版本,想换版本就直接升级 Expo SDK。手工改 React Native 版本后,很容易出现原生模块编译不过、Xcode 里一堆红字的情况。
安装依赖时也要养成一个习惯:
bash复制npx expo install 包名
而不是随手 npm install 包名。expo install 会去匹配当前 SDK 对应的原生模块版本,很多第三方库的 iOS 原生代码是跟着 Expo SDK 走的,版本不匹配在 JS 层不一定看得到,一旦进入 Xcode 编译阶段就会原形毕露。
已经掉进版本坑的项目,可以试试:
bash复制npx expo install --fix
这个命令会尽量把所有依赖修复成与当前 SDK 兼容的版本,但前提是你别乱改原生目录。
1.2 模拟器、真机和开发者模式的环境准备
macOS 端装好 Xcode 后,我第一次跑模拟器就遇到“Could not locate device support files”的错误,原因是 Xcode 版本太老,连新 iOS 系统的 Device Support 文件都没有。这种问题基本只有升 Xcode 一条路。
在真正开始 iOS 开发前,建议先确认这几点:
- Xcode 已启动过一次,且接受过许可协议。命令行环境里可以执行
sudo xcodebuild -license accept避免后续权限报错。 - Xcode 命令行工具指向正确,执行
xcode-select -p,如果路径不对,用sudo xcode-select -s /Applications/Xcode.app切换。 - Node 不要用系统自带的旧版本,推荐通过 nvm 管理,遇到 CocoaPods 或 RN 编译奇怪的报错时,换 Node 版本也是一种排查手段。
如果你要在 iPhone 真机上跑,请务必记得开启“开发者模式”。iOS 16 之后,新装开发包 App 的真机,都需要在“设置 -> 隐私与安全性 -> 开发者模式”里手动开启,否则手机连上 Xcode / Expo 后根本不会帮你安装应用,也不会弹出信任弹窗。这一步很容易被忽略,因为模拟器根本不需要开开发者模式。
另外要注意免费 Apple ID 签名的问题。免费账号确实能在真机上调试,但证书有效期只有 7 天,App 过几天就会打不开,需要重新连上 Xcode 或跑一次 expo run:ios 重装。如果你只是短期验证,免费账号完全够用;如果项目周期较长,还是尽早注册 Apple Developer Program,不然每 7 天重新签一次会非常消磨耐心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 不要一直待在Expo Go的舒适区里
Expo Go 是测试 Expo 项目最方便的工具,扫码即用,不需要经历 Xcode 编译,非常适合验证 UI 和纯 JS 功能。但很多人在项目中期开始掉坑,是因为一直留在 Expo Go 里不愿意切出来。
2.1 Expo Go和development build的本质差异
先理清概念。Expo Go 是一个通用壳 App,它内置了一堆常用的 Expo 原生模块。你用 expo start 启动 Metro 后,Expo Go 会去加载你项目里的 JS bundle。这种模式好处是省去原生编译,坏处是壳是固定的,你只能使用内置的原生能力。
一旦你的项目接入了自定义原生代码,或者用了某些需要修改 Info.plist、引入原生 SDK 的第三方库,Expo Go 就无能为力了。即使只做上架前的最终测试,Expo Go 的环境也与真实产物有差异,容易“Go 里没问题,TestFlight 出问题”。
我后来养成了一个习惯:项目一开始就规划成 development build,也就是通过 Expo 生成一个属于你自己项目的原生工程,在里面加一个开发调试入口。这样平时开发时还能保留热更新和 Metro 调试体验,但所有原生依赖都是真实编译进包里的,不会在最后关头给你来一个措手不及。
2.2 首次构建会经历什么,以及卡住时怎么办
切换到 development build 最直接的命令是:
bash复制npx expo run:ios
这条命令会先执行 prebuild,为你的项目生成 ios 原生目录,然后运行 CocoaPods 安装原生依赖,最后调用 Xcode 编译并安装到模拟器或已连接的真机上。
首次执行时通常比较慢,甚至可能卡在 Installing CocoaPods dependencies 这一步。这多半是网络问题或本机 CocoaPods 源的问题。在项目里执行:
bash复制cd ios
pod install
如果还是不行,可以试试更新本地 pod 仓库:
bash复制pod repo update
开发体验上,你需要记住一个关键区别:development build 装到手机后,如果手机和电脑在同一局域网,可以先执行 npx expo start,再用 App 里的开发者入口去连 Metro,而不是每次都用 Xcode 重新编译。App 打开后会有一个开发者菜单,选择连接开发服务器,这样改 JS 代码后的刷新速度会快很多。
真机首次跑 development build 时还可能遇到“未受信任的开发者”提示,这时需要到“设置 -> 通用 -> 描述文件与设备管理”里信任你的开发者证书。免费账号会面临 7 天重签的问题,付费开发者账号则没有这个烦恼。
3. iOS真机网络调试:那些和localhost、HTTPS缠斗的夜晚
从模拟器切到真机,最让人抓狂的往往不是编译,而是网络请求。你会在这一阶段遇到各种“为什么电脑上能通、手机上就挂”的怪事。
3.1 为什么模拟器能通,真机却全挂了
iOS 模拟器本质上与 Mac 共享网络栈,所以你在代码里请求 http://localhost:3000 时,模拟器能直接访问到 Mac 本地的服务。真机就不一样了,它运行在独立的网络环境里,localhost 指的是 iPhone 自己,不是你的电脑。
开发阶段,如果你本机起了一个后端服务,真机要访问它,必须把地址改成电脑的局域网 IP,比如 http://192.168.1.5:3000。并且要确认后端服务监听在 0.0.0.0 而不是只监听 127.0.0.1,否则即使手机和电脑在同一个 Wi-Fi,也连不上。
Expo 本身在启动 Metro 时会自动检测局域网 IP,默认给手机一个类似 exp://192.168.x.x:8081 的地址,这也是为什么“一键扫码”看起来很顺畅。但在公司网络、校园网或 AP 隔离环境下,手机和电脑之间可能互相无法访问,表现为扫码后一直加载、转圈,或者根本没有页面。遇到这种情况,可以先确认手机是否能 ping 通电脑 IP。如果实在不行,我建议直接用 USB 连接手机和电脑,然后通过 Xcode 的开发者模式安装调试包,再在 development build 里连接 Metro,这种方式不依赖局域网。
3.2 HTTPS抓包、证书信任与SSL版本报错
做 iOS 网络调试时,很多人会用到抓包工具,比如 Charles 或 Proxyman。流程无非是手机设置 HTTP 代理到电脑端口,然后安装并信任根证书。实际执行时最常见的坑有两个。
第一个坑是安装了根证书但未开启“完全信任”。iOS 对用户安装的证书默认不信任,你需要到“设置 -> 通用 -> 关于本机 -> 证书信任设置”里,把对应证书的开关打开,否则抓包时看到的是 SSL 握手失败或一堆乱码。
第二个坑非常经典,报错信息类似“客户端和服务器不支持一般 SSL 协议版本”。这通常不是 App 代码问题,而是抓包工具与目标服务器之间 TLS 版本协商失败。你可以看一下抓包工具的 SSL 代理设置,看看是否支持 TLS 1.3;或者服务端禁用了较老的 TLS 版本,而代理服务器默认尝试的版本不在支持列表内。此时可以调整抓包工具的 SSL 版本策略,或者干脆在 Proxy Settings 里关闭该域名的 SSL 解密,先确认 HTTPS 请求本身是否正常。
还有一点要注意:如果 App 端实现了证书固定(SSL Pinning),那就算安装了根证书,也无法解密 HTTPS 流量。抓包时常见表现是连接被重置,或者请求直接报错。这种情况下的排查思路是暂时在开发环境关闭证书校验,而不是在抓包工具上死磕。
iOS 还有一个关于明文请求的限制——App Transport Security(ATS)。默认情况下,iOS 会拦截所有 HTTP 明文请求,只允许 HTTPS。Expo 开发模式下通常会放行明文请求,方便联调,但这个放行不应该原样带到生产包。如果你上线后还需要请求某个 HTTP 接口,建议要么服务端升级 HTTPS,要么在 Info.plist 里只针对特定域名添加例外,而不是直接打开 NSAllowsArbitraryLoads 全局开关。
4. 启动白屏、图片缓存与文件下载:iOS特定行为的排查记录
RN + Expo 的 iOS 项目里,有三个问题几乎每个开发者都会遇到:启动白屏、图片缓存不刷新、下载文件在 iOS 上表现怪异。这里我把它单独列一节,因为它们的排查思路对 iOS 来说非常特殊。
4.1 启动白屏的几类根源与判断顺序
启动白屏在 iOS 上的概率远高于 Android,尤其当你用 development build 时。遇到白屏先别慌,按照优先级排查。
第一类,也是最常见的,是开发模式下 Metro 没连上。App 启动后找不到 Metro 服务器,JS bundle 加载不出来,表现就是白屏或者停留在启动图。这时看终端里 npx expo start 的输出,如果 App 连接成功,会看到 bundle 请求日志;如果没有,说明连接链路出了问题。可以杀掉 App 重新打开,或在开发者菜单里手动输入 Metro 地址。
第二类是缓存问题。新架构或第三方原生模块的缓存可能导致旧代码残留。办法是先清理再重启:
bash复制npx expo start -c
-c 会清掉 Metro 缓存。如果还白屏,就删掉 ios/build 目录,重新跑一次 npx expo run:ios。这个过程很耗时,但往往能解决很多“莫名其妙白屏”。
第三类是原生配置与代码不匹配。如果项目里装了原生模块但没重新编译 development build,App 在启动时可能因为找不到原生方法而崩溃,崩溃后有时候就是一个白屏。可以看 Xcode 的 Console 日志或 npx expo start 的红屏错误。如果你在 React Native 0.76 以上版本使用了新架构,部分第三方原生库不支持新架构时,也容易启动崩溃,可以在 app.json 中显式设置:
json复制{
"expo": {
"newArchEnabled": false
}
}
然后重新构建验证。这里要说明,关闭新架构不是长期方案,只是排查手段,确认是新架构问题后,应该去换支持新架构的库版本。
4.2 WebView下载、blob和a.click()在iOS上的“失效”真相
很多 RN 项目会在 App 里用 WebView 内嵌一个 H5 页面,而 H5 页面里如果有文件下载功能,iOS 端会表现得很怪异。比如“fetch -> blob -> createObjectURL -> a.click()”这个前端下载套路,在 Android WebView 里可能正常,在 iOS Safari 或 WKWebView 里却点了没反应。
原因在于 iOS 对非用户直接触发的下载行为限制较严格,单纯的 JS 动态创建 <a> 并且触发 click(),很容易被无视或是被新页面拦截。即使你在 HTML 里加了 download 属性,Safari 很多时候也会直接把文件打开成预览,尤其是 PDF 图片这类格式。
如果 H5 页面是你自己的,最简单的办法是让下载按钮用一个真实可点击的链接,或者 window.open(url, '_blank'),让用户在系统浏览器或 WebView 新页面里处理,但这体验并不好。如果是 RN 侧的原生页面,建议直接绕开 WebView,用原生下载链路。Expo 环境下最省事的组合是 expo-file-system 加 expo-sharing:
typescript复制import * as FileSystem from 'expo-file-system';
import * as Sharing from 'expo-sharing';
const downloadAndShare = async (url: string, fileName: string) => {
const targetUri = FileSystem.cacheDirectory + fileName;
const result = await FileSystem.downloadAsync(url, targetUri);
if (result.status === 200 && (await Sharing.isAvailableAsync())) {
await Sharing.shareAsync(targetUri);
}
};
这样下载的文件落在 App 沙盒缓存目录,然后调起系统分享面板,用户可以选择存储到“文件”App 或发给别人。实测这在 iOS 上最稳定,不会出现“下载了但找不到文件”的尴尬。
图片缓存不刷新也是 iOS 老问题。RN 自带的 Image 组件在 iOS 上会走 NSURLCache,有时服务端更新了同一路径的图片,App 里却还是旧图。处理方式可以是给 URL 加时间戳查询参数,或者在依赖支持时换用 expo-image,它的缓存控制更直观,也支持 reload 之类策略。
5. iOS交互细节与生命周期适配:从安全区一直聊到墓碑机制
iOS 和 Android 的交互差别,在真机上体会会更深。安全区、键盘、后台生命周期这些点,埋了很多“模拟器上没什么、真机上很丑”的雷。
5.1 键盘与安全区:最容易被忽略的两张“布局脸”
如果你用 ScrollView 包着一个评论框,输入时很容易出现键盘把输入框顶走或者遮挡的问题。RN 核心组件 KeyboardAvoidingView 在 iOS 上一般需要设置 behavior="padding",Android 则通常交给系统处理:
tsx复制import { KeyboardAvoidingView, Platform } from 'react-native';
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
{/* 页面内容 */}
</KeyboardAvoidingView>
但注意,如果你的页面用了自定义 header,或者外层套了导航栏,还要设置 keyboardVerticalOffset,否则键盘弹起后区域会算错,表现为整体上移过头或不够。
安全区问题更是 iOS 特有的“脸面”。iPhone 的刘海屏、灵动岛和底部 Home Indicator 都会遮住内容。过去很多人直接用核心组件里的 SafeAreaView,但它的表现很有限,只对最外层生效。我现在基本都是用 react-native-safe-area-context 包一层:
tsx复制import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
function DetailPage() {
const insets = useSafeAreaInsets();
return (
<View style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}>
{/* 页面内容 */}
</View>
);
}
这样底部按钮不会跑到 Home Indicator 下面,也不会有内容被灵动岛盖住。
5.2 AppState、后台恢复和墓碑机制
iOS 的“墓碑机制”其实比 Android 友好,App 退到后台后一般不会被立即杀死,而是挂起。但挂起不等于一切正常,网络连接很可能在后台被系统断开或超时。当你再次切回 App 时,页面可能还停留在旧状态,接口却已经失效了。
RN 里监听生命周期一般用 AppState:
tsx复制import { AppState } from 'react-native';
import { useEffect } from 'react';
useEffect(() => {
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') {
// 重新拉取数据、刷新页面状态
}
});
return () => sub.remove();
}, []);
这里要注意:iOS 上从“后台挂起”恢复到前台,顺序通常是 inactive -> active,所以监听 active 触发刷新是可靠的。但不要在这里做过于频繁的同步操作,因为如果用户只是临时下拉通知中心再回来,也会触发一次。我的习惯是记录一下时间戳,如果离上次刷新超过一段时间再重新请求。
另外,如果 App 是通过推送通知点击图标启动的,首次启动时拿到的通知参数往往和“从后台恢复”时不一样。处理推送跳转时,要同时处理冷启动和热启动两种路径,否则很容易出现“通知来了没反应”的线上反馈。
5.3 循环滚轮选择器的组件选型
如果你的产品里需要一个类似 iOS“日期滚轮”的选择器,并且要求循环滚动,用 @react-native-picker/picker 在 iOS 上可以直接获得原生滚轮风格,Android 上则是下拉框风格。它自带一个 itemStyle 和循环视觉效果,但真正的“无限循环”逻辑在数据量不够时并不容易实现。
比较常见的笨办法是让数据列表“首尾相连”,比如给数据源后面再接一段相同的数据,滚动到边界时无动画跳回对应位置。数据量小可以直接上,数据量大或需要复杂动画建议用 FlashList 配合 recyclerlistview 或者 @shopify/flash-list 来做自定义滚轮,这样对性能更可控。我的建议是:如果不是特别核心的交互,尽量用原生 Picker;一旦决定自定义滚轮,就要做好 Android 和 iOS 两端手势差异的测试,因为两端的滚动惯性差别非常明显。
6. 上架发布前的最后一道防线:证书、权限文案与合规弹窗
开发中的九九八十一难都过去了,很多人反而在上架这一步被卡得欲哭无泪。iOS 上架涉及签名、证书、描述文件、权限说明和隐私合规,Expo 能帮你省掉一部分麻烦,但不是全部。
6.1 签名不用从头手搓:EAS构建能帮你省一半事
如果你想打包 iOS 生产版本,传统流程需要开发者账号、本地 Xcode、手动配置证书和描述文件,最后 Archive 导出 IPA。用 Expo 的话,有更省事的路子:EAS Build。
bash复制npm install -g eas-cli
eas login
eas build:configure
eas build --platform ios --profile production
EAS Build 会在云端的 Mac 环境里完成签名和打包。你甚至不用自己在本地生成 p12 证书,只要在 EAS 里选择自动签名,它会帮你管理证书生命周期,包括向 Apple 申请、注册设备 UDID、生成描述文件等。对于从来没碰过真机证书的人,这套机制能少踩很多坑。
但自动签名也不是没有坑。一个常见问题是当你需要在 TestFlight 上分发时,EAS 自动生成的证书可能与你 Xcode 本地手动配置的证书冲突,尤其当你既用 EAS 又偶尔用 Xcode 导包时。我的做法是:统一走 EAS,不让本地 Xcode 直接签名生产包,开发阶段才在 Xcode 里手动选择 Team。
版本号也要顺手处理好。App Store 的构建版本号必须递增,在 app.json 里对应的是 expo.ios.buildNumber,注意这个字段的格式是纯数字字符串,比如 "1.0.0"、"1.0.1"。你每次提交新的构建时,记得改大它,否则上传 App Store Connect 时会报“构建版本号重复”的错误。
6.2 权限文案、隐私弹窗与“拒绝后退出App”的正确姿势
凡是涉及相机、相册、定位、麦克风的 iOS 应用,都需要在 Info.plist 里提供用途描述。很多新手把描述随便写一句“需要相机权限”就提交了,审核时被打回的概率不低。我的建议是尽量把用途写人话,例如“用于拍摄头像并上传”,而不是笼统的“用于相机功能”。在 Expo 项目里,权限描述可以通过插件配置注入:
json复制{
"expo": {
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "用于拍摄用户头像"
}
}
}
}
如果你的项目已经执行过 prebuild 生成了原生 ios 目录,直接改 Info.plist 也能生效,但下次 prebuild 时会被覆盖,所以最好还是在 app.json 里统一维护。
隐私弹窗这块,实际开发里有一个高频需求:首次启动时弹“用户协议和隐私政策”,如果用户点击不同意,需要退出 App。React Native / Expo 的实现思路一般是这样:
tsx复制import { Alert, BackHandler, Linking } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
const showPrivacyDialog = async () => {
const agreed = await AsyncStorage.getItem('hasAgreedPrivacy');
if (agreed) return;
Alert.alert(
'用户协议与隐私政策',
'请您仔细阅读并同意...',
[
{ text: '不同意', style: 'cancel', onPress: handleDisagree },
{ text: '同意', onPress: handleAgree },
],
{ cancelable: false }
);
};
const handleAgree = async () => {
await AsyncStorage.setItem('hasAgreedPrivacy', 'yes');
};
const handleDisagree = () => {
// iOS 上不建议直接 exit(0),更好的做法是引导去系统设置或留在不可用页面
Linking.openURL('app-settings:');
};
技术上,调用 BackHandler.exitApp() 在 Android 上可以退出 App,但 iOS 本身没有统一的“退出应用”API,直接调用原生 exit(0) 在 App Store 审核中可能被判定为糟糕的交互体验。较稳妥的方案是点击“不同意”后引导用户到系统设置页,或者停留在无法继续操作的提示页,避免强退带来的审核风险。
有的应用会把“不同意”按钮做成二次确认:“您需要同意协议后才能使用本应用”,然后再次弹出协议页面。这种设计能明显降低被审核挑战的概率,也保护了产品的合规底线。
6.3 审核被拒的常见原因与处理心态
上架审核被拒是常态,不用恐慌。常见原因里,ITMS-91053 属于隐私清单缺失或声明不完整的问题,这类通常会明确指出是哪个 SDK 使用了 required reason API 但没有声明。Expo SDK 较新版本已经内置了大部分隐私清单,但如果你手动接入了某个第三方原生 SDK,就要回到 Xcode 里检查那个库是否带 PrivacyInfo.xcprivacy 文件。
还有一类被拒原因是“App 截图与实际功能不符”,处理起来很简单,重新截图上传就行。但要注意,App Store 审核员会真的模拟用户操作,如果你的 App 要求必须先登录才能看到内容,请务必在审核备注里写明测试账号,否则很容易被以“功能无法访问”为由打回。
如果收到“4.3 设计相似”类的反馈,大概率是审核方认为你的 App 和现有应用过于相似,或者有刷屏嫌疑。这种问题不能靠改代码解决,更多要靠差异化文案、界面截图和功能说明来证明独特性,有时还需要在审核备注里解释清楚你的应用是做什么的、为什么与同类产品有本质区别。
最后再分享一个小习惯
这套流程走通之后,我现在做 RN + Expo 的 iOS 项目,一般会从第一天就切到 development build 模式,并且中途定期用 EAS Build 做一个 TestFlight 包,而不是等开发到最后才开始考虑原生构建。这样能提前暴露原生模块、签名、权限等一堆问题,避免把所有风险都积压在上架节点附近。
如果你也在用 Expo 做 iOS,希望这篇经验能帮你少走一些弯路。版本问题、真机调试、网络请求、启动白屏、上架合规,每一个环节都不是孤立的技术坑,而是 iOS 生态里一套完整的行为规则。尽早按照 iOS 的规则去设计你的调试和发布流程,比临到上架前东补西补要舒服得多。
