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 页面打开慢、偶发崩溃,下一篇应该会有你要找的东西。