WebView 请求异常排查操作手册

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。

相关推荐
weedsfly1 小时前
JavaScript 事件流:彻底搞懂捕获、冒泡与事件委托
前端·javascript·react.js
RainmeoX1 小时前
【实战】用纯前端打造绝区零风格 AI 角色助手 WebUI 并联调 vLLM
前端
杨利杰YJlio1 小时前
OpenClaw / clawdbot 是什么?看懂 Agent 体系
前端·后端
CodeSheep2 小时前
他俩只靠写代码,登上了胡润财富榜!
前端·后端·程序员
IT_陈寒2 小时前
React状态更新总是慢半拍?你可能忘了这个默认行为
前端·人工智能·后端
Kapaseker2 小时前
学不动了,入门 Compose Styles API
android·kotlin
铁皮饭盒3 小时前
TypeBox 比 Zod.js 校验 快10倍, 还兼容AI 工具调用, 他做对了什么?
前端·javascript·后端
Bigger11 小时前
Tauri (26)——托盘图标总对不上系统主题?一行 Template Image 搞定
前端·rust·app