WebView 的 JsBridge 方案,现在还有必要吗
「WebView 的 JsBridge 方案,现在还有必要吗」
上个月处理一个老项目的性能工单,用户反馈在低端机上点击 H5 页面的按钮后,Native 端要卡将近半秒才有响应。抓 Systrace 一看,时间全耗在了一条 shouldOverrideUrlLoading 的解析逻辑里。那套代码是五年前为了兼容 Android 4.4 封装的 JsBridge,通过 URL Scheme 拦截来做双向通信。看着那些密密麻麻的 decodeURIComponent 和字符串分割逻辑,我突然意识到一个问题:都 2024 年了,如果项目的 minSdk 早就提到了 24 甚至 26,我们到底为什么还要维护一套基于 URL 拦截的 JsBridge?
那套为了兼容 Android 4.4 的 URL Scheme 拦截,现在像个历史包袱
早期的 WebView 并没有给 JavaScript 提供直接调用 Native 的正规通道。Android 4.2 之前连 @JavascriptInterface 注解都没有,开发者只能往 WebView 里注入 javascript: 协议的 URL,或者在 shouldOverrideUrlLoading 里拦截假造的 URL,比如 jsbridge://callNative?data=xxx。Native 收到后再解析路径和 Query,反过来通过 loadUrl("javascript:window.callback()") 把结果传回 JS。
这套方案的问题在于它根本不是为了通信设计的。shouldOverrideUrlLoading 的回调时机取决于 WebView 内部的导航调度,每次通信都伴随着一次 URL 解析、主线程消息队列排队和字符串解码。在 Android 14 的 WebView(基于 Chromium 120+)上,我单测了十次取平均,一次简单的字符串传递从 JS 发出到 Native 收到,耗时大概在 12ms 到 35ms 之间波动。如果数据量稍微大一点,需要分片传输,这个时间会线性叠加。更麻烦的是,shouldOverrideUrlLoading 在 API 24 前后行为还不一样。旧版本返回 true 只是告诉 WebView 不要继续加载,而 API 24 引入的 WebResourceRequest 版本虽然能拿到 isForMainFrame() 来做更精确的判断,但拦截逻辑里如果不小心漏判了子 frame,某些广告 SDK 的 iframe 也会触发你的 JsBridge 解析,导致莫名其妙的空指针。
还有一点很少被提到:loadUrl("javascript:...") 在 Android 19 之前没有返回值。如果你的 JS 调用需要同步拿到一个结果,旧版 WebView 根本做不到。很多当年的 JsBridge 库为了解决这个问题,引入了 JS 层的轮询机制——Native 把结果写进一个全局变量,JS 每隔几十毫秒读一次。这种代码放在今天的设备上,除了增加功耗和 GC 压力,几乎没有别的意义。
@JavascriptInterface 没那么简单,线程问题坑过很多人
Android 4.2(API 17)引入了 @JavascriptInterface 注解,官方总算给了一个相对正规的通道。但很多人第一次用的时候都踩过同一个坑:直接在注解方法里更新 UI,然后崩一个 CalledFromWrongThreadException。
原因是 addJavascriptInterface 注册的对象,其被 JS 调用的方法并不执行在主线程,而是跑在 WebView 内部一个叫 JavaBridge 的线程上。如果你在里面直接 findViewById 或者修改 TextView 的文本,Android 的视图检查机制会直接抛异常。正确的做法是在方法内部把逻辑 post 到主线程:
@JavascriptInterface
public void postMessage(String json) {
new Handler(Looper.getMainLooper()).post(() -> {
// 处理消息
});
}API 17 还带来了一个-breaking change:如果没有加 @JavascriptInterface 注解,方法对 JS 不可见。这导致了一大批老代码在升级到 Android 4.2 后直接失效。不过那是十一年前的事了。今天更值得关心的是另一个问题:内存泄漏。如果你的 JsBridge 对象持有了 Activity 的隐式引用,而 WebView 又因为被放置在 Activity 的 View 树中形成了反向引用,配置变更时很容易导致 Activity 无法被回收。我见过一个项目,JsBridge 里直接写了内部类去调 Activity.startActivityForResult,结果旋转屏幕几次就LeakCanary 报警。
还有一个安全限制:在 API 17 以下的设备上,JS 通过反射可以拿到注入对象的 getClass(),进而拿到 ClassLoader,加载任意本地类。这就是 CVE-2012-6636。虽然今天 minSdk 24 的项目完全不用考虑这个漏洞,但市面上仍有不少需要兼容旧设备的金融或政务 App,它们的 JsBridge 库里至今还留着一堆防御性代码,比如用代理对象包装、手动检查 API level。这些代码在 Modern Android 上除了增加维护成本,没有实际作用。
WebMessage 和 WebMessagePort:被低估的原生方案
从 Android 6.0(API 23)开始,WebView 支持了 WebMessage 和 WebMessagePort,这是 Chromium 的 PostMessage 机制在 Android WebView 上的映射。它提供了一套真正的双向消息通道,不需要 URL 编码,不需要线程中转解析,JS 和 Native 各自持有一个 Port,直接互相 postMessage。
用法其实不复杂。Native 侧先创建两个 Port:
WebMessagePort[] channels = webView.createWebMessageChannel();
WebMessagePort nativePort = channels[0];
WebMessagePort jsPort = channels[1];
nativePort.setWebMessageCallback(new WebMessagePort.WebMessageCallback() {
@Override
public void onMessage(WebMessagePort port, WebMessage message) {
// 收到 JS 消息,注意这里仍然不在主线程
}
});
webView.postWebMessage(
new WebMessage("", new WebMessagePort[]{jsPort}),
Uri.parse("https://your-domain.com")
);JS 侧通过 onmessage 和 postMessage 与 Native 通信。这套方案最大的好处是语义清晰:它是消息端口,不是导航拦截,也不是 Java 对象注入。你不需要担心 shouldOverrideUrlLoading 被其他逻辑误触发,也不需要处理 URL 长度限制。实测在 Pixel 7、Android 14 的环境下,单次通信延迟稳定在 0.5ms 以内,基本上可以认为是零开销。
当然它也有局限。首先是 API 23 的门槛,Android 5.x 及以下用不了。其次,很多开发者反馈在 Chrome DevTools 里看不到 WebMessagePort 的通信内容,调试起来不如 console.log 直观。实际上只要调了 WebView.setWebContentsDebuggingEnabled(true),在 Chrome 的 DevTools 里确实能看到 WebMessage 的收发记录,但前提是你的 WebView 版本足够新(Chromium 67 之后支持度才比较完整)。另外,postWebMessage 的第二个参数 targetOrigin 必须和网页的 origin 匹配,本地 file 协议加载的 HTML 这里会踩坑,需要写 Uri.parse("*") 做通配,但在生产环境里这样干又有安全隐患,得配合域名白名单。
我个人觉得 WebMessagePort 是目前 Native 与 WebView 通信的最优解,前提是你们的 minSdk 大于等于 24。如果还在 21 到 23 之间,可以降级用 evaluateJavascript,没必要为了兼容去维护一套 URL Scheme。
性能实测:三种方案在 Android 14 上的差距
为了确认主观感受,我在同样的环境下做了组简单对比。设备是 Pixel 6a,系统 Android 14(API 34),WebView 版本 123.0.6312.80,测试内容是发送一个 2KB 的 JSON 字符串,往返一百次取平均耗时。
URL Scheme 拦截方案平均单次耗时 18ms,抖动很大,范围在 10ms 到 45ms 之间。瓶颈主要在 shouldOverrideUrlLoading 的主线程调度以及 Query 字符串的编解码。evaluateJavascript 方案平均 2.8ms,非常稳定,因为它直接走 V8 的接口注入脚本,没有导航调度的开销。WebMessagePort 方案平均 0.4ms,而且支持真正的异步双向通信,不需要像 evaluateJavascript 那样在 JS 层拼接回调函数名。
内存方面,URL Scheme 方案在一百次往返后,Heap Dump 里能看到大量 transient 的 String 和 byte[] 对象,是 URL 解析和 Uri.decode 产生的垃圾。evaluateJavascript 和 WebMessagePort 的 GC 压力明显小一个数量级。对于通信频次高的场景,比如 H5 视频播放器通过 Native 解码每一帧的元数据,URL Scheme 方案基本不可用。
现代 Hybrid 框架其实也在抛弃传统 JsBridge
看看现在主流的 Hybrid 方案,Capacitor 3.x 和 4.x 的底层通信在 Android 上已经全面转向 evaluateJavascript 和 addJavascriptInterface 的组合,完全抛弃了 URL 拦截。它的 Plugin 架构虽然仍叫 "Bridge",但本质上是一套类型化的 RPC 封装,不再需要你手写字符串解析。Tauri Mobile 走的是 WebView 的系统 API 路线,Android 端用的就是 Kotlin 直接调 evaluateJavascript,没有第三方 JsBridge 库中间层。
甚至 Flutter 的 webview_flutter 插件在 4.0 版本之后,底层实现也改为了更直接的 Platform Channel 到 WebView API 的映射。这些框架的演进说明社区已经形成共识:WebView 本身提供的原生 API 已经足够好用,额外抽象一层 "JsBridge" 库的收益越来越低。
不过这里有个例外:微信、支付宝这类超级 App 的小程序环境。它们并不是单纯的 WebView,JS 运行在自定义的 JSCore 或 V8 隔离环境里,通信模型是跨进程的,这时候确实需要一套 Bridge。但这属于特定生态的架构需求,和普通业务 App 里放一个 H5 页面不是一回事。
一个真实的崩溃:JsBridge 遇上了多进程渲染
Android 8.0(API 26)引入了 WebView 的多进程渲染支持,可以通过 setDataDirectorySuffix 给不同 WebView 实例隔离数据目录。Android 10 之后,部分 OEM 设备上 WebView 的渲染进程崩溃不再连带整个 App 闪退,而是通过 onRenderProcessGone 回调让开发者自己处理。
问题就出在这里。很多传统的 JsBridge 库在 Native 侧持有一个全局单例的 Bridge 对象,内部维护了 HashMap<String, Callback> 来保存 JS 的调用回调。一旦渲染进程因为内存不足或 JS OOM 被系统杀掉,onRenderProcessGone 触发后,WebView 必须被销毁重建。但如果你的 JsBridge 单例没清掉之前注册的回调,或者 Activity 被重建后 JS 层的回调函数名对不上 Native 这边的 key,就会出现调用成功但没有任何响应的 "假死" 状态,严重的还会因为 NullPointerException 崩溃。
日志大概长这样:
android.webkit.WebViewClient.onRenderProcessGone
Render process gone for detail: crashed=true之后如果你尝试用旧的 JsBridge 实例往死了的 WebView 里 loadUrl("javascript:..."),有些版本的 WebView 会直接抛 IllegalStateException。正确的做法是在 onRenderProcessGone 里彻底解绑 JsBridge 和 WebView 的引用,重建 WebView 后重新注入。但那些老旧的 JsBridge 库在设计时根本没考虑多进程渲染,改造起来比直接重写还麻烦。
什么时候还值得留着 JsBridge
说了这么多,我并不认为 JsBridge 这个概念完全死了。如果你维护的是一个需要兼容 Android 5.0 甚至 4.4 的存量项目,或者你的 H5 页面要跑在第三方 App 的 X5 内核、UC 内核上,那些内核的 API 支持和 Chromium 原生 WebView 并不完全一致,这时候有一套自己掌控的通信协议仍然是必要的。
另外,对于文件传输或二进制数据透传,evaluateJavascript 和 WebMessagePort 都有各自的长度或类型限制(WebMessage 目前主要传字符串,虽然 Chromium 高版本开始支持 ArrayBuffer,但 Android WebView 的绑定层跟进很慢),这时候用 HTTP 长轮询或者自定义协议反而更靠谱。
但如果你的项目 minSdk 已经定在 24 或 26,WebView 用的是系统自带的 Chromium,业务也只是普通的 H5 与 Native 互调,我真的建议直接把那个第三方的 JsBridge 库删掉。evaluateJavascript 做 Native 到 JS 的调用,@JavascriptInterface 做 JS 到 Native 的调用,复杂场景用 WebMessagePort 做双向通道,自己封装一个几十行的工具类完全够用。省下来的不只是包体积,还有未来每次 Android 大版本升级时的适配成本。
上周我刚把项目里那套五百多行的 JsBridge 核心类删掉,替换成了基于 WebMessagePort 的简易封装,配合 evaluateJavascript 做降级。APK 小了将近 200KB,H5 页面里那个被吐槽了很久的 "点击延迟" 也消失了。如果你的情况类似,那套为了兼容 Android 4.x 而存在的 URL Scheme 拦截层,现在真的可以进回收站了。