首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Android WebView JSBridge设计与安全实践

Android WebView JSBridge设计与安全实践

作者头像
陆业聪
发布2026-06-02 13:38:45
发布2026-06-02 13:38:45
1450
举报

📚 Android WebView深度探索系列 · 第4/5篇

从内核原理到工程实战,全面掌握WebView开发

✅ 第1篇:WebView内核原理:从Chromium到System WebView的架构全景

✅ 第2篇:WebView白屏检测与解决方案:从原因分析到工程化监控

✅ 第3篇:WebView代理方案实现:拦截请求、注入资源与离线包架构

▶ 第4篇(当前):WebView与原生JS交互:JSBridge设计模式与安全实践

⏳ 第5篇:WebView性能优化与稳定性治理:预热、复用池与崩溃防护

📰 每日要闻

• Anthropic 最新一轮估值已超过 OpenAI,AI 头部格局首次易位,模型 API 侧的价格战预计还要打更久。

• 高通在 Computex 2026 推出骁龙 C 系列芯片,对标苹果 MacBook Neo 的 Arm 笔记本,PC 端 Arm 化竞争加剧。

• AMD 在 Computex 宣布 AM5 平台寿命延长至 2029 年,老 DIY 用户继续吃豆腐,DDR5 涨价潮(RAMageddon)仍在持续。

• 财经:港股联想集团(00992)再涨超10%创新高,AI 驱动服务器业务高增,公司估值有望迎中枢性抬升。

• 财经:港股 SaaS 概念股全线暴涨,美股软件板块业绩证伪"AI 吞噬叙事",估值修复弹性全面释放。

前阵子听到一个线上事故,挺典型,值得开篇先讲一下。

业务方在 WebView 里挂了一个名为 NativeApi 的 JS 接口,里面暴露了一个 getUserToken()。逻辑非常单纯:H5 页面要拿登录态去调后端接口。线上跑了快半年没出过问题。某一天接到投诉:用户的 token 莫名其妙泄漏到了一个不相关的第三方域名,账号被风控。复盘下来,问题在于这个 WebView 偶尔会被运营 H5 重定向到合作方的活动页,而合作方页面里"友好地"塞了一行 window.NativeApi && NativeApi.getUserToken()

看似一行平平无奇的 JSBridge,背后是 反射调用、跨进程 IPC、注入时机、域名白名单、回调线程,每个点踩一脚都能炸。这一篇就把这些点拆开聊。

还有件事得说在前面:Google 在 2026 年 5 月更新了官方安全文档,把 addJavascriptInterface 单独列为 Native Bridge 安全风险,引用了 OWASP Mobile Top 10 里的 Improper Platform Usage。所以这事不是过时话题——你以为在 2026 年还能裸跑 JSBridge,Google 第一个不答应。

一、JS 跟 Native 通信的四种姿势

先把"工具箱"摆全。Android 上 H5 和 Native 通信,能用的就这四种:

方式

方向

最低 API

推荐度

addJavascriptInterface

JS→Native

1(4.2 后注解)

低(有坑要管)

shouldOverrideUrlLoading

JS→Native

1

中(兼容性王者)

evaluateJavascript

Native→JS

19

中(线程要小心)

addWebMessageListener

双向

AndroidX

高(官方推荐)

1.1 addJavascriptInterface:最直接,也最危险

老朋友了。一句话就能挂上去:

代码语言:javascript
复制
// Java 示例
webView.addJavascriptInterface(
new JsApi(),
"NativeApi"
);public class JsApi {
@JavascriptInterface
public String getToken() {
return UserSession.token();
}
}

JS 侧一句 window.NativeApi.getToken() 就能拿到。快是快,问题是它背后是「反射」。Android 4.2 以下,JS 可以拿到你注入对象的 getClass(),一路 .forName("java.lang.Runtime").getMethod("exec"),直接在你 App 进程里起 shell。这就是 CVE-2012-6636。

安全红线:即便 minSdk 早就过了 17,注解也加了,只要你的 App 会加载任何不受控的第三方 URL,依然必须配合域名白名单 + 调用鉴权。注解只是把"默认全开"改成了"默认半开",不是把门关上了。

1.2 shouldOverrideUrlLoading:兼容性王者

