欢迎光临
我们一直在努力

WebView与原生JS交互:JSBridge设计模式与安全实践

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

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

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

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

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

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

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

有一天我在排查一个线上 bug——H5 页面点了按钮没反应,原生客户端也没收到任何回调。抓包也没问题,JS 没报错,Log 里一片祥和。

最后定位的原因是:addJavascriptInterface 注入的对象在低版本系统上被回收了,而 JS 代码还在持有这个引用往上调。

那一刻我才真正意识到,JSBridge 这东西看起来简单,实际上坑得很深。这篇文章就来把这块彻底说清楚。

一、三种 JS-Native 通信方式

先把几种方式摆出来对比,再逐个深入讲。

方式方向核心原理主要缺陷
addJavascriptInterface JS→Native 向JS环境注入Java对象 4.2以下安全漏洞
shouldOverrideUrlLoading JS→Native 拦截特定URL scheme 回调异步难,并发丢失
evaluateJavascript Native→JS 主线程执行JS字符串 必须主线程,API 19+

1.1 addJavascriptInterface 的工作原理

这个 API 做的事其实很直接:把一个 Java 对象注入到 WebView 的 JS 全局作用域里,JS 就可以直接通过对象名调用它的方法。

// Native 侧:注入对象
webView.addJavascriptInterface(
NativeBridge(),
"NativeBridge"
)

// NativeBridge 定义
class NativeBridge {
@JavascriptInterface
fun showToast(msg: String) {
Toast.makeText(
ctx, msg,
Toast.LENGTH_SHORT
).show()
}
}

// JS 侧:直接调用
NativeBridge.showToast("Hello")

有几个细节必须知道:

• @JavascriptInterface 注解是 API 17 加的——没有这个注解的方法,在 API 17+ 的设备上 JS 根本调不到。这是历史上那个远程代码执行漏洞(CVE-2012-6636)修复方案的一部分。

• JS 调用 Native 方法时,是在 WebView 的 JS 线程上执行的,不是主线程。所以在 Bridge 方法里操作 UI,必须切回主线程。

• 注入的 Java 对象有一个生命周期问题:WebView 持有的是弱引用。如果你把匿名内部类或 Lambda 传进去,GC 可能把它回收掉。

4.2 以下安全漏洞:addJavascriptInterface 在 Android 4.2(API 17)之前没有 @JavascriptInterface 限制,恶意 JS 可通过 getClass().getMethod() 拿到注入对象的全部方法,进而反射调用 Runtime.exec 执行任意命令。目前 4.2 以下基本可以不管,但如果你的 app 还要适配,严禁注入过度封装的对象。

1.2 URL Scheme 拦截

原理是让 JS 通过 location.href 或 iframe 触发一次特定格式的 URL 跳转,Native 侧在 shouldOverrideUrlLoading 里拦截并解析。

// JS 侧,使用 iframe 触发
function callNative(action, params) {
const iframe =
document.createElement(
'iframe'
);
const url =
`myapp://bridge/${action}?`
+ JSON.stringify(params);
iframe.src = url;
iframe.style.display = 'none';
document.body.appendChild(iframe);
setTimeout(() => {
document.body.removeChild(
iframe
);
}, 300);
}

这个方案有几个硬伤:

• 多个 JS 调用快速触发时,WebView 会把 URL 变更合并,导致调用丢失

• URL 长度有限制,传大参数会被截断

• 无法直接同步返回值给 JS

所以 URL Scheme 方案现在基本只作为降级兼容手段,生产项目里很少单独使用。

1.3 evaluateJavascript

这个是 Native→JS 的主要方式,API 19(4.4)引入。

// 必须在主线程调用
webView.evaluateJavascript(
"window.onNativeCallback(" +
"'$data')"
) { result ->
// result 是 JS 执行的返回值
// 在主线程回调
Log.d(
"Bridge",
"JS returned: $result"
)
}

注意事项:必须在主线程调用,否则会直接 crash。如果你在 Bridge 的回调线程里拿到 Native 数据想立刻回传给 JS,一定要用 Handler.post 或 runOnUiThread 切线程。

