这是 「Android WebView 深度探索」 系列的第 4 篇。前三篇分别聊了内核选型、白屏诊断、离线包代理,今天我们直击工程师每天都要面对、又最容易被做歪的环节------JS 与 Native 怎么打通,并且打通得既快又安全。
引子:那个在生产环境潜伏了三年的"幽灵接口"
先讲个真事。去年我们做安全自查,工具扫到一个 H5 页面调了一个叫 device.exec 的 Native 方法,传一段字符串进去就能拿到 IMEI、当前定位、剪贴板。
追代码追了一下午,结果是 2023 年某次大版本重构时,一个老接口被新协议覆盖了,但没删干净,新协议加了鉴权、白名单、防重放,老的那个 @JavascriptInterface 直接裸奔在 addJavascriptInterface 里------任何加载到这个 WebView 的页面,包括被劫持的第三方页面,都能调它。
这件事让我重新审视了一件事:JSBridge 不是"加个方法让 H5 调"那么简单。它是 H5 沙箱通往 Native 全权限世界的唯一一道门,门是怎么造的、谁有钥匙、来访者怎么验明正身------每一项做歪了,都是高危漏洞。
原理篇:三种通信方式,到底应该选哪个
JS 和 Native 通信,工程上能用的就这三种。我把它们拉到同一张表里对比:
| 方式 | 方向 | 序列化开销 | 主要风险 |
|---|---|---|---|
| addJavascriptInterface | JS → Native(直接) | 极低 | 反射攻击 / 接口暴露 |
| shouldOverrideUrlLoading | JS → Native(伪 URL) | 中(需 URL 编码) | 参数长度限制 / 队列丢失 |
| evaluateJavascript | Native → JS(回调) | 中 | 线程切换 / 注入时机 |
现代生产级 JSBridge 几乎都走组合拳 :调用方向 JS → Native 用 addJavascriptInterface(性能最好),回调方向 Native → JS 用 evaluateJavascript(API 19+ 支持回调)。shouldOverrideUrlLoading 现在主要是兜底------给老系统、WebView 内核异常时用。
@JavascriptInterface 的工作原理
很多人调了几年这个注解,但没真正搞懂它背后做了什么。简单讲:WebView 启动时,Chromium 在 V8 引擎里注入一个全局对象,这个对象的每个方法都被代理到 Java 反射调用上。注解的作用是告诉 WebView:"只有标了这个的方法才允许 JS 调"。
这里有个臭名昭著的历史包袱------Android 4.2(API 17)以下,没有这个注解保护 。任何挂上去的 Java 对象,所有 public 方法都对 JS 暴露,包括从 Object 继承下来的 getClass()。攻击者拿到 getClass().forName(...) 就能拿到 Runtime,进而执行 exec("rm -rf ...")------这就是 CVE-2012-6636。
2026 年了,4.2 以下的设备占比已经低到忽略,但是注解漏挂 这个低级错误每年还是会出现。我们 CI 里加了 lint 规则:所有放进 addJavascriptInterface 的类,public 方法必须 100% 有 @JavascriptInterface,否则 build 失败。这种事不能靠 review。
协议设计:JSBridge 的"七寸"在哪
协议设计是 JSBridge 最容易被低估的部分。我见过太多团队上来就堆 @JavascriptInterface------一个方法对应一个能力,几十个能力就几十个 Java 方法。三年后回看,全是技术债。
生产级协议必须满足这五个特性:
• 统一入口 :JS 侧只调一个方法 bridge.call(method, params, callback),所有命名空间在 params 里走。
• 消息 ID:每个调用带一个 callId,Native 异步执行完用 callId 找到 JS 端的 callback。
• 双向异步:JS 调 Native 异步,Native 主动 push 给 JS(事件订阅)也走同一套协议。
• 错误码标准化 :成功失败一律用 {code, message, data},code 0 成功,非 0 各档错误。
• 超时与降级:每次调用挂超时定时器,Native 没回就失败回调,避免 Promise 永远 pending 让 H5 业务卡死。
协议格式
json
// JS → Native 请求
{
"callId": "req_18a3",
"namespace": "device",
"method": "getInfo",
"params": { "fields": ["os", "version"] },
"timeout": 5000,
"sign": "a3f5b8c2..."
}
// Native → JS 回调
{
"callId": "req_18a3",
"code": 0,
"data": { "os": "Android", "version": "15" }
}
把命名空间从方法名里拆出来非常重要------一年后扩展第二批能力,老协议保持不变,只在 namespace 维度加。我们线上目前有 14 个命名空间、200 多个方法,统一入口让维护成本基本平的。
高性能实现:把序列化开销压到底
JSBridge 一秒钟可能被调几百次(想想滑动列表里曝光埋点)。性能这件事不能含糊。三个核心原则:
1. 别绕弯走 URL Scheme 。 老一代 JSBridge 用 iframe.src = "jsbridge://..." 触发 shouldOverrideUrlLoading,参数要 URL encode、还有 2KB 长度限制。@JavascriptInterface 走 V8 直通车,参数是字符串直接进 JNI------同样的调用,性能差一个数量级。
2. 序列化用 JSON 但收敛 。 别用 Gson 反射全库扫描,用 Moshi codegen 或者直接 org.json。我们做过基准:500 次/秒的调用频率下,Gson 占 12% CPU,Moshi codegen 占 3.4%,org.json 占 4.1%。
3. 线程切换最小化 。 @JavascriptInterface 默认在 WebView IO 线程,纯计算的方法(如读 SharedPreferences、查内存缓存)不要切线程,直接同步返回。需要切的(IO、网络、数据库)才丢到协程或线程池。
Native 侧分发器骨架
kotlin
class BridgeCore(
private val webView: WeakReference<WebView>,
private val registry: HandlerRegistry,
private val guard: SecurityGuard
) {
// JS 调入口(IO 线程)
@JavascriptInterface
fun call(payload: String) {
val req = BridgeReq.parse(payload)
?: return
if (!guard.verify(req)) {
reply(req.callId, ERR_AUTH)
return
}
val handler = registry.find(req.namespace, req.method)
?: return reply(req.callId, ERR_404)
when (handler.runOn) {
SAME_THREAD -> handler.invoke(req) { reply(req.callId, it) }
IO -> scope.launch(Dispatchers.IO) {
handler.invoke(req) { reply(req.callId, it) }
}
MAIN -> mainHandler.post {
handler.invoke(req) { reply(req.callId, it) }
}
}
}
private fun reply(callId: String, res: BridgeRes) {
val js = "window.__bridge.dispatch('${callId}', ${res.toJson()})"
mainHandler.post {
webView.get()?.evaluateJavascript(js, null)
}
}
}
注意 runOn 这个枚举:每个 handler 注册时声明自己适合在哪个线程跑------纯内存查表用 SAME_THREAD 省一次切换,要碰 UI 的(弹窗、相册)用 MAIN,剩下的丢 IO。显式声明而不是统一切,是优化 P95 延迟的关键。
安全防护:四道闸门一道都不能少
JSBridge 的安全是个系统工程,单点防护都会被绕过。我们生产环境实际跑的是这四道闸门叠加:
| 闸门 | 检查点 | 防的是什么 |
|---|---|---|
| 域名白名单 | 页面 URL 是否在 allowlist | 外部页面调内部接口 |
| 方法分级鉴权 | 敏感方法要用户态 token | 未登录态调隐私接口 |
| 参数签名 | HMAC + 时间戳 + nonce | 参数篡改 / 重放攻击 |
| 调用审计 | 敏感方法异步上报 | 事后取证 / 异常告警 |
域名白名单的正确做法
很多团队把域名校验写成 url.contains("xx.com")------这是新手陷阱 。攻击者只要构造 https://evil.com/xx.com 就过了。
正确的做法:拿到 webView.url 之后用 Uri.parse 解析出 host,然后精确匹配或后缀匹配(带点) :host == "xx.com" || host.endsWith(".xx.com")。同时,校验时机必须在 call() 入口、不是页面加载时------因为 SPA 路由切换不会重走加载。
参数签名怎么签
签名的目的不是防偷窥(HTTPS 已经做了),是防重放和篡改。我们的方案是:
签名串 = HMAC-SHA256(SECRET, namespace + method + params_json + timestamp + nonce)。SECRET 由 Native 在 H5 加载时通过 evaluateJavascript 注入到 H5 的内存里,不落本地存储、不落任何持久化,关掉 WebView 就丢。timestamp 校验 5 秒漂移,nonce 进 LRU 去重------一个 nonce 用过就拉黑。
这套方案没法做到 100% 防破解(攻击者把 H5 整个挖回去逆向也能拿到 SECRET),但能让普通的中间人 + 自动化扫描器吃瘪,这已经是工程上的合理权衡。
JS 注入时机:那个让你怀疑人生的 BUG
最后讲一个非常隐蔽的坑:Bridge 的 JS 端代码什么时候注入。
很多团队在 onPageFinished 里调 evaluateJavascript 注入 window.__bridge。这有两个问题:一是 onPageFinished 比 H5 业务代码晚执行,业务代码里的 window.__bridge.call(...) 会报 undefined;二是 SPA 内部路由跳转不触发这个回调。
正解是用 WebViewCompat.addDocumentStartJavaScript (API 25 起作为 AndroidX 1.5+ 的稳定 API)------这个方法会在每次新文档解析的最早期 注入脚本,比任何业务 JS 都早。老版本兼容上用 onPageStarted 兜底,足够覆盖 99% 设备。
less
if (WebViewFeature.isFeatureSupported(
WebViewFeature.DOCUMENT_START_SCRIPT)) {
WebViewCompat.addDocumentStartJavaScript(
webView,
readAsset("bridge_runtime.js"),
setOf("https://*.your-domain.com")
)
}
注意第三个参数------限定注入的源 。如果不传或传 "*",等于把 bridge runtime 注给所有页面(包括 about:blank、第三方跳转),相当于第一道闸门白做了。
从零写一个 SDK:那些方法签名不会告诉你的事
如果你打算把上面这些拼成一个能在公司内推广的 SDK,下面这几条经验可能比代码更值钱:
• 把 SDK 切成核心 + 能力包 。核心只做协议解析、调度、安全;具体 API(拍照、定位、支付)通过注解处理器自动注册。这样业务接入只需要写自己的 Handler 类,不改核心。
• 给 H5 同学一个"能跑的 Mock" 。Web 端开发不能等你 App 包,所以 SDK 一定要附带 window.__bridge.mock 模式,所有方法本地返回假数据,供前端 dev 模式联调。
• 方法治理上协议先行 。新增能力先写 schema(OpenAPI 风格),评审过了再写代码。我们用一个 bridge.yaml 描述所有方法,CI 里跑一致性校验,Native 实现和 H5 类型定义全自动生成。
• 埋好可观测性。每次调用打:方法名、耗时、是否成功、错误码、来源 URL。线上 P95 飙了能 5 分钟定位是哪个方法、哪个域名、哪个版本。
• 预留逃生舱。SDK 配一个远程开关,敏感方法可以一键全网下线。出了 0day 漏洞不用等 App 发版。
收尾
回到开头那个潜伏三年的"幽灵接口"------上线时它通过了所有评审,因为没人去检查"老接口是不是还在 addJavascriptInterface 里"。这件事的根本教训是:JSBridge 是基础设施,不能靠人来守。
本系列下一篇我们聊性能与稳定性------预热、复用池、崩溃防护。把 JSBridge 这扇门修好之后,整个 WebView 还有一战要打:怎么让它不卡、不崩、不漏内存。
下一篇将继续《Android WebView 深度探索》系列。