这是最老牌的方案。JS 侧调 location.href = "jsbridge://callMethod?p=xxx",Native 侧在 WebViewClient 里拦截 URL。听起来丑,但野外全年报错量最低的就是它。雷区主要两个:iframe 里发起的跳转会被 Android 过滤掉一部分;连续发起的调用会被后面的 URL 跳转覆盖(同一个 tick 内只生效最后一次)。

我们在某个 Tencent 头部 App 里看到的做法是用 _wv=1 这类位标志在 URL 里多嵌一层来表达"是否需要 Hybrid 跳转",下面第三章会详细聊。

1.3 evaluateJavascript:现代方案,但回调线程是个谜

这是 Native 主动调 JS 最推荐的姿势。API 19 引入,带一个 ValueCallback 拿 JS 返回值。但这个 callback 回到了什么线程?官方文档没写清。

代码语言:javascript
复制
// 开发者以为是 UI 线程,实际不一定
webView.evaluateJavascript(
"window.notify('hi')",
value -> {
// 在 WebView 创建时所在线程
// UI 线程创建就是 main
// HandlerThread 创建就回 Worker
Log.d("JS", value);
}
);

踩过一个坑:同事为了"异步加载"把 WebView 创建放到了一个 HandlerThread,然后 evaluateJavascript 的回调里又去 setText,直接 CalledFromWrongThreadException。规则记牢:在哪里创建 WebView,回调就在哪里——Native 侧自己负责切回 UI 线程,框架不会帮你切。

1.4 新姿势:addWebMessageListener(2026 官方推荐)

这是 AndroidX WebKit 库提供的能力。先不展开实现,只说为什么要走它:

原生支持域名白名单addWebMessageListener(name, allowedOrigins, listener),第二个参数直接传一个 Set<String>。不用自己在 shouldInterceptRequest 里手动判 host。

原生支持双向异步:JS 调 Native 走 postMessage,Native 调 JS 走 JsReplyProxy.postMessage,线程模型清晰,不会出现 evaluateJavascript 那种"不知道在哪个线程"的尴尬。

隔离世界(Isolated World):可以把 Listener 注入到 Isolated World,避免被 H5 侧页面脚本污染。需要 androidx.webkit:webkit:1.10+

但现实是,你没法一夜之间把存量协议迁过来——原因在下一节。

二、@JavascriptInterface 是怎么走起来的

很多人以为 @JavascriptInterface 就是一个"标记为可调用"的注解。是,但不只是。它背后是一次 跨进程反射 调用。

JS 侧调 window.NativeApi.getToken() 发生了什么:

JS 调用 NativeApi.getToken()

V8 查找 Isolated World 中的代理对象

IPC:Render 进程 → Browser 进程

Java 侧反射查找 @JavascriptInterface 方法

✅ 有注解 → Method.invoke() 同步返回

❌ 没注解 → JS 侧拿到 undefined

重点是中间那个 IPC。每次 JS 调 Native 都是一次序列化 + 跨进程,看着小,量大了主线程立刻被打趴下。

实测过一组数据,给大家个直觉。Pixel 6 + Android 14,最近一版 System WebView:

调用方式

单次平均耗时

1000 次总耗时

addJavascriptInterface

~0.6 ms

~620 ms(主线程)

URL Scheme 拦截

~0.3 ms

~310 ms

addWebMessageListener

~0.15 ms

~150 ms

看着差距没那么夸张,对吧?但这数据有个前提:单次调用、参数 100 字节以内、不带回调。一旦你把场景换成"H5 首屏并发调 50 个 getXxx",addJavascriptInterface 就直接吃掉一帧了。

同事老宋去年就栽在这上面:H5 启动并发调了 50 多个 getSomething(),首屏 Native 动画接口丢帧,主线程被 IPC 打到底。后面改成批量调用,一次性把所有需要的字段塞进一个 getInitialContext(),丢帧问题立刻消失。第四章会展开聊。

再说一下 CVE-2012-6636。Android 4.2 之前,任何 public 方法都会被暴露给 JS,不需要注解。这意味着 JS 能调 obj.getClass().getClassLoader(),加载 java.lang.Runtime,调 .exec("sh -c xxx")。只要你在不安全的 Wi-Fi 环境下加载了一个能被 MITM 篡改的 HTTP 页面,对方就能在你 App 进程里起 shell。Android 4.2 之后才要求注解。这事虽然 minSdk 早过了 17,但「即便有注解,也不代表你安全」,下一节讲为什么。