二、生产级 JSBridge 框架设计

说完基础 API,来讲真正值得参考的架构设计。我见过很多项目的 JSBridge 写得像一团乱麻——所有 handler 塞在一个大 switch-case 里,回调用全局 Map 管,超时没人处理,安全校验全靠注释里的"TODO"。

一个生产可用的 JSBridge 至少要解决这几个问题:消息协议、回调管理、异步调用、超时处理。

2.1 消息协议设计

先定一个统一的 JSON 消息格式,双端都遵守:

// 请求协议
{
"callId": "uuid-xxx",
"action": "getUserInfo",
"params": { … },
"timestamp": 1717040000000
}

// 响应协议
{
"callId": "uuid-xxx",
"code": 0,
// 0=成功,其他=错误码
"data": { … },
"msg": "success"
}

callId 是关键——它把请求和响应关联起来,支持多个并发调用同时在途而不串号。

2.2 回调管理与超时处理

class CallbackManager {

private val callbacks =
ConcurrentHashMap<
String,
CallbackEntry
>()

private val handler =
Handler(Looper.getMainLooper())

fun register(
callId: String,
callback: BridgeCallback,
timeoutMs: Long = 10_000L
) {
val entry = CallbackEntry(
callback,
System.currentTimeMillis()
)
callbacks[callId] = entry

// 超时自动清理
handler.postDelayed({
val cb =
callbacks.remove(callId)
cb?.callback.onError(
-1, "timeout"
)
}, timeoutMs)
}

fun dispatch(
callId: String,
resp: BridgeResponse
) {
val entry =
callbacks.remove(callId)
?: return
if (resp.code == 0) {
entry.callback.onSuccess(
resp.data
)
} else {
entry.callback.onError(
resp.code, resp.msg
)
}
}
}

这里用 ConcurrentHashMap 是因为 addJavascriptInterface 的调用发生在 JS 线程,dispatch 也可能在其他线程触发,要保证线程安全。

2.3 Handler 注册与分发

用注解驱动的 Handler 注册,比手写 switch-case 优雅多了:

// 定义注解
@Retention(
AnnotationRetention.RUNTIME
)
@Target(
AnnotationTarget.FUNCTION
)
annotation class BridgeHandler(
val action: String
)

// Handler 实现
class UserBridgeHandler {

@BridgeHandler("getUserInfo")
fun getUserInfo(
params: JSONObject,
callback: BridgeCallback
) {
val user =
userRepo.getCurrentUser()
callback.onSuccess(
user.toJson()
)
}

@BridgeHandler("logout")
fun logout(
params: JSONObject,
callback: BridgeCallback
) {
// … 处理逻辑
}
}

启动时通过反射扫描所有带 @BridgeHandler 注解的方法,建立 action→method 的映射表。运行时按 action 分发,代码清晰,新增 handler 只需加一个方法。

三、JS注入时机与常见的坑

很多人踩过这个坑:往 WebView 里注入初始化 JS,但 H5 页面加载完后发现这段代码根本没执行,或者执行了但方法调不到。

3.1 注入时机

WebView 加载流程中有几个关键时机:

URL 开始加载

onPageStarted

此时注入:DOM 未就绪,JS 方法可能找不到

onPageFinished

推荐注入时机:DOM 已就绪,可以安全执行 JS

onLoadResource(多次)

不要在此注入:会多次触发,重复执行副作用

3.2 注入 vs 页面加载的竞态

还有一个坑:如果 H5 页面加载很快(本地 assets 或缓存命中),onPageFinished 会在 Native 还没调 addJavascriptInterface 之前就触发——这种情况下 JS 调 Bridge 会找不到对象。

解法:在 WebView 创建时就立刻调用 addJavascriptInterface,不要等页面加载。这个 API 是 WebView 级别的,不是页面级别的,加载任何页面都会有效。

// 正确:创建时就注入
val webView = WebView(context)
webView.addJavascriptInterface(
bridge, "NativeBridge"
)
// 然后再 loadUrl
webView.loadUrl(url)

