Expo 应用在 Android 9 上 HTTP 失败:是 cleartext,不是 VPN
调用普通 HTTP API 的 Expo 构建在 Android 9 及更高版本上失败。隧道是背锅的:cleartext 标志属于构建配置。
你发布了新构建,切到 VPN,同一个页面却打不开了。在 Android 9 及更高版本上,更常见的解释要无聊得多:平台拒绝为目标 API 28 或更高的应用提供明文 HTTP,而 VPN 只是改变了足够的上下文,让这个问题暴露出来。你看到的报错取决于 HTTP 客户端,所以它很少直接说 cleartext。
这个失败到底是什么
从 Android 9 起,只要应用以 API 级别 28 或更高为目标,平台就默认拦截 cleartext。发往明文 HTTP 地址的请求在任何一个数据包离开设备之前就被平台拒绝。你看到哪种报错取决于 HTTP 库:有时是笼统的网络失败,有时是超时,有时是明确指出 cleartext 的消息。它绝不会做的事,是到达你的服务器。Android 在安全配置指南中记录了默认行为和按域名限定的替代方案。
同一台设备上的浏览器仍然能打开该页面,而这正是大多数人忽略的线索。浏览器不受你应用的网络安全策略约束。
为什么背锅的是 VPN
通常有三件事同时变化。连上 VPN 后应用可能解析到另一个主机名,设备可能是新机、构建也更新,或者构建本身换到了会触发默认行为的目标 API 级别。VPN 看得见,目标 SDK 看不见,于是工单记在了 VPN 头上。
在动网络配置之前,先对比新旧两个构建的目标级别。目标为 27 或更低的应用不受此限,这也解释了所有「我旧手机上能用」的反馈。
Expo 的标志究竟落在哪里
在托管式 Expo 项目中,往 app.json 里加配置并不会编辑 Android manifest,因为生成 manifest 的是 prebuild 和开发构建。真正在那个阶段写入配置的是配置插件。受支持的路径是 expo-build-properties,在应用配置里把 cleartext 选项放在 android 之下。
{
"plugins": [
["expo-build-properties", {
"android": { "usesCleartextTraffic": true }
}]
]
}这段代码只设置了插件选项,manifest 是之后才生成的。跑一次 prebuild 或全新的开发客户端构建,然后在重新测试之前检查生成的 AndroidManifest.xml 里有没有 android:usesCleartextTraffic,因为只靠 JS 重载会保留旧的 manifest。把插件版本对齐项目所用的 Expo SDK。插件选项会随版本变动,从别的 SDK 抄来的片段是「静默失效」的可靠来源。
bare 流程与更窄的选项
对 bare React Native 来说,等价做法是在 application 元素上设置 android:usesCleartextTraffic,或者用一份只对指定域名放行 cleartext 的网络安全配置。应当优先选择按域名限定的形式,因为它允许内部 API,其余全部留在 HTTPS 上。它还会把自己的例外清单写进文档,而这正是评审会追问的部分。
这两种做法都不会改变平台对你没有点名的任何内容的默认行为。
先从设备日志里证明它
改配置之前,先拿到确切的失败。在已连接的设备上运行 adb logcat,会显示平台的 cleartext 拒绝,并指出是哪个组件触发的。连接被拒、名字解析不了、TLS 握手失败,指向的完全是别处;此时打开 cleartext 只会白白削弱应用。
然后让生产环境保持诚实。对无法提供 TLS 的实验室端点,cleartext 可以接受,但只能在你排得开的最短时间内使用。如果 API 能在私有网络内部终结 TLS,就把证书装进设备的信任存储,让 cleartext 保持关闭,因为隧道加密的只是到出口的那一段,出口之后一概不管。
一旦端点能用 HTTPS 应答,就撤掉该权限,重新构建,并在同一次会话里分别用 VPN 和普通 Wi-Fi 测试。两条路径的名字解析可能不同,只有都测一遍才能知道哪边说的是真话。