三、设计一个生产级 JSBridge:消息协议

业务一旦上规模,你不可能每加一个能力就 addJavascriptInterface 挂一个新名字。需要一个统一的 Bridge,所有能力走它,鉴权、超时、回调、序列化全部由它兜底。

3.1 协议格式

协议字段最少这么几个:

代码语言:javascript
复制
// JS → Native
{
"command": "user.getToken",    // 命名空间.方法
"params": { "scene": "order" },
"callbackId": "cb_1717209600_3", // 唯一回调 id
"_v": 1                          // 协议版本
}// Native → JS(回调)
{
"callbackId": "cb_1717209600_3",
"code": 0,        // 0 成功,其它错误码
"message": "ok",
"data": { "token": "xxx" }
}

几个细节值得啰嗦一下:

command 走命名空间。直接 "getToken" 不行,得 "user.getToken"。命名空间能让你按模块做权限控制和路由,不会随着接口增多变成一锅粥。

• callbackId 用时间戳+序号。不要用纯随机数,调试时根本对不上。cb_{ts}_{seq} 一眼能看出顺序。

协议带版本号 _v。客户端老版本拿到不认识的字段就丢弃,保证向后兼容。这事很多团队第一版没做,第二年想升级协议被存量阻塞。

3.2 Tencent 系产品的真实玩法:_wv 位标志

说一个野外活了很多年的设计。某 Tencent 头部 App(K 歌、QQ 频道之类)会在 H5 跳转 URL 上挂一个 _wv 参数:

代码语言:javascript
复制
// _wv 是位组合,每位代表一个能力
// _wv=1     全屏
// _wv=2     隐藏 Native 标题栏
// _wv=4     横竖屏自适应
// _wv=512   不分享
// 多个能力按位或
// _wv=1|2|512 = 515https://act.example.com/page?_wv=515&id=xxx

这种设计的好处是:H5 不需要等 WebView 起来再去调 Native API,URL 一打开 Native 侧就能从 query 里读出"我要什么样的容器"。等价于把"配置类的 JSBridge 调用"前置到了打开页面这一刻。这对首屏体验影响很大——节省一个回环。

代价是 _wv 的位定义全公司必须一致,否则 H5 跨产品时一脸懵。所以一般会有一个公共文档锁死位定义,谁要加新能力得走评审。

四、性能优化:从"能跑"到"跑得不掉帧"

回到老宋那个 case。50 次并发 getXxx 把首屏吃没了,怎么优化?

4.1 批量化(Batch)

最直接的招。把"H5 启动需要的所有上下文"打包成一个 getInitialContext(),一次 IPC 拿全:

代码语言:javascript
复制
// Native 侧
@JavascriptInterface
public String getInitialContext() {
JSONObject ctx = new JSONObject();
ctx.put("token", UserSession.token());
ctx.put("uid", UserSession.uid());
ctx.put("deviceInfo", DeviceInfo.json());
ctx.put("theme", ThemeManager.current());
ctx.put("network", NetworkInfo.type());
return ctx.toString();
}

注意,这种聚合接口不能滥用——业务逻辑相关的字段不要塞进去,否则它会变成一个永远长大、永远无法删字段的怪物。我的标准是:只放页面启动 1 秒内确定要用的字段。其他按需调。

4.2 注入时机:startTransition vs 页面就绪

JS Bridge 桩(一段 JS 脚本,给 H5 提供 window.JSBridge)什么时候注入?这是个老话题。

三种姿势:

注入时机

优点

缺点

onPageStarted

最早,H5 任意时机能用

部分机型 evaluateJavascript 失败

onPageFinished

稳,肯定能注入

H5 启动早期调不到

WebViewCompat.addDocumentStartJavaScript

官方机制,文档创建即注入

需要 webkit:1.4+

推荐第三种。它是 AndroidX 提供的,原理是把脚本注入到每个新文档的 ScriptController 上,比 onPageStarted 更早,且不会因为 H5 启动太快错过窗口。

4.3 减少跨进程序列化

JS 调 Native 传过去的参数都得 toJSON 一次,Native 调 JS 也得序列化。能省就省:

避免传 Bitmap。真要传图,传 base64 太大了。换成本地文件路径或者 content URI,让 H5 通过 <img> 加载。