// 错误:在 onPageStarted 里注入
override fun onPageStarted(…) {
webView.addJavascriptInterface(
bridge, "NativeBridge"
) // 有竞态风险
}

四、安全防护:不能省的几道关卡

JSBridge 安全问题在混合 app 里被严重低估了。H5 业务代码往往来自多个团队甚至第三方,如果 Bridge 不做好防护,等于给了外部代码调用 Native 能力的通道。

4.1 域名白名单校验

最基础的一道防线——只允许白名单内的域名调 Bridge:

class SecureBridge(
private val webView: WebView,
private val allowedHosts:
Set<String>
) {

private fun isUrlTrusted(): Boolean {
val currentUrl =
webView.url ?: return false
return try {
val host = URI(currentUrl).host
allowedHosts.any { allowed ->
host.endsWith(allowed)
}
} catch (e: Exception) {
false
}
}

@JavascriptInterface
fun invoke(json: String) {
if (!isUrlTrusted()) {
Log.w(
"Bridge",
"Untrusted caller blocked"
)
return
}
// 正常处理
dispatch(json)
}
}

4.2 接口权限分级

不是所有 Bridge 接口都需要同等级别的信任。可以把接口分成三级:

• 公开级:任何域名都能调,比如获取设备信息、上报埋点

• 业务级:需要在白名单域名内,比如打开 Native 页面、调用支付

• 敏感级:需要额外 token 鉴权,比如获取用户敏感信息、调用危险能力

enum class BridgeLevel {
PUBLIC, // 公开
BUSINESS, // 业务(域名校验)
SENSITIVE // 敏感(需Token)
}

@BridgeHandler(
action = "getDeviceId",
level = BridgeLevel.PUBLIC
)
fun getDeviceId(…) { … }

@BridgeHandler(
action = "getUserToken",
level = BridgeLevel.SENSITIVE
)
fun getUserToken(…) { … }

4.3 参数校验与防重放

所有从 JS 传来的参数,一律不要相信。特别是涉及 ID、金额、操作类型的字段,必须在 Native 侧做二次校验,不能只靠 JS 层的约束。

防重放攻击也值得考虑:请求里携带 timestamp,Native 侧判断时间窗口(比如只接受前后 30 秒内的请求),可以防止截获并重放的攻击。对于敏感操作,还可以加一次性 nonce。

五、高性能 JSBridge:减少序列化开销

做了几个项目之后,我发现 JSBridge 的性能问题主要集中在两个地方:JSON 序列化/反序列化的开销,以及线程切换的成本。

5.1 批量消息合并

H5 页面初始化时可能短时间内触发大量 Bridge 调用(初始化配置、获取用户信息、埋点上报……)。与其每次都单独一个 evaluateJavascript,可以引入一个消息队列,将短时间内积累的多条消息合并成一次 JS 执行:

class BatchJsDispatcher(
private val webView: WebView
) {
private val pendingMsgs =
ArrayDeque<String>()
private val handler =
Handler(
Looper.getMainLooper()
)
private var scheduled =
false

fun post(jsCode: String) {
pendingMsgs.add(jsCode)
if (!scheduled) {
scheduled = true
handler.post { flush() }
}
}

private fun flush() {
scheduled = false
if (pendingMsgs.isEmpty())
return
val batch =
pendingMsgs.joinToString(
separator = ";"
)
pendingMsgs.clear()
webView.evaluateJavascript(
batch, null
)
}
}

5.2 避免大对象序列化

如果需要传大量数据(比如把一个列表数据下发给 H5),不要把整个对象全部塞进 Bridge 消息里。更好的做法是 Native 侧把数据存到 WebView 可访问的 localStorage 或注入一个 lazy getter,H5 按需读取。

六、从零实现一个生产级 JSBridge SDK

说了这么多理论,来看一个完整的 SDK 骨架,把前面所有点串起来。

6.1 完整 SDK 结构

jsbridge-sdk/

JsBridge.kt — 入口,WebView 绑定

BridgeHost.kt — JS接口注入对象

