场景:纯 HTTP 提取遇到 JavaScript 渲染或 JS 挑战的页面时,需要一个浏览器内核兜底。本文讨论如何在移动端用 WebView 实现这个兜底,包括为什么不选现成框架、JS 桥怎么设计、跨层回传怎么处理,以及打包链路。
目录
为什么不选现成框架
自写壳的整体结构
WebView 与外部页面的交互难点
JS 桥的设计
跨层回传链路
打包链路
几个踩坑
通用教训
1. 为什么不选现成框架
移动端 WebView 应用常见的现成框架有 wails3、Capacitor、Cordova 等。它们能快速搭出一个"壳 + 前端页面"的应用,但在需要让 WebView 加载外部页面并与其交互时,会遇到几个绕不开的限制。
以 wails3 为例,实测遇到的三个问题:
| 问题 | 表现 | 影响 |
|---|---|---|
WebView 的setURL是 no-op | 调用后页面不跳转 | 无法加载外部页面 |
window.go绑定只注入框架自己的页面 | 外部页面里没有这个对象 | 外部页面无法回调宿主 |
混合内容策略固定为NEVER_ALLOW | HTTP 资源在 HTTPS 页面里被拦截 | 部分外部页面加载失败 |
前两条是致命的。兜底方案的核心就是“让 WebView 加载外部页面,并把渲染后的内容传回来”。现成框架的设计前提是“壳只加载自己的前端”,不覆盖这个场景。
所以选择自写壳:直接用 Android 的WebView和addJavascriptInterface,代码量不大,但控制权完整。
2. 自写壳的整体结构
自写壳由四部分组成:
text
Android 壳(Java) ├── 内嵌后端(Go 二进制,assets 里) ├── WebView(加载本地 UI + 外部页面) ├── JS 桥(window.hc,暴露给页面) └── 回传逻辑(把外部页面内容 POST 回后端)
启动流程:
text
1. 从 assets 拷贝对应 ABI 的 Go 二进制到私有目录 2. 拉起二进制,监听 127.0.0.1:<port> 3. 轮询等待后端就绪 4. WebView 加载 http://127.0.0.1:<port>/ui
本地 UI 页面负责日常交互。当用户输入一个需要兜底的 URL 时,前端通过 JS 桥通知壳层,壳层切换 WebView 到外部页面。
3. WebView 与外部页面的交互难点
3.1 加载外部页面后,本地页面就不在了
WebView 是单实例的。加载外部页面意味着当前本地 UI 被替换。所以交互设计要区分两个阶段:
text
阶段一:本地 UI 页面(有 window.hc 桥) → 用户输入 URL → 调用 hc.sniffRender(url) 阶段二:外部页面(没有 window.hc 桥) → 页面自己的 JS 执行,内容渲染 → 壳层注入轮询脚本,检测渲染完成 → 拿 outerHTML,切回本地 UI
关键点:JS 桥只在本地 UI 页面里存在。外部页面里没有window.hc,不能指望外部页面主动回调。回传必须由壳层主动发起。
3.2 如何判断外部页面渲染完成
外部页面有三种情况:
服务端渲染:加载完成即有内容;
SPA:加载完成后 JS 还在渲染,需要等;
JS 挑战:先执行挑战脚本,通过后才渲染内容。
判断方法:在onPageFinished里注入一段轮询脚本,周期性检查目标元素是否出现:
javascript
(function poll() { var el = document.querySelector('<目标容器选择器>'); if (el && el.children.length > 0) { window.hcRenderDone(document.documentElement.outerHTML, location.href); return; } if (Date.now() - start > 15000) { window.hcRenderDone(null, location.href); // 超时 return; } setTimeout(poll, 500); })();超时上限设 15 秒左右。再长用户等不了,也太浪费。
3.3 混合内容问题
本地 UI 是http://127.0.0.1,外部页面可能是https://。WebView 默认会拦截混合内容。
两个处理方向:
允许混合内容:
settings.setMixedContentMode(MIXED_CONTENT_COMPATIBILITY_MODE),但这是全局设置,不够精细;让外部页面的回传不走 WebView 的 fetch,而是走 JS 桥或 Java 网络栈。
推荐第二条。回传的内容可能很大(渲染后的 HTML 几十到几百 KB),走 JS 桥传字符串是最直接的方式。
4. JS 桥的设计
4.1 两个方向的调用
JS 桥需要支持两个方向:
| 方向 | 用途 | 实现 |
|---|---|---|
| JS → Java | 本地 UI 触发兜底、外部页面回传结果 | addJavascriptInterface |
| Java → JS | 壳层注入轮询脚本、切回本地 UI | evaluateJavascript |
4.2 本地 UI 侧接口
java
@JavascriptInterface public void sniffRender(String url) { // 切 WebView 到外部页面 runOnUiThread(() -> webView.loadUrl(url)); }4.3 外部页面回传接口
java
@JavascriptInterface public void renderDone(String html, String url) { // 拿到渲染后的 HTML,POST 回后端 new Thread(() -> { postToBackend("/api/sniff/render", html, url); runOnUiThread(() -> webView.loadUrl(localUiUrl)); }).start(); }4.4 命名空间
addJavascriptInterface注入的对象挂在window下的一个命名空间里,比如window.hc。本地 UI 页面里可以调用hc.sniffRender(...)。
外部页面也会看到这个对象。但外部页面不知道它存在,不会主动调用。注入的轮询脚本里调用window.hcRenderDone(...)是壳层自己注入的,不属于外部页面。
4.5 线程问题
@JavascriptInterface方法运行在 WebView 的 JavaBridge 线程,不是主线程。所有 UI 操作必须runOnUiThread包一层。网络请求也要另起线程,不能阻塞 JavaBridge。
5. 跨层回传链路
完整回传链路:
text
1. 前端(本地 UI)调用 hc.sniffRender(url) 2. 壳层切 WebView 到外部页面 3. 外部页面加载,JS 执行,内容渲染 4. 壳层注入轮询脚本,检测到目标元素出现 5. 脚本调用 window.hcRenderDone(outerHTML, location.href) 6. 桥接方法接收,Java 网络栈 POST 到 http://127.0.0.1:<port>/api/sniff/render 7. 后端提取器解析 HTML,存结果 8. 壳层切回本地 UI 9. 本地 UI 请求 /api/sniff/result,显示提取结果
5.1 为什么回传用 Java 网络栈
不走 WebView 的 fetch,而是用 Java 的HttpURLConnection:
不受 WebView 混合内容限制;
不受页面 CSP 限制;
请求与 WebView 解耦,页面切走也不影响。
代价是要处理一遍网络错误、超时、重试。但这部分逻辑比在 WebView 里绕各种限制简单得多。
5.2 后端接口
text
GET /api/sniff?url=<详情页URL> → 直接提取(可能返回 need_browser) POST /api/sniff/render Body: { html, url } → 从渲染后的 HTML 提取,存结果 GET /api/sniff/result → 读结果(读后清空)三个接口的分工:
GET /api/sniff:轻量路径,纯 HTTP 能解决时走这条;POST /api/sniff/render:兜底路径,浏览器渲染后回传;GET /api/sniff/result:本地 UI 拉取结果。
result接口读后清空,避免下次打开时显示上一次的旧数据。
5.3 结果状态
后端需要区分三种状态:
| 状态 | 含义 |
|---|---|
| 成功 | 提取到列表数据 |
| need_browser | 需要浏览器兜底 |
| 失败 | 提取失败(URL 错误、页面结构完全不符) |
本地 UI 根据状态决定下一步:成功直接显示;need_browser 触发hc.sniffRender;失败提示用户。
6. 打包链路
不用 Gradle,直接用 Android SDK 的工具链:
text
1. Go 交叉编译 GOOS=android GOARCH=arm64 go build -o hcrelay-arm64 GOOS=android GOARCH=arm go build -o hcrelay-arm 2. aapt2 编译资源 + 链接 aapt2 compile --dir res -o res.zip aapt2 link -o base.apk -I android.jar --manifest AndroidManifest.xml res.zip 3. javac 编译 Java javac -source 8 -target 8 -cp android.jar -d classes/ *.java 4. d8 转 dex d8 --output dex/ classes/**/*.class 5. 合并 APK zip -j base.apk dex/classes.dex zip -j base.apk assets/hcrelay-arm64 assets/hcrelay-arm 6. 对齐 + 签名 zipalign -f -p 4 base.apk aligned.apk apksigner sign --ks keystore.jks --out signed.apk aligned.apk
6.1 双 ABI
同时打 arm64 和 arm32 两个 Go 二进制,放进assets/下的对应子目录。启动时根据Build.SUPPORTED_ABIS选择。
好处是一个 APK 覆盖两种设备;代价是 APK 变大。实测下来双 ABI 的 APK 约 8 MB,比 wails3 的单 ABI 19 MB 小得多,因为 Go 二进制本身比框架运行时小。
6.2 权限
xml
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
INTERNET是网络请求必须;CHANGE_WIFI_MULTICAST_STATE用于 SSDP 发现(如果壳需要发现局域网设备)。
还有android:usesCleartextTraffic="true",允许 HTTP 明文,因为本地后端是http://127.0.0.1。
7. 几个踩坑
7.1 内部类里的 this
HcBridge作为MainActivity的内部类,里面对this的引用指向HcBridge而不是MainActivity。
java
// 报错:Toast.makeText(this, ...) —— this 是 HcBridge Toast.makeText(this, "xxx", Toast.LENGTH_SHORT).show(); // 正确 Toast.makeText(MainActivity.this, "xxx", Toast.LENGTH_SHORT).show();
7.2 切回本地 UI 的时机
renderDone收到后立即切回本地 UI 会让用户看到画面闪烁。更平滑的做法是:
先 POST 到后端(异步);
后端返回成功;
再切回本地 UI。
这样用户看到的是"外部页面 → 本地 UI → 结果出现",中间没有加载等待。
7.3 进程被杀后重启
Android 可能随时回收进程。壳层需要处理:
后端进程是否还在;
WebView 当前页面状态;
用户操作到一半被打断的情况。
简单做法是每次启动都重新拉起后端,WebView 加载本地 UI 初始页。复杂状态不做持久化,避免启动时状态不一致。
8. 通用教训
现成框架不覆盖外部页面场景。选择框架前先确认"能不能加载第三方页面并与之交互",不能就自写壳。
JS 桥只在本地页面里。外部页面不能主动回调,回传必须由壳层注入脚本发起。
提取逻辑保持单一来源。浏览器只负责渲染和回传 HTML,解析仍在后端提取器里。避免两处逻辑不一致。
回传走 Java 网络栈。不受 WebView 混合内容和 CSP 限制。
打包不用 Gradle 也能做。aapt2 + javac + d8 + zipalign + apksigner 五步,一条 shell 脚本搞定。
双 ABI 覆盖两种设备。Go 二进制交叉编译两个架构,APK 体积增加不多。
本文基于实际移动端 WebView 兜底方案开发过程整理。不同 Android 版本、WebView 实现和打包工具链下细节可能不同,请以实际环境为准。