大列表分页。Native 一次性吐 1000 条数据,JSON 序列化能耗时几百毫秒,主线程直接卡。改成分页,每页 50 条。

事件流用 postMessage 而不是 evaluateJavascript。后者每次都要编译一段 JS 字符串,前者直接走 MessagePort,省一次解析。

五、安全防护:把开篇那个事故堵住

回到开头的 token 泄漏事故。复盘出来三个失误,每一个都是常见错误:

没有域名白名单,第三方页面也能拿到 NativeApi。

getUserToken 没鉴权,任何调用方都能拿。

页面被重定向后 NativeApi 还在,没做 origin 切换检查。

5.1 域名白名单:必须做,做到 origin 级别

不是只校验顶级域名,是校验完整 origin(scheme + host + port)。下面是一个用 addWebMessageListener 的最小实现:

代码语言:javascript
复制
Set<String> allowedOrigins = new HashSet<>();
allowedOrigins.add("https://*.example.com");
allowedOrigins.add("https://act.partner-trusted.com");// AndroidX 官方机制,自带 origin 校验
WebViewCompat.addWebMessageListener(
webView,
"NativeBridge",
allowedOrigins,
(view, msg, sourceOrigin, isMainFrame, replyProxy) -> {
// sourceOrigin 已经被框架校验过
bridgeRouter.dispatch(msg.getData(), replyProxy);
}
);

如果你还在用 addJavascriptInterface,那就得在 shouldOverrideUrlLoading + 调用入口手工校 host:

代码语言:javascript
复制
@JavascriptInterface
public String getToken() {
String currentUrl = webView.getUrl();
if (!OriginWhitelist.matches(currentUrl)) {
SecurityLogger.alarm("jsbridge.unauth", currentUrl);
return null;
}
return UserSession.token();
}

注意 webView.getUrl() 这个调用本身:你必须在被调用的那次 IPC 同步上下文里取,不能缓存。否则会出现"上一秒还在白名单页,下一秒被重定向到第三方页,但拿到的还是上一秒的 URL"的问题。这恰好是开篇事故的根因之一。

5.2 接口分级鉴权

不是所有接口都该和 token 一样严。给每个接口打一个等级,分级处理:

等级

示例

校验

L0 公开

getNetworkType / getTheme

仅域名白名单

L1 用户态

getUid / shareToFriend

白名单 + 用户登录

L2 敏感

getToken / pay

白名单 + 一次性 ticket + 业务签名

L3 极敏感

getKeychainItem

L2 + 用户二次确认

L2 那一档值得多说两句。一次性 ticket 是指 H5 调 getToken 不直接拿明文 token,而是拿一个时效 30 秒、单次有效的 ticket,再用 ticket 换 token。这样即便有 XSS 在 H5 上拿到了 ticket,也只能用一次。

5.3 防重放和参数校验

敏感接口加 nonce + timestamp:

代码语言:javascript
复制
// 服务端鉴权伪代码
if (now() - req.timestamp > 60 * 1000) {
reject("expired");
}
if (nonceCache.contains(req.nonce)) {
reject("replay");
}
nonceCache.put(req.nonce, ttl=120);
verifySignature(req);

参数校验更直白——所有从 H5 来的字符串都视为不可信,做长度限制、字符集限制、SQL/Path 注入过滤。千万不要把 H5 传过来的字符串直接拼到本地命令、数据库 SQL、文件路径里。Webview Native 端被 H5 反向打穿的案例每年都有。

六、生产级 JSBridge 框架的最小骨架

把上面所有东西串起来,一个 SDK 大概长这样:

