搞混合开发这几年我相信每个开发都绕不开一个词Js 与 Android 原生交互。简单说就是 H5 页面跑在 App 的 WebView 里前端需要拿到设备信息、调用摄像头、读取剪贴板或者把支付结果回传给 App而原生端又需要把一些数据推给网页。两边语言不一样、运行环境不一样全靠 WebView 这个容器互相传话。所有 App 内嵌 H5 的玩法本质上都建立在这套交互之上。这篇文章适合两类人看一类是刚接触混合开发的前端搞不清楚window.nativeBridge这种东西是怎么来的另一类是 Android 工程师想系统梳理 JSBridge 的细节、踩坑历史和协议设计。我在早期做这类功能时踩过不少坑Release 包里接口突然不可用、回调函数莫名消失、页面一刷新桥就断、低端机型调用频繁直接卡顿……这篇文章就把背后的原理和可落地的方案一起写清楚不搞纯理论重点是能直接抄进项目的代码和排错思路。1. 交互方案全景先看清这四类通道1.1 JS 调原生的三条主流路径从消息方向来看最核心的一条是“JS 主动调原生”。这条路上一共有三种主流实现注入对象通过WebView.addJavascriptInterface把一个 Java 对象挂到页面的window上JS 里直接window.bridge.xxx()调用方法上必须带JavascriptInterface注解。拦截 URLJS 端发起一个自定义协议请求比如myapp://bridge/getDeviceInfo?callbackxxx原生在shouldOverrideUrlLoading或shouldInterceptRequest里拦截解析。拦截弹窗JS 里调用window.prompt、alert、confirm原生WebChromeClient对应回调里拦截。其中prompt是最特殊的因为它的返回值能同步传回 JS。这三种方案各有脾气。注入对象是最直观的也是现在绝大多数团队的首选拦截 URL 是“上古时期”的常用方案因为当时注入对象有安全漏洞弹窗拦截看起来很绕但它能做到同步返回所以不少老牌 JSBridge 框架底层其实是靠它实现的。1.2 原生调 JS 的两条执行通道反向调用同样重要原生主动通知 H5比如“支付结果出来了前端切个页面”。Android 给的原生调 JS 通道有两条loadUrl(javascript:...)老方案任何版本都能用但没有返回值在部分系统版本上执行 JS 时会对 WebView 有额外的副作用。evaluateJavascript(...)Android 4.4 开始提供异步回调能拿到 JS 执行后的返回值体验好得多。反向调用比正向调用简单真正的复杂度在“时机”页面没加载完就执行 JS很容易被忽略频繁调用时还要保证切到主线程。这些后面我会专门讲。1.3 到底怎么选型如果让我给一句话建议新项目无脑选注入对象 统一协议封装老项目碰到维护困难再逐步往这上面迁移。原因很简单注入对象的调用链路短、可以直接返回数据、代码读起来最像普通业务逻辑而 URL 和 prompt 都是“曲线救国”天然要做字符串解析和回调包装复杂度更高。选型时别只看单点能力还要想清楚一个问题你的 bridge 是要只给一个页面用还是要服务多端、多个业务线如果只是某个活动页调一两个接口那怎么快怎么来如果公司里多个 App、多套 H5 都要复用同一套能力就应该把交互抽象成“协议层”而不是把几十个JavascriptInterface方法直接铺开。协议怎么设计我放到第 4 章详细展开。2. 核心实现一JS 调用原生的细节与源码逻辑2.1 addJavascriptInterface把 Java 对象挂到 Window 上先看最常用的一种代码很简单public class JsBridge { JavascriptInterface public String getDeviceInfo() { return android; } JavascriptInterface public void openAlbum() { // 打开相册逻辑 } } webView.addJavascriptInterface(new JsBridge(), nativeBridge);JS 端这样调用var device window.nativeBridge.getDeviceInfo(); window.nativeBridge.openAlbum();看到这里很多前端朋友会问为什么getDeviceInfo的返回值能直接给 JS其实这个方法的底层在 4.4 前后实现不一样4.4 之前Android 通过 WebView 里的 Java Object Binding 机制把对象映射成了 JavaScript 环境里的一个特殊对象4.4 之后 WebView 底层切到了 Chromium 内核注入对象的实现也变成了 Chromium 的JavaScriptInterface代理机制。不管底层怎么变对外表现都差不多一个 Java 对象被“挂”到了页面 window 上JS 把它当成普通 JS 对象用。这里有个历史坑必须讲清楚。在 Android 4.2 之前addJavascriptInterface注入的对象会把所有public方法都暴露给 JS包括getClass这类危险方法恶意网页可以利用反射机制触发严重的安全问题。后来系统要求开发者必须用JavascriptInterface注解显式标记可被调用的方法。所以现在你写的 bridge 方法如果忘加注解Release 包里大概率调不通。这就像一个门禁系统对象本身就是“访客”JavascriptInterface注解是“门禁卡”没有卡的内部房间一律不许进。实操里有几个点特别容易忽略返回值尽量用 String 或基本类型。虽然JavascriptInterface方法返回复杂对象有时也能转成 JSON 给 JS但这种跨语言的对象传递非常不可控遇到循环引用直接出问题。我在真实项目中统一约定原生 bridge 方法的返回值要么是 String要么是 void。不要在里面直接操作 UI。JS 调用原生方法时这个 Java 方法不是在主线程执行的而是在 WebView 的桥接线程里。如果你在里面直接弹 Toast、操作 View轻则顺序错乱重则崩溃。需要切 UI 时用Activity.runOnUiThread或View.post包一层。不要在方法里做耗时任务。因为是同步穿透调用处理太久会阻塞 WebView 的桥接线程页面白屏时间变长体验非常差。2.2 自定义 scheme 拦截不暴露对象的老派通道在注入对象还不安全的年代主流的做法是拦截 URL。原理很简单JS 端只要发起一个自定义协议的跳转原生 WebView 客户端就有机会在跳转前拦截它。function callNative(action, params) { var url myapp://bridge ?action encodeURIComponent(action) params encodeURIComponent(JSON.stringify(params || {})); var iframe document.createElement(iframe); iframe.style.display none; iframe.src url; document.documentElement.appendChild(iframe); setTimeout(function() { iframe.remove(); }, 100); }原生端webView.setWebViewClient(new WebViewClient() { Override public boolean shouldOverrideUrlLoading(WebView view, String url) { if (url ! null url.startsWith(myapp://)) { handleBridgeUrl(url); return true; } return super.shouldOverrideUrlLoading(view, url); } });这里有两个设计细节值得说。第一为什么用 iframe 而不是location.href。早期很多团队用location.href发协议但连续快速调用时后面的跳转会覆盖前面的导致大量消息丢失某些低端机型还会触发页面白屏或重新加载。iframe 的方式不会引导页面跳转只发出一个“网络请求”的影子稳定性高很多。第二URL 协议要设计得像“门牌号”。前缀不能用太通用的词比如myapp://就适合自己 App 内部使用解析时建议用Uri.parse()拆出 action、params、callbackId并做长度限制因为 URL 本身不适合承载超大参数传大 JSON 容易出现被截断的问题。大部分 bridge 协议只用它传“方法名 回调 ID”具体数据走“原生主动去取”的方式这样最稳。这种方案的优点是没有暴露任何 Java 对象给网页攻击面小缺点是没法同步返回且协议解析要自己写。除非你是维护老代码否则我不建议新项目再用它做主力通道倒是可以把它作为降级方案保留。2.3 prompt 拦截同步返回的隐藏通道第三种方案是拦截window.prompt。它的优点是能同步拿到返回值这也是很多自研 JSBridge 框架选它做底层通道的原因。JS 端var result window.prompt(myapp://getDeviceInfo?callbackId1);原生端webView.setWebChromeClient(new WebChromeClient() { Override public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { if (message ! null message.startsWith(myapp://)) { String resultData handleBridgePrompt(message); result.confirm(resultData); return true; } return super.onJsPrompt(view, url, message, defaultValue, result); } });原理其实不复杂window.prompt本来就是浏览器弹一个输入框用户输入的文字会作为函数返回值。原生在onJsPrompt里拦截后调用result.confirm(value)这个value就会作为 prompt 函数的返回值回到 JS 手里。原生拦截替代了“用户输入”这一步这么一想就通了。但用 prompt 有两个前提要注意。第一个是线上页面如果本身也会用 prompt 做业务输入拦截时必须先判断消息前缀不能无脑全拦第二个是 prompt 的返回值本质是字符串大对象同样不适合直接传建议只返回一个“结果码 数据引用”具体数据后续通过原生主动调 JS 回调推送。2.4 三种方案怎么选一张表看清楚维度注入对象URL 拦截Prompt 拦截同步返回支持不支持支持安全隔离高需注解中靠协议校验中靠协议校验实现复杂度最低中中数据大小较大受限URL 长度中对页面影响无iframe 无感可能干扰业务弹窗历史定位当前主流早期主流JSBridge 底层策略我个人在实际项目里的组合是主力用注入对象同时在 JS 层封装一层invoke(action, params)内部如果发现window.nativeBridge不存在自动降级用 prompt 通道。这样既照顾了主流程的效率又给老内核环境留了退路。3. 核心实现二原生调用 JS 的执行机制与回调设计3.1 两种执行方式loadUrl 与 evaluateJavascript 的取舍原生调 JS 的常规操作很简单但选错 API 会踩不少坑。// 方式一老版本兼容 webView.loadUrl(javascript:window.showToast(hello)); // 方式二4.4 推荐 webView.post(new Runnable() { Override public void run() { webView.evaluateJavascript( window.showToast window.showToast(hello), new ValueCallbackString() { Override public void onReceiveValue(String value) { // value 是 JS 里最后一句表达式返回的字符串 } }); } });evaluateJavascript有三大优势能拿到 JS 执行后的返回值、不会像loadUrl那样额外触发一次重新流程、异步回调不阻塞主线程。但它有最低版本要求4.4 以下只能退回loadUrl。做兼容的套路也很简单封装一个execJs(code, callback)运行时判断Build.VERSION.SDK_INT 19走新 API否则走老的loadUrl返回值拿不到就算了。这里必须强调一下执行时机。很多“原生调 JS 不生效”的问题本质是页面还没准备好。建议在onPageFinished之后再执行 JS如果需要在onPageStarted就注入初始化代码也要用window.bridge window.bridge.xxx()的防御式写法避免还没定义就调用。另外evaluateJavascript必须运行在 UI 线程如果你是从网络回调、子线程任务里触发的先webView.post(...)再执行。3.2 回调通道从 callbackId 到 Promise原生调 JS 不只是“把数据塞给页面”这么简单更多时候它是在响应 JS 的请求。比如 JS 调了getLocation原生去定位定位是异步的等结果回来后原生怎么通知 JS这就需要一个回调协议。推荐的完整闭环是JS 发起调用时带上一个自增的callbackId同时把该 ID 的 resolve/reject 存到 JS 端的 map 里。原生处理完业务后通过evaluateJavascript调用 JS 端一个固定入口函数receiveResponse把callbackId和结果数据一起传回去。JS 端用callbackId找到对应的 Promise完成路由。JS 端核心代码const pending {}; let seq 0; function callNative(action, params) { return new Promise((resolve, reject) { const callbackId seq; pending[callbackId] { resolve, reject }; const payload { action, params, callbackId }; // 优先走注入通道失败降级 prompt if (window.nativeBridge) { window.nativeBridge.invoke(JSON.stringify(payload)); } else { window.prompt(myapp:// JSON.stringify(payload)); } }); } // 原生端统一回调这个函数 window.receiveResponse function (payload) { const { callbackId, code, data, msg } payload; const cb pending[callbackId]; if (!cb) return; delete pending[callbackId]; if (code 0) { cb.resolve(data); } else { cb.reject(new Error(msg || native error)); } };原生端的响应部分private void sendResponse(WebView webView, String callbackId, int code, String data) { String payload new JSONObject() .put(callbackId, callbackId) .put(code, code) .put(data, data) .toString(); String js window.receiveResponse window.receiveResponse( payload ); webView.post(() - webView.evaluateJavascript(js, null)); }这里稍微注意一下传给evaluateJavascript的 payload 需要做 JSON 序列化如果里面包含换行、单引号等特殊字符建议用JSONObject.quote()或直接把整个对象序列化成合法 JS 字面量否则很容易出现语法错误。使用callbackId而不是“自定义函数名”的核心原因有三个不会污染全局命名支持异步超时一个入口函数处理所有回调方便在入口统一打日志。这也是几乎所有 JSBridge 框架的标准设计。4. 架构设计搭一套能用几年的 JSBridge4.1 统一报文把交互抽象成协议前面讲的是通道但真正让 bridge 好维护的是统一的消息格式。建议一份报文固定包含四个字段{ action: getDeviceInfo, params: { needBattery: true }, callbackId: 1 }原生返回也保持统一{ callbackId: 1, code: 0, data: {}, msg: ok }为什么一定要统一因为一旦 bridge 要处理几十个接口你会发现比起“给每个功能写一个JavascriptInterface方法”统一协议 统一分发要轻松得多。前端只需维护一份 API 清单原生只暴露一个或少数几个入口剩下的活交给协议层。我见过反面教材某同事为了省事给业务写了二十多个JavascriptInterface方法每个方法还直接依赖 Activity 里的具体业务代码。后来页面改版Activity 换了bridge 里大量代码跟着返工。如果一开始就只暴露一个invoke(String json)把所有 action 分发到独立的 Handler后续加需求就只是“加一个 action 实现类”完全不影响调用方。4.2 初始化时序为什么你的第一个调用总是丢失混合开发里有个经典问题页面加载很快JS 第一行就调用 bridge但原生对象可能还没注入完成调用直接被吞了。解决思路是两层配合第一JS 端做“队列缓冲”。所有callNative进来时如果发现 bridge 未就绪不直接报错而是把消息先塞进一个pendingQueue等ready信号到了再统一发送。let isReady false; const queue []; function callNative(action, params) { const payload { action, params, callbackId: seq }; if (isReady) { doSend(payload); } else { queue.push(payload); } } window.bridgeReady function () { isReady true; while (queue.length) { doSend(queue.shift()); } };第二原生在合适的时机通知 JS。“合适的时机”不是绝对的onPageFinished因为在 SPA 场景下页面可能早已准备好而onPageFinished反而等太久。更稳的做法是在addJavascriptInterface注册完成后立刻注入window.bridgeReady window.bridgeReady()同时onPageFinished再补发一次。这样既覆盖了初次加载也覆盖了可能的重置场景。另外还有一个容易被忽略的细节页面刷新后注入对象会重新挂载。如果你的页面用了location.reload()或者路由强制刷新原本 JS 端的isReady状态会丢失所以把bridgeReady设计成“幂等”的重复调用也不重复发队列能省掉很多线上烦恼。4.3 方法分发告别一长串 if-else原生收到统一报文后分发逻辑也要设计得干净。推荐一个极简版public interface JsActionHandler { void handle(JSONObject params, String callbackId); }public class BridgeDispatcher { private final MapString, JsActionHandler actions new HashMap(); public void register(String action, JsActionHandler handler) { actions.put(action, handler); } JavascriptInterface public void invoke(String jsonString) { JSONObject obj new JSONObject(jsonString); String action obj.optString(action); String callbackId obj.optString(callbackId); JSONObject params obj.optJSONObject(params); JsActionHandler handler actions.get(action); if (handler ! null) { handler.handle(params, callbackId); } else { sendResponse(callbackId, 1, action not found); } } }注册业务时bridgeDispatcher.register(getDeviceInfo, (params, callbackId) - { String data deviceInfoService.get(); sendResponse(callbackId, 0, data); });这套结构最大的价值是业务逻辑不侵入 WebView 层测试时可以单独对BridgeDispatcher做单元测试不需要真的起一个 WebView。如果公司内部做的是跨端 Hybrid 框架iOS 端也可以用同一套协议映射到 WKWebView 的WKScriptMessageHandler前后端联调完全不用关心平台差异。5. 安全与兼容线上出问题大多是这两类原因5.1 JavascriptInterface 的漏洞史与防御讲安全不能绕开历史。早期 WebView 的addJavascriptInterface会把 Java 对象的public方法全部暴露给 JS这客观上造成了非常大的攻击面。Android 4.2 之后系统要求必须加JavascriptInterface注解才把暴露范围收住了。但“加了注解”不等于“绝对安全”。实际项目中我们还要自己做几道防线域名白名单。bridge 入口必须校验webView.getUrl()的域名是否在允许列表里。如果页面被反代、被劫持、跳到了第三方页面那么第三方完全可以调用你的 bridge 能力。校验逻辑不要写死在某个方法里最好是放在invoke入口统一处理。不把 Activity 直接暴露给 JS。很多人图省事直接addJavascriptInterface(this, android)这等于把整个 Activity 的 public 方法都放到了网页面前。虽然 4.2 后有注解保护但你很难保证以后不会有人给 Activity 加一个 public 方法而忘了风险。正确的做法是只注入一个专门写的 Bridge 类类里不持有敏感权限对象。不要给 bridge 对象起通用的名字。android、app、bridge这类名字太容易被猜测。虽然命名不能作为安全手段但能显著降低被针对的概率。对 action 参数多半是“字符串拼接”进来的务必做合法性校验。不要在没有任何过滤的情况下把前端传的参数直接拼进 SQL、文件路径或 shell 命令这点和写后端接口是一个道理。5.2 线程、内存与混淆线程问题是所有 WebView 开发者的老朋友。很多线上崩溃日志指向 WebView 方法点开一看都是“从子线程调用了 WebView”。Android 8.0 之后 WebView 本身跑在独立渲染进程里JS 与 Java 的通信也经过了进程间的桥接原本在低版本可能侥幸通过的子线程调用在高版本上就可能直接异常。统一的做法是所有 WebView 相关的操作入口一律先切到主线程。内存方面addJavascriptInterface是隐形的强引用。如果注入的 Bridge 对象持有了 Activity而页面没有被及时销毁内存泄漏的概率会非常高。onDestroy里至少要做三件事Override protected void onDestroy() { if (webView ! null) { webView.removeJavascriptInterface(nativeBridge); webView.loadUrl(about:blank); webView.stopLoading(); webView.destroy(); } super.onDestroy(); }这里尤其要提醒destroy()方法不能在子线程调用而且必须先移除 JavascriptInterface再 destroy否则在部分系统上会崩溃。混淆也是一个经典“Release 失效”原因。JavascriptInterface方法名在 Release 包里被混淆后JS 端调用就会找不到方法。要在 ProGuard/R8 配置里加上-keepclassmembers class com.example.bridge.** { android.webkit.JavascriptInterface methods; }建议将 Bridge 类统一放在一个包下配一条规则覆盖全部比每个类单独 keep 更省心。6. 常见问题与排查技巧实录6.1 高频问题速查表现象可能原因排查重点Release 包调用无反应方法被混淆检查混淆规则是否 keep 了 JavascriptInterface第一次调用丢失Bridge 未就绪JS 队列 ready 信号是否完整回调函数不执行callbackId 路由失败看原生是否调了window.receiveResponse页面刷新后报 undefined注入对象重新挂载重新广播 ready前端重试队列连续调用丢消息使用location.href发协议改为 iframe 或 evaluateJavascript低端机型卡顿频繁同步大对象数据分批、合并调用避免大 JSON 直接传白屏一段时间后复活WebView 渲染进程崩溃检测渲染进程恢复重新初始化页面原生回调 JS 不执行在主线程但 page 未就绪等 onPageFinished 或加防御式window.xxx 6.2 我常用的三个调试手段第一个是转发 Console 日志。默认 WebView 里的console.log不会打到 Logcat设置一下WebChromeClient.onConsoleMessage把所有 console 信息转发出来排查 JS 调用时机非常方便webView.setWebChromeClient(new WebChromeClient() { Override public boolean onConsoleMessage(ConsoleMessage consoleMessage) { Log.d(WebConsole, consoleMessage.message()); return true; } });第二个是统一入口打审计日志。在invoke和sendResponse两处统一打日志记录 action、callbackId、耗时。这样上下行消息都有据可查出问题时能直接判断是“没收到”还是“收到没处理”。第三个是开启 WebView 调试。调试包跑起来后在 Chrome 地址栏输入chrome://inspect可以像调试普通网页一样打断点。生产环境千万别开这是安全底线。6.3 两个容易被忽略的隐形坑第一个坑是SPA 框架对 prompt 通道的污染。我之前在 Vue 页面里试过某些路由组件在初始化时覆盖了window.prompt导致 bridge 降级通道直接失效。这类问题特别隐蔽因为页面看起来一切正常但所有 bridge 调用都石沉大海。如果代码里用了降级逻辑建议在 JS 端直接保存一个_safeBridgePrompt window.prompt的引用避免后期被覆盖。第二个坑是evaluateJavascript拼参数时引号转义不完整。很多崩溃和语法错误源于前端传递的数据里带着单引号、换行符或/script直接拼字符串就把页面搞崩了。统一用一个JSON.stringify 编码函数包一层不要手搓字符串拼接。写在最后做了这么多年混合开发我最大的体会是Js 与 Android 原生交互的技术方案没有绝对最优只有相对适合。短平快的活动页直接addJavascriptInterface最快要服务多端、多业务的复杂场景就必须把协议层、分发层和回调层单独抽出来宁可第一天多花半天设计也不要等功能堆到一半再回头补。最后分享一个我自己常用的稳妥底线无论用什么通道JS 端永远不要直接依赖某个全局函数存在调用前都做防御判断原生端任何方法入口都要做域名和参数校验。能保住这两条大部分线上问题就都不会演变成事故。