BridgeDispatcher.kt — action 分发

CallbackManager.kt — 回调+超时

SecurityChecker.kt — 安全校验

BatchDispatcher.kt — 批量下发

handlers/ — 各业务Handler

6.2 核心入口类

class JsBridge private constructor(
private val webView: WebView,
private val config: BridgeConfig
) {
private val dispatcher =
BridgeDispatcher()
private val callbackMgr =
CallbackManager()
private val security =
SecurityChecker(config)
private val batcher =
BatchJsDispatcher(webView)

fun bind() {
webView.addJavascriptInterface(
BridgeHost(
dispatcher, callbackMgr,
security, webView
),
"NativeBridge"
)
}

// Native 主动调用 JS
fun callJs(
method: String,
params: JSONObject
) {
val js =
"window.JSBridge" +
".onNativeCall(" +
"'$method'," +
"$params)"
batcher.post(js)
}

fun destroy() {
callbackMgr.cancelAll()
webView.removeJavascriptInterface(
"NativeBridge"
)
}

companion object {
fun create(
webView: WebView,
block: BridgeConfig.Builder.() ->
Unit
) = JsBridge(
webView,
BridgeConfig.Builder()
.apply(block).build()
).also { it.bind() }
}
}

6.3 调用示例

// 初始化
val bridge = JsBridge.create(
webView
) {
allowedHosts(
"example.com",
"h5.myapp.com"
)
timeout(10_000L)
registerHandlers(
UserHandler(),
PaymentHandler(),
RouterHandler()
)
}

// 主动下发数据给 H5
bridge.callJs(
"onUserLogin",
JSONObject().apply {
put("userId", user.id)
put("name", user.name)
}
)

// 销毁时清理
override fun onDestroy() {
bridge.destroy()
webView.destroy()
super.onDestroy()
}

七、实战踩坑合集

最后聊几个真实项目里遇到的问题,比枯燥的文档有用多了。

坑1:Bridge 对象被 GC 回收

症状是随机出现 JS 调用没反应,复现不稳定。原因前面提到了:传进 addJavascriptInterface 的对象如果是局部变量,很容易被回收。

解法:把 Bridge 对象保存为 Activity/Fragment 的成员变量,或者用 WebView 的 tag 持有它,保证生命周期一致。

坑2:JS 在 evaluateJavascript 之前就执行了

场景:页面 onPageFinished 后立刻 callJs,但偶发 H5 报 window.JSBridge is undefined。

原因:onPageFinished 只代表 HTML 主文档加载完,不代表所有 JS 文件执行完毕。解法是 H5 初始化完成后主动调一个 Bridge(比如 NativeBridge.ready()),Native 收到信号后再开始 callJs。

坑3:WebView 销毁后 callback 仍在执行

用户快速退出页面,Bridge 请求还没完成,callback 里的 evaluateJavascript 在已销毁的 WebView 上执行,轻则静默失败,重则 crash。在 onDestroy 里调用 bridge.destroy() 清掉所有待处理回调是必须的。

坑4:Bridge handler 里直接做耗时操作

有同学在 Bridge handler 里直接做网络请求或数据库查询——因为调用是在 JS 线程发起的,这会阻塞 WebView 渲染。正确做法是立即切到后台线程异步处理,完成后通过 callback 回调。

用了这套框架之后,我们项目的 Bridge 相关 crash 从一个版本里 7-8 个降到了基本为零,JS 调用超时的问题也从偶发变成了可被捕获和上报。关键不是哪个具体的技巧,而是把这些容易出问题的点都纳入了系统性管理。

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

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

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

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

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

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

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

下一篇预告

第5篇将进入 WebView 系列的收官篇——性能优化与稳定性治理:WebView 预热方案、复用池设计、内存泄漏排查,以及线上 crash 和 ANR 的防护体系。如果你的 App 里 WebView 页面打开慢、偶发崩溃,下一篇应该会有你要找的东西。

赞(0)
未经允许不得转载:171主机测评 » WebView与原生JS交互:JSBridge设计模式与安全实践
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址