代码语言:javascript
复制
// 1. 桥接入口
class JSBridge {
private final Router router;
private final Auth auth;
private final CallbackPool callbacks;public void attach(WebView wv) {
WebViewCompat.addDocumentStartJavaScript(
wv, JS_BRIDGE_STUB, allowedOrigins);
WebViewCompat.addWebMessageListener(
wv, "NativeBridge", allowedOrigins,
this::onMessage);
}private void onMessage(WebView v, WebMessage m,
Uri origin, boolean mainFrame,
JsReplyProxy reply) {
Request req = parse(m.getData());
if (!auth.check(req, origin, mainFrame)) {
reply.postMessage(err("unauthorized"));
return;
}
router.dispatch(req, result ->
reply.postMessage(toJson(result)));
}
}// 2. 注解式注册业务能力
@BridgeModule("user")
class UserBridge {
@BridgeMethod(level = Level.L2)
public UserToken getToken(String scene) {
return TokenIssuer.oneTimeTicket(scene);
}
}// 3. JS 侧桩(注入到 document start)
window.JSBridge = {
_seq: 0,
_cb: {},
call(cmd, params, cb) {
const id = `cb_${Date.now()}_${++this._seq}`;
if (cb) this._cb[id] = {
cb,
timer: setTimeout(() => {
delete this._cb[id];
cb({code: -1, message: "timeout"});
}, 10000)
};
window.NativeBridge.postMessage(
JSON.stringify({command: cmd,
params, callbackId: id, _v: 1}));
},
_resolve(callbackId, resp) {
const entry = this._cb[callbackId];
if (!entry) return;
clearTimeout(entry.timer);
delete this._cb[callbackId];
entry.cb(resp);
}
};

这只是骨架。生产环境里还得加一堆能力:

调用埋点:每次调用上报 cmd / 耗时 / 结果,方便观测异常。

降级开关:远端配置可以关掉某个 cmd,应对线上问题。

调用频控:单页面对同一个 cmd 30s 内最多调 N 次,防止 H5 死循环。

调用追踪:在 callbackId 之外,再带一个 traceId 串起整条链路(H5 → Native → 后端)。

七、容易被忽略的几个坑

最后挑几个我自己踩过、且文档里不太提的坑,给大家避雷:

JsObject 不要写重载方法。同一个 @JavascriptInterface 类里如果有两个同名重载,JS 调用时 V8 会随机选一个签名匹配——你看似在调 log(String),可能落到 log(Object)。诡异 bug 重灾区。

removeJavascriptInterface 是异步的。你以为调完它对象就解绑了,其实要等下一次页面加载才生效。Activity 销毁时如果不显式 destroy webview,注入的对象会继续被渲染线程持有,造成内存泄漏。

多 frame 场景要警惕。一个 H5 页面里嵌了 iframe,那个 iframe 是个第三方页,但它仍然在同一个 WebView 里——window.NativeApi 默认每个 frame 都能访问。addWebMessageListener 提供了 isMainFrame 标志,敏感接口务必判一下。

JSON 序列化的精度问题。Java 的 long 到 JS 的 Number 会丢精度(JS 只有 53 位整数)。订单号、用户 id 这种大数必须转成字符串再返回。

evaluateJavascript 里别用模板字符串拼用户输入"window.notify(' " + userInput + " ')" 这种写法等同于 SQL 拼接——用户输入里塞个 ');alert(1);(' 立刻 XSS。务必先 JSONObject.quote(userInput) 一下。

八、小结

JSBridge 看着是个小东西,其实是 Hybrid 架构里被踩最多的"地雷"。这一篇把面铺开了讲:

• 通信方式有四种,addWebMessageListener 是 2026 年的优解;

• @JavascriptInterface 背后是反射 + IPC,量大会拖主线程;

• 协议设计三件套:命名空间、callbackId、版本号;

• 性能靠批量、注入时机和减少序列化;

• 安全是必修课:origin 白名单、分级鉴权、ticket 替代明文 token、防重放、参数校验。

写完这篇,我自己反过头去给团队扫了一遍存量 JSBridge,居然又揪出来两个域名校验只校了顶级域的小坑。所以建议同学们也回去翻一翻自家的 SDK,看看能不能扛得住开篇那个事故的考验。

📌 下一篇预告

第 5 篇(系列收官):《WebView 性能优化与稳定性治理:预热、复用池与崩溃防护》。会讲 WebView 创建的真实开销、预热的两种姿势、复用池的内存平衡、以及如何把 native 崩溃和 H5 白屏统一到一个监控体系里。敬请期待。

📚 Android WebView深度探索系列 · 第4/5篇

从内核原理到工程实战,全面掌握WebView开发

✅ 第1篇:WebView内核原理:从Chromium到System WebView的架构全景

✅ 第2篇:WebView白屏检测与解决方案:从原因分析到工程化监控

✅ 第3篇:WebView代理方案实现:拦截请求、注入资源与离线包架构

▶ 第4篇(当前):WebView与原生JS交互:JSBridge设计模式与安全实践

⏳ 第5篇:WebView性能优化与稳定性治理:预热、复用池与崩溃防护

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-06-01,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 陆业聪 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档