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 通信方式先把几种方式摆出来对比再逐个深入讲。方式方向核心原理主要缺陷addJavascriptInterfaceJS→Native向JS环境注入Java对象4.2以下安全漏洞shouldOverrideUrlLoadingJS→Native拦截特定URL scheme回调异步难并发丢失evaluateJavascriptNative→JS主线程执行JS字符串必须主线程API 191.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.2API 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 194.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 域名白名单校验最基础的一道防线——只允许白名单内的域名调 Bridgeclass SecureBridge( private val webView: WebView, private val allowedHosts: SetString ) { 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 层的约束。防重放攻击也值得考虑请求里携带timestampNative 侧判断时间窗口比如只接受前后 30 秒内的请求可以防止截获并重放的攻击。对于敏感操作还可以加一次性 nonce。五、高性能 JSBridge减少序列化开销做了几个项目之后我发现 JSBridge 的性能问题主要集中在两个地方JSON 序列化/反序列化的开销以及线程切换的成本。5.1 批量消息合并H5 页面初始化时可能短时间内触发大量 Bridge 调用初始化配置、获取用户信息、埋点上报……。与其每次都单独一个 evaluateJavascript可以引入一个消息队列将短时间内积累的多条消息合并成一次 JS 执行class BatchJsDispatcher( private val webView: WebView ) { private val pendingMsgs ArrayDequeString() 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 getterH5 按需读取。六、从零实现一个生产级 JSBridge SDK说了这么多理论来看一个完整的 SDK 骨架把前面所有点串起来。6.1 完整 SDK 结构jsbridge-sdk/JsBridge.kt— 入口WebView 绑定BridgeHost.kt— JS接口注入对象BridgeDispatcher.kt— action 分发CallbackManager.kt— 回调超时SecurityChecker.kt— 安全校验BatchDispatcher.kt— 批量下发handlers/— 各业务Handler6.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() }七、实战踩坑合集最后聊几个真实项目里遇到的问题比枯燥的文档有用多了。坑1Bridge 对象被 GC 回收症状是随机出现 JS 调用没反应复现不稳定。原因前面提到了传进addJavascriptInterface的对象如果是局部变量很容易被回收。解法把 Bridge 对象保存为 Activity/Fragment 的成员变量或者用 WebView 的 tag 持有它保证生命周期一致。坑2JS 在 evaluateJavascript 之前就执行了场景页面 onPageFinished 后立刻 callJs但偶发 H5 报window.JSBridge is undefined。原因onPageFinished 只代表 HTML 主文档加载完不代表所有 JS 文件执行完毕。解法是 H5 初始化完成后主动调一个 Bridge比如NativeBridge.ready()Native 收到信号后再开始 callJs。坑3WebView 销毁后 callback 仍在执行用户快速退出页面Bridge 请求还没完成callback 里的 evaluateJavascript 在已销毁的 WebView 上执行轻则静默失败重则 crash。在onDestroy里调用bridge.destroy()清掉所有待处理回调是必须的。坑4Bridge 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 页面打开慢、偶发崩溃下一篇应该会有你要找的东西。