1. 问题模型
这类问题通常不是一句"客户端问题"或"服务端问题"能解释清楚。
一次 WebView 页面访问,通常会经过这些层:
text
Android App
-> WebView/Chromium 网络栈
-> 系统代理/Charles/网络环境
-> DNS/CDN 边缘节点
-> CDN 缓存/压缩变体
-> 源站/回源服务
-> 返回响应或重定向
-> WebView 执行跳转
如果某一层返回了异常重定向,例如:
text
https://cdn-a.example.com/path/page?x=xxx
-> 301 Location: http://origin.example.com/path/page/?x=xxx
Android WebView 可能会直接报:
text
net::ERR_CLEARTEXT_NOT_PERMITTED
这是因为 Android App 通常禁止明文 HTTP,WebView 在发出 HTTP 请求之前就拦截了。
即使服务器配置了 HTTP 自动跳 HTTPS,例如:
nginx
server {
listen 80;
server_name origin.example.com;
return 301 https://$http_host$request_uri;
}
这段配置也必须等 HTTP 请求真正到达服务器后才会生效。
如果 Android WebView 在本地明文策略阶段就拦截了 http://...,请求不会发到服务器,自然也就拿不到这个 301 Location: https://... 响应。
2. 基础概念
2.1 什么是 CDN
CDN 可以理解为一层分布在各地的"加速缓存网络"。
用户不直接访问源站机器,而是先访问离自己更近、网络更快的 CDN 节点:
text
用户设备
-> CDN 边缘节点
-> 源站服务
如果 CDN 节点上已经有这份资源,就直接返回给用户;如果没有,CDN 再去源站取。
2.2 什么是 CDN 边缘节点
CDN 边缘节点是用户真正连上的 CDN 服务器。
比如用户访问:
text
https://cdn-a.example.com/client/pages/module/page?uid=xxx
DNS 可能会把这个域名解析到某个离用户较近的边缘节点 IP。
这个边缘节点可能直接返回:
text
HTTP/2 200
也可能直接返回:
text
HTTP/2 301
Location: ...
如果响应头里有类似下面的信息,通常说明请求经过了 CDN 节点:
text
x-via: ... Cdn Cache Server ...
x-ws-request-id: ...
Age: ...
2.3 什么是源站/回源服务
源站就是 CDN 后面的真实业务服务或静态资源服务。
CDN 没有命中缓存,或者配置要求必须向源站确认时,会去源站拉取内容,这个过程叫"回源"。
text
用户设备
-> CDN 边缘节点
-> 源站/回源服务
<- 源站返回资源
<- CDN 返回给用户
2.4 什么时候会触发回源
常见触发回源的情况:
text
1. CDN 边缘节点没有缓存这条 URL
2. 缓存过期,需要向源站重新验证
3. 请求头导致命中不同缓存变体,例如 Accept-Encoding: br
4. CDN 配置要求某些路径不缓存
5. 运维主动刷新缓存后,下一次请求重新取源
注意:不是所有请求都会到源站。
如果 CDN 边缘节点自己就返回了缓存的 301/200,源站日志里可能完全查不到这次请求。
2.5 什么是缓存变体
同一个 URL,CDN 可能因为请求头不同,保存多份缓存。
例如:
text
同一个 URL + Accept-Encoding: gzip -> 一份缓存
同一个 URL + Accept-Encoding: br -> 另一份缓存
所以会出现这种现象:
text
普通 curl 不带 br:返回 200
WebView 带 br:返回旧 301
这不是客户端拼错 URL,而是 CDN 的某个缓存变体没有刷新干净。
3. 常见现象
3.1 App 报错,但浏览器正常
更准确地说:普通浏览器环境通常不会像 Android App WebView 一样受 App 的明文 HTTP 策略限制,所以它可能有机会继续走完后续跳转链路,或者被浏览器/服务端升级到 HTTPS。
而 Android WebView 一旦准备导航到:
text
http://origin.example.com/...
可能在真正发出 HTTP 请求前就被系统拦截:
text
net::ERR_CLEARTEXT_NOT_PERMITTED
因此不能简单认为"浏览器能打开,WebView 就一定能打开"。
3.2 Charles 抓不到网页请求
可能原因:
text
1. WebView 没有走 Charles 代理
2. Charles 没开 SSL Proxying
3. 请求被 WebView/Chromium 内部缓存或重定向缓存处理
4. Android 在发出 HTTP 请求前已经拦截
5. 你看的不是 WebView 请求,而是 App 原生 OkHttp 请求
3.3 运维说源站没有日志
可能原因:
text
1. 请求被 CDN 边缘节点直接返回,没有回源
2. Android 拦截了 HTTP,所以没有请求到源站
3. 运维查的是带 / 的 URL,但实际请求是不带 / 的 URL
4. 运维查的是普通缓存,但 WebView 命中的是 br/gzip 等缓存变体
4. URL 在客户端、前端、WebView、服务器之间如何流转
这一章专门解释一个常见疑问:
text
客户端请求了 A URL
前端又跳转到 B URL
B URL 被服务器 301 到 C URL
WebView 的各种回调分别会看到哪个 URL?
4.1 一个典型流转
假设 Android App 一开始加载的是课程页面:
text
URL A:
https://cdn-a.example.com/client/module/home/
页面加载完成后,前端 JS 根据业务状态执行跳转:
js
window.location.href = "https://cdn-a.example.com/client/pages/module/page?uid=xxx&sessionId=xxx"
这时产生了新的主框架导航:
text
URL B:
https://cdn-a.example.com/client/pages/module/page?uid=xxx&sessionId=xxx
服务器/CDN 收到 URL B 后返回:
text
HTTP/2 301
Location: http://origin.example.com/client/pages/module/page/?uid=xxx&sessionId=xxx
于是 WebView 准备继续导航到:
text
URL C:
http://origin.example.com/client/pages/module/page/?uid=xxx&sessionId=xxx
如果 App 禁止明文 HTTP,WebView 会在 URL C 发出前报错:
text
net::ERR_CLEARTEXT_NOT_PERMITTED
4.2 shouldOverrideUrlLoading 什么时候触发
shouldOverrideUrlLoading 通常在 WebView 准备加载一个新的主框架 URL 时触发。
在上面的例子里,可能会看到两次:
text
第一次:
shouldOverrideUrlLoading -> URL B
第二次:
shouldOverrideUrlLoading -> URL C
也就是说:
text
前端 window.location.href 跳转 URL B
-> WebView 触发 shouldOverrideUrlLoading(URL B)
-> WebView 请求 URL B
-> CDN/服务器返回 301 Location: URL C
-> WebView 触发 shouldOverrideUrlLoading(URL C)
如果 URL C 是 HTTP,而 App 禁止 HTTP,就会马上失败。
4.3 shouldInterceptRequest 什么时候触发
shouldInterceptRequest 会在 WebView 准备发起网络请求时触发,主文档、JS、CSS、图片、XHR 都可能触发。
对于主文档跳转,常见顺序是:
text
shouldOverrideUrlLoading(URL B)
shouldInterceptRequest(URL B)
服务器返回 301 Location: URL C
shouldOverrideUrlLoading(URL C)
onPageStarted(URL C)
onReceivedError(URL C, net::ERR_CLEARTEXT_NOT_PERMITTED)
注意:如果 URL C 是 HTTP 并被 Android 拦截,shouldInterceptRequest(URL C) 不一定会出现,因为请求可能还没真正发出。
4.4 onPageStarted 什么时候触发
onPageStarted 表示 WebView 已经开始一次页面导航。
如果重定向到了 URL C,日志里可能看到:
text
onPageStarted URL C
这不代表 URL C 一定已经成功请求到服务器,只表示 WebView 已经开始处理这个导航。
如果 URL C 被明文策略拦截,随后会看到:
text
onReceivedError URL C net::ERR_CLEARTEXT_NOT_PERMITTED
4.5 onReceivedError 里哪个 URL 最重要
onReceivedError 里通常有两个 URL 概念:
text
当前 WebView 页面 URL
失败的 failingUrl
示例:
text
当前页面:
https://cdn-a.example.com/client/module/home/
failingUrl:
http://origin.example.com/client/pages/module/page/?uid=xxx
排查时最关键的是 failingUrl,因为它才是真正失败的请求或导航目标。
4.6 DevTools Network 里怎么看重定向
WebView DevTools 的 Network 事件会把重定向记录在 redirectResponse 里。
你会看到类似:
json
{
"url": "http://origin.example.com/client/pages/module/page/?uid=xxx",
"redirect": {
"url": "https://cdn-a.example.com/client/pages/module/page?uid=xxx",
"status": 301,
"headers": {
"location": "http://origin.example.com/client/pages/module/page/?uid=xxx"
}
}
}
这表示:
text
请求 URL B 时,服务器返回 301;
301 的 Location 指向 URL C;
WebView 准备继续请求 URL C。
4.7 为什么运维查不到 URL C
如果 URL C 是 HTTP,Android WebView 可能在发出请求前就拦截。
所以:
text
URL B 的 301 响应可以在 CDN 日志里查到;
URL C 的源站日志可能查不到,因为请求根本没发出去。
排查时要让运维查:
text
原始请求 URL B
而不是只查:
text
重定向后的 URL C
4.8 一句话判断责任点
text
如果第一跳 URL B 就是错的:查前端/客户端拼 URL。
如果第一跳 URL B 是对的,但服务器返回 Location 到错误 URL C:查 CDN/服务端重定向。
如果 URL C 是 HTTP,Android 报 cleartext:这是 Android 安全策略暴露了前面的错误,不是 Android 主动生成了错误 URL。
5. 先确认设备和包名
5.1 查看设备
bash
adb devices -l
输出示例:
text
List of devices attached
device_serial_1 device usb:xx product:xxx model:AndroidPhone
device_serial_2 device usb:xx product:xxx model:AndroidTablet
如果有多台设备,后续命令都要带 -s <serial>。
示例:
bash
adb -s device_serial_1 shell getprop ro.product.model
5.2 查看当前前台 App
bash
adb -s device_serial_1 shell dumpsys window | grep -E 'mCurrentFocus|mFocusedApp'
输出示例:
text
mCurrentFocus=Window{xxx u0 com.example.app/com.example.TestActivity}
这里可以确认当前前台包名是:
text
com.example.app
5.3 查看相关包名
bash
adb -s device_serial_1 shell pm list packages | grep -i example
如果不知道包名,可以用:
bash
adb -s device_serial_1 shell pm list packages | grep -i class
adb -s device_serial_1 shell pm list packages | grep -i app
6. 判断是否有代理或 Charles
很多 WebView 请求异常会被 Charles、WiFi 代理、公司网关影响。
6.1 查看系统代理配置
bash
adb -s device_serial_1 shell 'settings get global http_proxy'
正常无代理时:
text
null
如果看到:
text
192.168.x.x:8888
说明设备可能正在走 Charles 或其他 HTTP 代理。
6.2 查看 Connectivity 里的代理和网络信息
bash
adb -s device_serial_1 shell 'dumpsys connectivity | grep -i -A2 -B2 "HttpProxy\|SSID\|DnsAddresses\|LinkAddresses" | head -100'
重点看:
text
SSID: "xxx"
HttpProxy: [192.168.x.x] 8888
DnsAddresses: [...]
如果 HttpProxy 有值,说明 WebView 很可能走代理。
6.3 查看设备 IP
bash
adb -s device_serial_1 shell 'ip route; ip addr show wlan0 | head -30'
示例:
text
192.168.2.0/24 dev wlan0 ...
inet 192.168.2.5/24 ...
7. 用设备自己的网络栈 curl 验证
电脑 curl 正常,不代表手机网络路径正常。要尽量在设备上执行 curl。
7.1 确认设备是否有 curl
bash
adb -s device_serial_1 shell 'command -v curl || true'
如果输出:
text
/system/bin/curl
说明设备可以直接 curl。
7.2 请求目标 URL
假设问题 URL 脱敏后是:
text
https://cdn-a.example.com/client/pages/module/page?uid=xxx&sessionId=xxx
执行:
bash
adb -s device_serial_1 shell 'curl -sS --max-time 15 -D - -o /dev/null "https://cdn-a.example.com/client/pages/module/page?uid=xxx&sessionId=xxx"'
参数解释:
text
-sS 安静模式,但错误仍输出
--max-time 15 最多等待 15 秒
-D - 把响应头打印到标准输出
-o /dev/null 不输出响应体,只看头
重点看:
text
HTTP/1.1 200 OK
HTTP/2 200
HTTP/2 301
Location: ...
X-Request-Id: ...
x-via: ...
x-ws-request-id: ...
Age: ...
7.3 区分带斜杠和不带斜杠
这非常重要。
目录型 URL 常见差异:
text
不带 /:
https://cdn-a.example.com/client/pages/module/page?x=xxx
带 /:
https://cdn-a.example.com/client/pages/module/page/?x=xxx
它们可能命中完全不同的缓存或重定向逻辑。
分别请求:
bash
adb -s device_serial_1 shell 'curl -sS --max-time 15 -D - -o /dev/null "https://cdn-a.example.com/client/pages/module/page?x=xxx"'
bash
adb -s device_serial_1 shell 'curl -sS --max-time 15 -D - -o /dev/null "https://cdn-a.example.com/client/pages/module/page/?x=xxx"'
如果不带 / 返回 301,带 / 返回 200,说明问题可能是"目录补斜杠重定向"。
8. 模拟 WebView 请求头
设备 curl 返回 200,但 WebView 仍然失败时,常见原因是 WebView 请求头不同。
WebView/Chrome 通常会带:
text
Accept-Encoding: gzip, deflate, br, zstd
User-Agent: Mozilla/5.0 (...) Android WebView/...
X-Requested-With: com.example.app
Sec-Fetch-*
Sec-CH-UA-*
8.1 模拟 WebView 的 User-Agent
bash
adb -s device_serial_1 shell 'curl -sS --max-time 15 -D - -o /dev/null \
-A "Mozilla/5.0 (Linux; Android 11; Device Build/xxx; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/150.0.0.0 Mobile Safari/537.36 App/1.0 Android/11" \
"https://cdn-a.example.com/client/pages/module/page?x=xxx"'
8.2 模拟 Accept-Encoding
重点测试 br:
bash
curl --http2 -sS --max-time 15 -D - -o /dev/null \
-H 'accept-encoding: br' \
'https://cdn-a.example.com/client/pages/module/page?x=xxx'
再测试普通 gzip:
bash
curl --http2 -sS --max-time 15 -D - -o /dev/null \
-H 'accept-encoding: gzip, deflate' \
'https://cdn-a.example.com/client/pages/module/page?x=xxx'
如果:
text
gzip 返回 200
br 返回 301
说明 CDN 可能按 Accept-Encoding 分了缓存变体,Brotli 变体没有刷新或规则不同。
8.3 模拟完整 WebView 头
bash
curl --http2 -sS --max-time 15 -D - -o /dev/null \
-H 'accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8' \
-H 'accept-encoding: gzip, deflate, br, zstd' \
-H 'accept-language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7' \
-H 'cache-control: no-cache' \
-H 'pragma: no-cache' \
-H 'sec-fetch-dest: document' \
-H 'sec-fetch-mode: navigate' \
-H 'sec-fetch-site: none' \
-H 'sec-fetch-user: ?1' \
-H 'upgrade-insecure-requests: 1' \
-H 'x-requested-with: com.example.app' \
-A 'Mozilla/5.0 (Linux; Android 11; Device Build/xxx; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/150.0.0.0 Mobile Safari/537.36 App/1.0 Android/11' \
'https://cdn-a.example.com/client/pages/module/page?x=xxx'
如果完整模拟后能复现 WebView 的 301,就能证明问题不在 App 代码,而在服务端/CDN 对特定请求头或缓存变体的处理。
9. 使用 ADB 调试 WebView 请求
Charles 抓不到时,最可靠的方法是直接用 WebView DevTools。
9.1 确认 WebView 调试端口
bash
adb -s device_serial_1 shell 'cat /proc/net/unix | grep -i webview_devtools | head -20'
输出示例:
text
@webview_devtools_remote_12345
其中 12345 通常是 App 进程 PID。
如果没有输出,可能是:
text
1. 当前页面不是 WebView
2. App 没开启 WebView 调试
3. WebView 进程未启动
9.2 转发 DevTools 端口
假设 socket 是:
text
webview_devtools_remote_12345
执行:
bash
adb -s device_serial_1 forward --remove tcp:9222
adb -s device_serial_1 forward tcp:9222 localabstract:webview_devtools_remote_12345
查看页面列表:
bash
curl -s http://127.0.0.1:9222/json/list
你会看到类似:
json
[
{
"title": "错误页面",
"url": "file:///android_asset/local_error.html?originalUrl=...",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/xxxx"
}
]
9.3 用 Chrome 打开可视化 DevTools
在电脑 Chrome 地址栏打开:
text
chrome://inspect/#devices
或者直接打开 json/list 里的 devtoolsFrontendUrl。
在 DevTools 里:
text
Network -> 勾选 Preserve log
Network -> 勾选 Disable cache
重新触发页面加载
查看 Document 请求和 Redirect
重点看:
text
Status Code
Location
Remote Address
Request Headers
Response Headers
Initiator
9.4 用脚本抓 WebView Network 事件
可视化 DevTools 有时不方便保存证据,可以用 Node 脚本直接抓。
先安装一个 WebSocket 客户端库:
bash
mkdir -p ~/tmp/cdp-tools
npm install --prefix ~/tmp/cdp-tools ws@8
然后保存脚本 webview-cdp-check.js:
js
const WebSocket = require('./node_modules/ws');
const http = require('http');
function getJson(url) {
return new Promise((resolve, reject) => {
http.get(url, res => {
let s = '';
res.on('data', d => s += d);
res.on('end', () => resolve(JSON.parse(s)));
}).on('error', reject);
});
}
(async () => {
const testUrl = process.argv[2];
if (!testUrl) {
console.error('Usage: node webview-cdp-check.js <url>');
process.exit(1);
}
const targets = await getJson('http://127.0.0.1:9222/json/list');
const target = targets.find(t => t.type === 'page') || targets[0];
console.log('Target:', {
id: target.id,
title: target.title,
url: target.url,
});
const ws = new WebSocket(target.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
const events = [];
function send(method, params = {}) {
return new Promise((resolve, reject) => {
const msg = { id: ++id, method, params };
pending.set(msg.id, { resolve, reject, method });
ws.send(JSON.stringify(msg));
});
}
ws.on('message', data => {
const m = JSON.parse(data.toString());
if (m.id && pending.has(m.id)) {
const p = pending.get(m.id);
pending.delete(m.id);
if (m.error) p.reject(new Error(JSON.stringify(m.error)));
else p.resolve(m.result);
return;
}
const p = m.params || {};
if (m.method === 'Network.requestWillBeSent') {
const url = p.request && p.request.url;
if (url && (url.includes('/client/pages/') || p.redirectResponse)) {
events.push({
method: m.method,
url,
type: p.type,
headers: p.request.headers,
redirect: p.redirectResponse && {
url: p.redirectResponse.url,
status: p.redirectResponse.status,
remoteIPAddress: p.redirectResponse.remoteIPAddress,
remotePort: p.redirectResponse.remotePort,
protocol: p.redirectResponse.protocol,
headers: p.redirectResponse.headers,
},
});
}
}
if (m.method === 'Network.responseReceived') {
const r = p.response;
if (r && r.url && r.url.includes('/client/pages/')) {
events.push({
method: m.method,
url: r.url,
status: r.status,
remoteIPAddress: r.remoteIPAddress,
remotePort: r.remotePort,
protocol: r.protocol,
fromDiskCache: r.fromDiskCache,
fromServiceWorker: r.fromServiceWorker,
headers: r.headers,
});
}
}
if (m.method === 'Network.loadingFailed') {
events.push({
method: m.method,
errorText: p.errorText,
blockedReason: p.blockedReason,
type: p.type,
});
}
if (m.method === 'Page.frameNavigated') {
events.push({
method: m.method,
url: p.frame && p.frame.url,
});
}
});
await new Promise((resolve, reject) => {
ws.once('open', resolve);
ws.once('error', reject);
});
await send('Network.enable');
await send('Page.enable');
await send('Network.setCacheDisabled', { cacheDisabled: true });
await send('Page.navigate', { url: testUrl });
await new Promise(r => setTimeout(r, 6000));
ws.close();
console.log(JSON.stringify(events, null, 2));
})().catch(e => {
console.error(e);
process.exit(1);
});
使用:
bash
cd ~/tmp/cdp-tools
node webview-cdp-check.js 'https://cdn-a.example.com/client/pages/module/page?x=xxx'
输出里重点看:
json
"status": 301,
"location": "http://origin.example.com/...",
"remoteIPAddress": "x.x.x.x",
"x-request-id": "...",
"x-via": "...",
"x-ws-request-id": "..."
这些就是给运维查 CDN 日志的关键证据。
10. 如何判断请求在哪里出问题
10.1 App 是否拼错 URL
看 WebView 第一跳:
text
requestWillBeSent:
https://cdn-a.example.com/client/pages/module/page?x=xxx
如果第一跳已经是错误 URL,比如:
text
http://origin.example.com/...
那更偏向前端或客户端拼接问题。
如果第一跳是正确 HTTPS,后面才出现错误 Location,那就是服务端/CDN 重定向问题。
10.2 是否是 301/302 重定向
看 DevTools 里的:
json
"redirect": {
"status": 301,
"headers": {
"location": "http://origin.example.com/..."
}
}
如果有这个,说明 URL 是服务端/CDN 返回的,不是 App 自己变出来的。
10.3 是否是 Android 明文拦截
错误:
text
net::ERR_CLEARTEXT_NOT_PERMITTED
表示 WebView 要访问 HTTP,但 App/系统不允许。
这时服务端的 HTTP -> HTTPS 301 可能来不及生效,因为 Android 在发出 HTTP 请求之前已经拦截。
例如服务端可能有这样的 nginx 配置:
nginx
server {
listen 80;
server_name origin.example.com;
return 301 https://$http_host$request_uri;
}
这只能处理"HTTP 请求已经打到服务器"的情况。
如果 WebView 因 Android 明文限制直接在本地失败,服务器 access log 里可能没有这条 HTTP 请求,运维侧的 301 规则也没有机会执行。
10.4 是否是代理问题
如果 DevTools 里看到:
json
"remoteIPAddress": "192.168.x.x",
"remotePort": 8888
通常说明 WebView 走了 Charles 或其他代理。
这时先关闭代理再测。
10.5 是否是 CDN 边缘缓存问题
如果响应头里有:
text
x-via: ... Cdn Cache Server ...
x-ws-request-id: ...
Age: ...
说明请求走了 CDN 边缘。
如果运维源站日志查不到,可能是 CDN 边缘直接返回了响应,未回源。
10.6 是否是压缩变体缓存问题
测试:
bash
curl --http2 -D - -o /dev/null \
-H 'accept-encoding: gzip, deflate' \
'https://cdn-a.example.com/client/pages/module/page?x=xxx'
bash
curl --http2 -D - -o /dev/null \
-H 'accept-encoding: br' \
'https://cdn-a.example.com/client/pages/module/page?x=xxx'
如果结果不同:
text
gzip -> 200
br -> 301
说明 CDN 对不同 Accept-Encoding 维护了不同缓存变体,其中一个变体没有刷新。
11. 清缓存操作
11.1 清业务 App
bash
adb -s device_serial_1 shell am force-stop com.example.app
adb -s device_serial_1 shell pm clear com.example.app
注意:pm clear 会清登录态。
11.2 查看当前 WebView Provider
bash
adb -s device_serial_1 shell dumpsys webviewupdate
输出示例:
text
Current WebView package: com.google.android.webview.beta
11.3 清 WebView Provider
bash
adb -s device_serial_1 shell am force-stop com.google.android.webview.beta
adb -s device_serial_1 shell pm clear com.google.android.webview.beta
如果 provider 是稳定版:
bash
adb -s device_serial_1 shell am force-stop com.google.android.webview
adb -s device_serial_1 shell pm clear com.google.android.webview
11.4 什么时候清缓存没用
如果你通过 DevTools 看到服务端/CDN 实时返回:
text
HTTP 301
Location: http://...
那清本地缓存通常没用,因为问题还在服务端/CDN 响应里。
12. 给运维的证据清单
遇到这类问题,不要只说"页面打不开"。给运维这些字段:
text
1. 请求时间,精确到秒
2. 原始请求 URL,注意带不带 /
3. 是否带 Accept-Encoding: br
4. HTTP 状态码
5. Location 响应头
6. x-request-id
7. x-via
8. x-ws-request-id
9. remoteIPAddress
10. WebView User-Agent
示例:
text
时间:2026-xx-xx 16:18:04
请求:
https://cdn-a.example.com/client/pages/module/page?uid=xxx&sessionId=xxx
请求头:
accept-encoding: gzip, deflate, br, zstd
user-agent: Android WebView ...
响应:
HTTP/2 301
location: http://origin.example.com/client/pages/module/page/?uid=xxx
x-request-id: request-id-xxx
x-via: cdn-node-a, cdn-node-b, cdn-node-c
x-ws-request-id: ws-request-id-xxx
remoteIPAddress: x.x.x.x
13. 给前端的建议
如果问题是目录补 / 触发的 301,前端最好直接生成带 / 的 URL。
不推荐:
text
/client/pages/module/page?uid=xxx
推荐:
text
/client/pages/module/page/?uid=xxx
原因:
text
1. 避免服务端补斜杠 301
2. 减少 CDN 重定向链路
3. 避免某些缓存变体返回旧 Location
4. Android WebView 对 HTTP Location 特别敏感
前端改这个属于防御性修复,不能替代运维修 CDN,但可以降低再次触发的概率。
14. 排查流程速查
第一步:看 App 第一跳
用日志或 DevTools 确认 WebView 第一跳 URL。
text
如果第一跳就是错的 -> 查前端/客户端拼 URL
如果第一跳是对的,后面跳错 -> 查 CDN/服务端重定向
第二步:设备 curl
bash
adb -s device_serial_1 shell 'curl -D - -o /dev/null "https://cdn-a.example.com/path/page?x=xxx"'
第三步:带 br curl
bash
curl --http2 -D - -o /dev/null \
-H 'accept-encoding: br' \
'https://cdn-a.example.com/path/page?x=xxx'
第四步:WebView DevTools
bash
adb -s device_serial_1 shell 'cat /proc/net/unix | grep webview_devtools'
adb -s device_serial_1 forward tcp:9222 localabstract:webview_devtools_remote_xxx
curl -s http://127.0.0.1:9222/json/list
然后用 Chrome DevTools 或 CDP 脚本看 Network。
第五步:给运维证据
一定给:
text
请求 URL
是否带 /
Accept-Encoding
Status
Location
x-request-id
x-via
x-ws-request-id
remoteIPAddress
15. 常见坑
坑 1:只刷了带 / 的 URL
实际请求:
text
/page?x=xxx
刷新的却是:
text
/page/?x=xxx
这是两条不同 URL,缓存可能完全不同。
坑 2:普通 curl 是 200,就以为 WebView 也正常
普通 curl 可能没有带:
text
Accept-Encoding: br
X-Requested-With
Sec-Fetch-*
WebView 可能命中不同缓存变体。
坑 3:查源站日志查不到
CDN 边缘直接返回的 301 不会回源。
要查 CDN 边缘日志,而不是只查源站日志。
坑 4:HTTP -> HTTPS 301 不能救 Android
Android WebView 禁止 HTTP 时,会在请求发出前拦截。
因此:
text
http://origin.example.com -> 301 -> https://origin.example.com
这条链路在 Android 上可能根本走不到。
服务端配置类似下面这样时也是同理:
nginx
server {
listen 80;
server_name origin.example.com;
return 301 https://$http_host$request_uri;
}
它的含义是"服务器收到 80 端口 HTTP 请求后,再返回 HTTPS 地址"。
但 Android WebView 的明文拦截发生在更前面:如果请求还没离开 App/WebView,服务器就不会收到请求,也就不会返回这个 301。
坑 5:Charles 抓不到就以为没请求
WebView DevTools 比 Charles 更接近 WebView 真相。
如果 Charles 和 DevTools 结果冲突,优先相信 DevTools。