接手过一个改造任务:把原本跑在 PC 浏览器里的 Vue2 报表系统,用 Cordova 11 打包成 Android APK。PC 端测得很顺畅,WebSocket 推送一切正常。结果装到手机上直接翻车——页面能打开,登录接口也能通,唯独 WebSocket 连不上,onerror 一直在触发,实时数据全部拉不到。这种“桌面好好的、移动端就废”的问题,排查起来其实比全部不能用还折磨人。
折腾了一个下午,把 Android 权限、明文流量策略、Cordova 白名单、地址监听挨个捋了一遍,总算搞定了。这篇文章就把当时的完整排查思路和最终方案记录下来,给同样用 Cordova 打包 Vue2 项目的朋友做个参考。
1. 问题现象与初步定位
1.1 现象复现:一致的代码,不同的结果
先说项目背景,这套系统是典型的 Vue2 单页应用,开发模式下 WebSocket 地址写的是ws://localhost:8080,PC 浏览器打开一切正常,实时数据推送、消息恢复都能跑通。用 Cordova 11 打包成 APK 后,页面能正常加载,HTTP 接口也都能请求通,唯独 WebSocket 连接建立不起来。
打开 Android WebView 的调试日志,能看到类似这样的错误:
WebSocket connection to 'ws://localhost:8080/ws' failed: Error during WebSocket handshake: Unexpected response code: 403或者干脆是:
WebSocket connection failed: net::ERR_CLEARTEXT_NOT_PERMITTED这两种错误指向的是完全不同的问题,但混在一起时新手很容易误判成“是不是代码写错了”。实际上,只要是同样的 APK 包,PC 上没问题、手机上不行,基本可以断定是移动端运行环境对网络请求的限制,而不是 Vue2 业务代码本身的逻辑问题。
1.2 排错前先做好这两件事
遇到这类问题,我建议先别急着改代码,先把下面两件事做了,能省掉大量无效排查:
第一,确认 WebSocket 服务器确实监听在可供手机访问的地址上。很多人开发时启动服务默认绑定127.0.0.1,这个地址在 PC 上访问没问题,因为浏览器和服务器在同一台机器。手机是独立设备,访问localhost指向的是手机自身,自然连不上。
第二,拿到手机端的完整错误信息。PC 浏览器按 F12 能看到详细的失败原因,手机 WebView 里的报错往往一闪而过。我习惯在代码里给 WebSocket 的onerror事件加日志,然后再用后文提到的远程调试方式抓取完整调用栈。很多问题在没有完整错误日志前,全凭猜会走很多弯路。
2. 为什么 PC 正常手机不行:四个关键疑点
2.1 INTERNET 权限:基础但必须确认
Android 应用访问网络,第一步是申请INTERNET权限。Cordova 11 默认在AndroidManifest.xml里带上了这个权限,大多数情况下不需要手动加。但如果你改过安卓平台目录,或者使用的 Cordova 插件对权限做过裁剪,这个权限可能丢失。
排查方法很简单,解包 APK 后检查AndroidManifest.xml,或者直接看platforms/android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />这个权限缺失的表现是:HTTP 接口可能能通(取决于 WebView 和原生请求库的差异),但 WebSocket 这类长连接往往会立刻失败。实际上,Cordova 应用的 HTTP 请求走的是 WebView 网络栈,同样受权限影响,所以如果你发现页面里的 Axios 请求也能通,基本可以排除这个原因。但权限检查成本低,花 10 秒确认一下总比后面绕圈强。
2.2 Android 9 明文流量限制:ws:// 被拦的第一道墙
从 Android 9(API 28)开始,系统默认禁止应用使用明文流量。所谓明文流量,就是不经过 TLS 加密的传输,包括http://和ws://协议。
ws://是明文协议,Android 9 以上的设备会直接拦截。PC 浏览器通常没有这个限制,所以你在开发环境里用ws://localhost:8080一切正常,到了手机上就报ERR_CLEARTEXT_NOT_PERMITTED。
这里有个容易混淆的点:如果你用wss://(WebSocket 的安全版本),走的是 TLS 加密通道,明文限制不会拦它。但大多数开发环境的 WebSocket 服务都是本地起的,配 TLS 成本太高,所以最简单的方案是给应用开启明文流量许可。
这个配置的本质是修改AndroidManifest.xml中<application>标签的android:usesCleartextTraffic属性。Cordova 项目不应该直接改platforms/android下的文件,因为重新build时会覆盖,正确做法是通过config.xml的编辑指令来注入。
2.3 Cordova 白名单与 CSP:还有两道隐形门槛
Cordova 项目有两个容易忽视的访问控制层:一是 Cordova 自身的白名单机制,二是网页的 Content Security Policy(CSP)。
Cordova 11 的config.xml里有几个关键标签:
<access origin="*" /> <allow-navigation href="*" />access控制应用可以请求哪些外部资源,allow-navigation控制 WebView 可以导航到哪些地址。WebSocket 连接受access影响,如果origin配得太严,连接会在网络层被 Cordova 拦掉。
另外,如果你的index.html里配了 CSP,需要在connect-src指令中明确允许 WebSocket 地址。Vue2 项目用脚手架生成时不一定带 CSP,但自己加过安全策略的就要小心,CSP 里的connect-src会限制所有网络连接,包括ws://。
CSP 配置示例:
<meta http-equiv="Content-Security-Policy" content="default-src 'self' data: gap: https://ssl.gstatic.com 'unsafe-eval'; connect-src 'self' ws://192.168.1.100:8080 http://192.168.1.100:8080; style-src 'self' 'unsafe-inline';">注意connect-src不是只写ws://就行,你还得把 WebSocket 服务的完整地址加上,域名带端口。很多连接失败其实是 CSP 拦截,但报错信息被 WebView 吞了,看起来像是在握手阶段出的问题。
2.4 地址与监听:localhost、127.0.0.1 和 0.0.0.0 的差别
这可能是最有迷惑性的一类原因。PC 上你写ws://localhost:8080没问题,手机上也写ws://localhost:8080,报错却是连接被拒绝或者超时。
原因不复杂:手机里的localhost指向的是手机自己,不是开发机。手机要连接电脑上的 WebSocket 服务,必须使用电脑在局域网中的 IP 地址,比如ws://192.168.1.100:8080。
但这还没完。即使你改了地址,如果 WebSocket 服务启动时监听的是127.0.0.1,那么局域网内其他设备依然连不上。你得让服务监听0.0.0.0,表示接受所有网络接口的连接。
以 Node.js 的ws库为例:
const WebSocket = require('ws'); const server = new WebSocket.Server({ port: 8080, host: '0.0.0.0' });开发时图省事直接写port: 8080,Node.js 默认也会监听0.0.0.0,但有些框架或自定义服务可能只绑了回环地址。这个用netstat -ano | findstr 8080(Windows)或lsof -i :8080(Mac/Linux)就能查。
另一个与此相关的坑是路由器的“AP 隔离”功能。手机和电脑连同一个 WiFi,如果路由器开启了 AP 隔离,设备之间是不允许互相通信的,这时候 WebSocket 永远连不上,但所有设备访问外网又是正常的,非常容易误判。
3. 实操修复:从 config.xml 到网络安全配置
3.1 用 edit-config 给 AndroidManifest 开启明文流量
Cordova 官方支持在config.xml里用edit-config指令修改生成的 Android 配置文件,这样重新构建时不会丢配置。
在config.xml的<platform name="android">节点里加上:
<platform name="android"> <edit-config file="app/src/main/AndroidManifest.xml" mode="merge" target="/manifest/application"> <application android:usesCleartextTraffic="true" /> </edit-config> </platform>file属性指向的是相对于platforms/android目录的路径,target是 XPath 表达式,mode="merge"表示把<application>节点里的属性合并进原文件。这样构建后,最终生成的AndroidManifest.xml里会带有android:usesCleartextTraffic="true"。
这个方案是全局放行所有明文流量。如果只是开发环境用,问题不大;如果要上生产,建议改用下一节的按域名放行方案,更安全。
修改完config.xml后,不要直接改platforms/android下的文件,一定要重新执行构建命令。我自己就吃过亏,直接改了安卓工程文件,结果执行cordova build android后所有手改全部被覆盖。
3.2 network_security_config:按域名放行更稳妥
如果你不希望全局放开明文流量,Android 还提供了网络安全配置文件,可以更精细地控制哪些域名允许明文。
在项目根目录新建resources/android/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?> <network-security-config> <base-config cleartextTrafficPermitted="false"> <trust-anchors> <certificates src="system" /> </trust-anchors> </base-config> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="true">192.168.1.100</domain> <domain includeSubdomains="true">api.example.com</domain> </domain-config> </network-security-config>然后在config.xml里引入这个文件,并设置networkSecurityConfig属性:
<platform name="android"> <resource-file src="resources/android/xml/network_security_config.xml" target="app/src/main/res/xml/network_security_config.xml" /> <edit-config file="app/src/main/AndroidManifest.xml" mode="merge" target="/manifest/application"> <application android:networkSecurityConfig="@xml/network_security_config" /> </edit-config> </platform>注意这里有个细节:如果你的项目已经通过edit-config设置了usesCleartextTraffic,又设置了networkSecurityConfig,那么networkSecurityConfig会优先于usesCleartextTraffic。usesCleartextTraffic其实是 Android 为简单场景提供的快捷开关,两者同时存在时,配置文件的规则生效。所以生产环境推荐用配置文件方式,开发环境图省事则用usesCleartextTraffic="true"直接开全局。
3.3 修 WebSocket 连接地址:动态拼接而不是写死
解决了明文流量问题,接着要处理地址。一个很容易踩的坑是:Vue2 项目开发时,API 地址和 WebSocket 地址都写在环境变量文件里,打包时直接写死localhost。
最好的做法是在前端代码里动态判断当前环境,自动拼接 WebSocket 地址:
// 以 HTTP 请求地址为基础推导 WebSocket 地址 function getWebSocketUrl() { const { protocol, hostname } = window.location; const wsProtocol = protocol === 'https:' ? 'wss://' : 'ws://'; // 如果项目部署在 WebView 里,location.hostname 可能是空或者 file:// 协议 // 这时需要从配置里读取一个基准地址 const baseHost = hostname || window.WebSocketServerHost || '192.168.1.100'; return `${wsProtocol}${baseHost}:8080/ws`; }在 Cordova WebView 中,window.location的协议可能是file://,这时候location.hostname会是空字符串,所以不能完全依赖它。我当时的做法是:在index.html里定义一个全局配置对象,根据构建环境往里写不同地址:
window.AppConfig = { wsUrl: 'ws://192.168.1.100:8080/ws' };然后在业务代码里读取这个全局变量。这样打包测试机时改一个地方就行,不用在业务代码里到处找硬编码。
另外一点:如果 WebSocket 服务端有握手校验,比如检查Origin请求头,手机 WebView 发出的Origin值可能和 PC 完全不同。PC 浏览器通常带的是http://localhost:8080,Android WebView 则可能是file://或http://localhost。服务端如果只白名单了 PC 的 Origin,就会返回 403。
3.4 服务端监听与 Origin 校验调整
这一步说起来简单,但实际踩坑的人不少。需要同时确认两件事:
一是确认 WebSocket 服务监听的地址。如果是127.0.0.1,改成0.0.0.0。不同语言的写法不一样,Node.js 的ws库直接在构造函数里传host参数即可;Python 的websockets库在serve时也有host参数。
二是处理服务端的 Origin 校验逻辑。以 Node.jsws库为例,默认它不校验 Origin,但很多人自己加了校验逻辑:
const WebSocket = require('ws'); const server = new WebSocket.Server({ port: 8080, host: '0.0.0.0', verifyClient: (info) => { // 不要只允许 http://localhost // 允许合法来源、file:// 来源等 const origin = info.origin || ''; return origin.startsWith('http://localhost') || origin.startsWith('file://') || origin.startsWith('http://192.168.1.'); } });如果你不确定服务端有没有校验,可以先临时关掉校验,看手机能否连上。能连上就说明是 Origin 白名单的问题,再针对性调整即可。这个排查方式很实用,能快速把问题定位到具体环节。
4. 地址与握手细节:WebSocket 在 WebView 里的特殊性
4.1 WebView 与浏览器的实现差异
Android WebView 的 Chromium 内核版本和手机系统 Android 版本强相关。Cordova 11 打包时,不同的 Android 设备上 WebView 可能是不同版本的 Chromium,对 WebSocket 协议的支持细节可能存在细微差异。
通常来说,WebSocket 在 WebView 里的基本行为与浏览器一致,但有几个特例:
第一,file://协议下的权限判定更严格。当页面通过 Cordova 加载时,地址可能是file:///android_asset/www/index.html,这种情况下混合内容(HTTPS 页面加载 WS 明文)会直接被拦截,除非 CSP 与网络安全配置都处理了。
第二,WebView 对Origin头的处理与 Chrome 浏览器不完全相同,这个前面已经提到了。
第三,部分国产 Android 系统对后台网络限制严格,应用退到后台后,WebSocket 连接会被系统主动掐断。这与项目代码无关,是系统级行为。
4.2 混合内容与 WSS 的取舍
如果你的 Web 应用本身跑在 HTTPS 环境下,那么页面里发起的ws://连接会被浏览器和 WebView 当作混合内容直接拦截。这时候即使usesCleartextTraffic="true"也不一定能解决问题,因为浏览器内核自身的混合内容拦截策略也在起作用。
两种做法:
第一种,把 WebSocket 服务升级为wss://,用 TLS 加密。开发环境可以用自签名证书,但 Cordova WebView 对自签名证书的信任又是一个麻烦事,需要额外配置。
第二种,如果 WebSocket 服务只在内网使用,不涉及敏感数据传输,就在 WebView 的配置层面把混合内容限制放开。Cordova 项目里可以通过插件来设置setMixedContentMode,比如使用相关配置插件:
cordova plugin add cordova-plugin-webview-mixed-content-mode然后配置:
<platform name="android"> <preference name="MixedContentMode" value="always" /> </platform>这个方案适合内网工具类应用,如果涉及外部用户数据,还是建议老老实实上wss://。
4.3 端口占用与多环境切换
还有一个常见的开发场景:本地同时开着前端开发服务器(端口 8080)和 WebSocket 服务(端口 8080),端口冲突会导致 WebSocket 服务根本没起来。PC 浏览器测试时可能连接的是另一个进程,但看起来像正常的。
更隐蔽的是,有些 WebSocket 服务库会自动重连,如果第一次握手失败,它不会立刻报错,而是进入静默重试,这会让你误以为连接还在建立中。排查时建议把重试次数暂时改成 0,让每一次失败都清晰暴露出来。
5. 常见问题速查与避坑记录
5.1 典型报错与应对速查表
| 报错信息 | 根本原因 | 应对方案 |
|---|---|---|
net::ERR_CLEARTEXT_NOT_PERMITTED | 明文流量被 Android 拦截 | usesCleartextTraffic或网络安全配置文件放行 |
WebSocket connection failed: Unexpected response code: 403 | 服务端校验 Origin 失败 | 调整服务端 Origin 白名单 |
net::ERR_CONNECTION_REFUSED | 端口没监听或地址错误,服务可能只绑了 127.0.0.1 | 确认服务监听0.0.0.0,手机改用局域网 IP |
net::ERR_CONNECTION_TIMED_OUT | 网络不通,可能是 AP 隔离或防火墙,或不在同一网段 | 关闭路由器 AP 隔离,检查防火墙入站规则 |
WebSocket is closed before the connection is established | 连接在建立前被生命周期或心跳逻辑关闭 | 检查应用是否退到后台,清除异常重试逻辑 |
| 连接经常断开、几秒后重连 | 心跳机制缺失或服务器主动断开 | 补充心跳检测,发送 ping 帧保活 |
这张表是我后来反复用的排查清单。遇到问题先对号入座,八成的场景都能直接找到方向。
5.2 手机 WebView 特有的三个坑
第一个坑是localhost的语义。在 PC 上它指开发机,在手机 WebView 里指手机自己。这不是代码 bug,是环境差异。我见过有同事反复确认“明明浏览器能连,为什么 App 里不行”,最后才发现是不该写 localhost。
第二个坑是 HTTP 与 WS 协议一致性。如果你的页面是通过 HTTPS 加载的,WebSocket 也必须用 WSS,否则会被混合内容策略拦截。很多人只注意到 Android 明文流量限制,忽略了这一层。
第三个坑是热更新与缓存。Cordova 打包的 WebView 对静态资源有缓存,改完前端代码重新打包后,如果手机上的旧版本没有清理,可能还在执行旧的 WebSocket 地址链接逻辑。建议每次重新构建 APK 后,在代码里临时打印一行console.log(wsUrl),用调试工具确认实际连接地址,避免“改了但没生效”的错觉。
5.3 远程调试:chrome://inspect 和 logcat
遇到 WebView 里看不清楚的网络问题,别光靠真机瞎连,直接把 WebView 暴露给桌面 Chrome 调试器看。
手机开启 USB 调试,连接电脑后,在 Chrome 地址栏输入chrome://inspect,WebView 会出现在远程调试列表中。打开调试器后,你能看到完整的 Console 错误日志、Network 请求,还可以在 Console 里直接执行 JavaScript 验证环境变量和 WebSocket 状态。
如果 Chrome 调试器没检测到设备,可以先检查手机有没有开启“USB 调试”模式,以及设备驱动是否装好。实测下来,这是排查 WebView 网络问题最有效的工具,没有之一。
另一条路径是 Android 官方的 logcat。在终端执行:
adb logcat | grep -i "websocket"能抓到一些内核层和网络层的错误日志,和 Chrome 调试器互补。
实际操作时我一般两手抓:先用chrome://inspect看 JavaScript 层有没有报错,再用logcat确认系统层的明文流量和网络权限问题。这套组合能筛掉绝大多数干扰项。
5.4 固化一套排查顺序,避免重复踩坑
经过这次折腾,我后来在类似项目里都是按固定顺序排查:
- 查
AndroidManifest.xml有没有INTERNET权限和明文流量配置; - 用
chrome://inspect抓 JavaScript 报错,看是不是 CSP 或 WebSocket 地址问题; - 用
netstat之类的命令确认 WebSocket 服务监听在0.0.0.0; - 让手机和电脑连同一个 WiFi,验证 AP 隔离是否关闭;
- 用手机浏览器直接访问 HTTP 接口,确认网络链路本身是通的。
这套顺序的好处是:先排除环境问题,再查代码问题。如果上来就改业务代码,很可能一两小时过去才发现改的不是根因。
最后分享一个我后来固化的经验:Cordova 打包的 WebSocket 问题,九成是环境配置问题,而不是业务代码问题。遇到这类“PC 正常、手机异常”的情况,保持耐心,按权限、明文策略、地址监听、服务端校验的顺序逐层排查,比盲目改 Vue 代码高效得多。希望这篇记录能让你少走几小时弯路。