做移动端开发,只要碰过Hybrid方向,最后都绕不开JSBridge。这个词看着像个黑话,其实就是JavaScript和Native之间的一座桥:网页需要调用摄像头、相册、定位、支付这些原生能力,原生又想把登录态、系统事件、推送结果扔给网页,两边各跑各的运行时,没人牵线根本说不上话。面试里被问过,项目里被H5同事催过,线上问题往往还得从这一层查起。所以我把JSBridge的核心原理、方案选型、落地封装和踩坑经验串成一条线,一次讲清楚它到底怎么工作、怎么用才不会给自己埋雷。
这篇文章适合谁看?第一类是Hybrid项目里的Android和iOS开发,需要设计或维护桥层;第二类是写H5但经常要跟客户端对齐接口的前端,搞懂底层机制后,很多“为什么客户端说回调丢了”的问题自己就能判断;第三类是刚接触移动容器化开发的同学,想从原理层面把WebView通信这层补起来。下面所有内容都来自我在实际项目里验证过的做法,个别细节在不同系统版本上可能有差异,我会单独标注。
1. 从Hybrid开发说起:JSBridge到底解决什么问题
1.1 两个运行时之间为什么需要有座桥
要理解JSBridge,先得想清楚一个很基础的问题:为什么JS不能直接调用原生相机?原生代码为什么不能直接读H5页面里的一个变量?
因为两边压根活在两个世界。WebView里的JavaScript由浏览器的JS引擎解释执行,这个引擎是个沙箱,JS能访问的只有当前DOM、BOM和引擎暴露出来的少数全局对象,系统级API一概碰不到。这是浏览器安全的根基:网页内容来源复杂,如果任意网页都能直接操作摄像头、读通讯录,那打开一个钓鱼页面就等于把手机交出去了。所以WebView的边界从一开始就把JS关在了一个只有页面自己的笼子里,JS想拿手机上的能力,必须找“门卫”。
原生这一侧则是完全另一套逻辑。Android和iOS各自有独立的运行时、内存模型、线程调度和生命周期机制。原生代码拿不到WebView内部那份JS运行时环境的直接句柄,更不能像普通方法调用那样直接把一个Java对象塞给JS去操作。就算能塞,中间还隔着JS引擎和原生运行时之间的内存模型差异,传个对象引用过去,两边根本认不得对方的地图。
两个运行时互不认识,但又必须协作,于是就有了“消息通道”的概念。任何跨运行时的调用,本质上都是在做一件事:把参数序列化成双方都能理解的数据,通过一条中间通道送过去,再把返回结果序列化送回来。JSBridge就是这个通道本身,同时它还得负责把“调用哪个方法”“参数长什么样”“结果回给谁”这些信息约定清楚。理解了这一层,后面所有实现方式你都会觉得顺理成章——没有魔法,全是协议。
1.2 桥在整体架构里的位置
在一个典型的Hybrid架构里,从上到下大致是三层:H5业务层、桥层、Native能力层。H5业务层只负责页面渲染和业务逻辑,不直接跟系统API打交道;Native能力层负责摄像头、定位、支付、分享这些系统能力;桥层夹在中间,承担协议解析、安全过滤、任务调度和生命周期管理。
很多新手以为桥层就是两边各写一个函数互调,真在工程里跑起来才发现没那么简单。一套成熟的JSBridge,至少要管五件事:
- 能力注册:Native侧登记好哪些能力对外开放,哪些不开放
- 协议解析:把JS侧发来的原始消息解析成方法名、参数、回调标识
- 安全校验:校验来源、校验方法名是否在白名单、参数是否合法
- 任务分发:把方法名路由到对应的原生实现,执行后回传结果
- 生命周期管理:页面销毁时清理回调、移除注入的桥对象,防止内存泄漏
所以桥层是架构里最敏感的那一层。业务H5可以频繁迭代,原生能力可以慢慢加,但桥层一旦设计得混乱,后面每次加一个能力都要动公共代码,改出问题就全线爆雷。我见过不少项目最后桥层被塞了一堆临时方法,每个方法参数格式还不统一,前端每次对接都要翻半天旧代码,这种状态基本等于把地基里的承重墙挖空了一半。后面第3章的协议设计,就是为了从一开始避免这种混乱。
2. 核心原理拆解:JS调用Native的三种主流姿势
JS调到Native,业界主要就三种主流做法:URL Scheme拦截、注入JS全局对象、拦截JS弹窗。第三种属于历史遗留方案,现在用得少,但老项目里依然能见到,我把它放在最后补一句。前两种你必须在心里门儿清。
2.1 URL Scheme拦截:经典但坑不少
URL Scheme的方案,思路是让JS侧发起一个“假跳转”,原生侧在WebView的导航开始前把这个跳转拦下来,解析出方法名和参数,走原生逻辑。之所以能拦,是因为WebView在每次发生导航请求时都会先问原生侧一句“这个请求我要不要放行”,原生侧就在这个机会里做手脚。
JS侧最典型的载体是动态创建一个隐藏的iframe,让这个iframe去加载一个我们自定义协议的URL。为什么不用location.href?因为location.href会改变当前页面的导航状态,在SPA里一改就触发页面刷新或者路由变化,体验上没法接受。iframe只是发起了一个额外的子资源加载,不会影响当前页面,所以更适合当信使。
JS侧代码大概这样:
function callNative(method, params, callbackId) { const url = 'jsbridge://callNative' + '?method=' + encodeURIComponent(method) + '¶ms=' + encodeURIComponent(JSON.stringify(params || {})) + '&callbackId=' + callbackId; const iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.src = url; document.body.appendChild(iframe); // 等这次请求真正发出后移除 iframe,避免它在页面里堆积 requestAnimationFrame(() => { document.body.removeChild(iframe); }); }注意两个细节:params必须用encodeURIComponent包一层再拼进URL,不然中文和特殊字符在URL解析时会乱成一锅粥;iframe用完之后要移除,而且要等这次消息真正发出后再移除,移除早了有些安卓WebView会把请求一起取消掉。
Android端的拦截是在WebViewClient里重写shouldOverrideUrlLoading:
webView.setWebViewClient(new WebViewClient() { @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { if (url != null && url.startsWith("jsbridge://")) { handleBridgeUrl(url); return true; } return false; } });iOS老一代用的是UIWebViewDelegate的shouldStartLoadWithRequest,现在用WKWebView则是通过decidePolicyForNavigationAction,区别是苹果要求你必须在回调里返回一个decisionHandler,不返回就会造成导航卡住:
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if let url = navigationAction.request.url, url.scheme == "jsbridge" { handleBridgeURL(url) decisionHandler(.cancel) return } decisionHandler(.allow) }URL Scheme方案的优点非常明显:兼容性极好,老老小小的Android和iOS版本全支持,不依赖任何系统扩充API,只要是个WebView就能用。但缺点也致命:一是自定义协议是全局的,如果手机上另一个App也注册了同样的scheme,这里就可能存在被干扰甚至被劫持的风险;二是每次调用都等于发一次导航请求,高频调用时性能肉眼可见地变差;三是URL长度有上限,大参数根本塞不进去,动不动就被截断。所以这套方案现在更多是作为兜底通道存在,而不是主通道。
2.2 注入JS全局对象:现在的主流方案
注入JS全局对象是目前的主流,思路是Native侧把一套能力封装成一个JavaScript可调用的全局对象,直接注入到WebView的JS上下文里。业务JS就像调用本地库一样调这个对象的方法。
Android上实现这个靠的是addJavascriptInterface。在Android 4.2之后,被注入对象暴露给JS的方法必须加@JavascriptInterface注解,不加就不生效,这样做的目的是避免把Java类里所有public方法都暴露给JS,从根上封堵了早期那个著名的远程代码执行漏洞。
public class JsBridge { @JavascriptInterface public void callNative(String method, String params, String callbackId) { // 这里做分发 } } webView.addJavascriptInterface(new JsBridge(), "JSBridge");注入之后,JS侧直接用window.JSBridge.callNative(...)就能发起调用:
window.JSBridge.callNative('getDeviceInfo', '{}', 'cb_123');iOS这条链路比Android曲折一点。UIWebView时代,iOS能通过JavaScriptCore直接拿到JSContext,可以像Android那样往window上挂对象。但到了WKWebView,苹果出于安全和性能考量不再直接暴露JSContext给原生侧,而是提供了一套更规范的消息机制:WKUserContentController配合WKScriptMessageHandler。你需要提前注册一个消息处理器,然后JS侧通过window.webkit.messageHandlers来给它发消息。
let config = WKWebViewConfiguration() let userController = WKUserContentController() userController.add(self, name: "JSBridge") config.userContentController = userController // 用这个 config 创建 WKWebView收到消息的代理方法长这样:
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "JSBridge" else { return } if let body = message.body as? [String: Any] { let method = body["method"] as? String let params = body["params"] as? [String: Any] let callbackId = body["callbackId"] as? String // 走业务分发 } }JS侧对应的调用方式:
window.webkit.messageHandlers.JSBridge.postMessage({ method: 'getDeviceInfo', params: {}, callbackId: 'cb_123' });两种平台的实现形态不同,但本质一模一样:都是在JS全局上下文里挂一个Native开的“窗口”,JS往这个窗口扔消息,Native侧监听这个窗口收消息。它的好处是参数不受URL长度限制,性能比URL拦截好,也没有scheme冲突问题,还能在原生侧做统一的消息格式校验。所以新项目我建议优先走注入方案。
2.3 Native如何回调JS:完整链路怎么闭环
别忘了一件事:JS调用Native拿到的结果,最终还是要回到JS这边。Native拿不到JS函数的直接引用,所以只能反着来——通过一个全局JS方法把结果塞回去。
Android端用evaluateJavascript执行回调:
webView.evaluateJavascript( "window._onNativeCallback('cb_123', " + jsonResult + ")", null );iOS端用WKWebView的evaluateJavaScript执行同样的事:
webView.evaluateJavaScript("window._onNativeCallback('cb_123', \(jsonString))") { _, error in // error 为 nil 才代表执行成功 }把两边的动作拼起来看,一次完整调用的闭环是:
- JS侧生成一个唯一callbackId,把回调函数按照这个id存进本地Map
- JS侧把method、params、callbackId序列化后发到Native侧
- Native侧解析消息,反序列化参数,在原生线程或主线程执行对应能力(比如相册、定位)
- Native侧把执行结果序列化成JSON,通过evaluateJavascript执行JS全局回调入口
- JS侧全局回调入口根据callbackId找到之前存起来的回调函数,把结果交给它,然后从Map里删掉这个id
异步是这套链路里的主旋律。跨运行时通信根本不支持同步返回,你不可能让JS一调就立刻拿到Native的值。所以callbackId的设计是整个闭环的关键,它必须全局唯一,否则两个调用撞在一起,回调就会串线。常见做法是“前缀+时间戳+随机数”,碰撞概率极低。
到这里,核心原理基本通了。下面要说的是从原理到工程这一段路怎么走。
3. 从原理到落地:JSBridge协议设计与统一封装
3.1 消息格式和callbackId怎么定
原理懂了,落地第一步是定协议。协议不统一,后面所有工作都是白做。我在项目里最常用的一套消息格式是这样:
{ "version": 1, "method": "getDeviceInfo", "params": { "fields": ["os", "version", "network"] }, "callbackId": "cb_165000000001_482" }version是协议版本号,用于以后升级兼容。method是能力名,采用“动词+名词”的小驼峰命名,比如openCamera、getLocation、saveImage。params是JSON对象,放业务参数。callbackId是回调标识,调用方自己生成,回传时原样带回来。
为什么version必须放在第一优先级别?因为桥是会演进的,今天这个协议只传三个字段,明年可能加一个fields字段,甚至要改某些字段的类型。没有版本号,哪天较真的后端或前端客户端同事对不上旧数据,排查几个小时都不知道哪里变了。加个version,老线程靠它向下兼容,新线程靠它决定是否要升级,省掉大把撕扯。
参数侧有个非常隐蔽的坑:JS里一切“带有副类型”的值都别往桥里传。日期对象会被序列化成字符串,Function直接丢失,undefined在某些平台会变成null。跨运行时传输只能认JSON基础类型:字符串、数字、布尔、数组、普通对象。凡是需要传函数的地方,一律改成传callbackId,靠回调机制解决。这条规则写进团队规范里,能少踩一半的坑。
3.2 JS侧SDK怎么封装
协议定完,JS侧不要裸写一大堆window.JSBridge调用,而是要封装成一个统一SDK。前端和后端同事都要用同一个入口,不要在业务代码里到处散落平台判断。
一个能同时兼容Android注入和iOS消息通道的SDK核心,大致长这样:
const BridgeSDK = { _callbacks: {}, _nextCallbackId: 1000, call(method, params = {}, callback, timeout = 5000) { const callbackId = 'cb_' + Date.now() + '_' + (this._nextCallbackId++); if (typeof callback === 'function') { this._callbacks[callbackId] = callback; setTimeout(() => { if (this._callbacks[callbackId]) { delete this._callbacks[callbackId]; callback({ code: -1, message: 'bridge call timeout' }); } }, timeout); } // Android 注入方案 if (window.JSBridge && typeof window.JSBridge.callNative === 'function') { window.JSBridge.callNative(method, JSON.stringify(params || {}), callbackId); return; } // iOS WKWebView 消息通道 if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.JSBridge) { window.webkit.messageHandlers.JSBridge.postMessage({ method: method, params: params || {}, callbackId: callbackId }); return; } // 兜底:URL Scheme 拦截 this._invokeByIframe(method, params, callbackId); }, _invokeByIframe(method, params, callbackId) { const url = 'jsbridge://callNative?method=' + encodeURIComponent(method) + '¶ms=' + encodeURIComponent(JSON.stringify(params || {})) + '&callbackId=' + callbackId; const iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.src = url; document.body.appendChild(iframe); requestAnimationFrame(() => document.body.removeChild(iframe)); }, // 这个方法是 Native 端回传时执行的全局入口 _onNativeCallback(callbackId, result) { const cb = this._callbacks[callbackId]; if (cb) { cb(result || {}); delete this._callbacks[callbackId]; } } }; window.BridgeSDK = BridgeSDK; // Native 端执行 JS 时需要找到这个全局入口 window._onNativeCallback = (callbackId, result) => BridgeSDK._onNativeCallback(callbackId, result);这段代码里藏着两个细节。第一个是超时清理,不能省。Native端如果漏回传或者回调线程出问题,没有超时兜底,业务侧的Promise会一直pending,表现出来就是“页面转圈转到天荒地老”。第二个是callbackId递增计数器,配合时间戳能保证同一进程里不重号。
SDK里还应该把结果做一次统一包装,别让Native直接裸吐数据。我常用的返回格式是:code为0表示成功,非0表示业务错误,data放真正返回的数据,message放错误描述。这样前端只认这一套结构,不需要每个方法单独记返回格式。
3.3 Native侧如何分发处理
Native侧的核心是一个方法路由器,收到桥消息后按method分发到对应的处理器。不要用一坨switch-case堆在入口,会随着能力增多越来越难维护。
Android侧我用一个Map做方法注册表:
public class BridgeDispatcher { private final Map<String, BridgeHandler> handlers = new HashMap<>(); public void registerHandler(String method, BridgeHandler handler) { handlers.put(method, handler); } public void dispatch(String method, String params, BridgeCallback callback) { BridgeHandler handler = handlers.get(method); if (handler == null) { callback.fail("method not found: " + method); return; } handler.handle(params, callback); } public interface BridgeHandler { void handle(String params, BridgeCallback callback); } public interface BridgeCallback { void success(String jsonResult); void fail(String errorMessage); } }收到addJavascriptInterface传来的消息后,直接调dispatcher.dispatch(method, params, callback)。callback实现里再调evaluateJavascript回传结果。注意Android操作WebView必须在主线程,如果某个handler在子线程处理完逻辑,回传前要切回主线程,否则直接闪退或者崩溃日志查都查不到。
iOS侧我用字典+闭包,或者直接用Swift的枚举和协议做分发,思路一致。关键同样是把每个能力封装成独立模块,不要把所有能力堆在ViewController里。
统一封装的最终目标,是让业务侧只看到“BridgeSDK.call(方法名, 参数, 回调)”,完全不用关心底层是Android还是iOS、是注入还是URL拦截。这一层做好,后面加任何新能力都只是注册一个handler的事,不会到处埋雷。
4. 最佳实践:安全、性能与兼容性
4.1 安全防护:把桥守成一个门岗
JSBridge的本质是给JS开了一个口子去碰原生能力,这个口子开多大、对谁开,你必须心里有数。真的,安全这块既不能摆烂,也不能用力过猛到影响业务。
第一个底线是方法白名单。Native侧只注册明确开放的能力,method找不到对应handler时直接返回“method not found”,绝不能做“任意方法反射执行”这种事。早年有些项目图方便,把Java类的public方法全部映射给JS,等于直接把后门钥匙挂在了门口。
第二个底线是来源校验。如果业务里有WebView加载远程页面的场景,一定要能区分当前页面是否可信。不可信页面连相机、相册、通讯录这类敏感能力都不应该开放,或者至少要弹确认框让用户知情。这个校验不能只在前端做,必须Native侧也做一遍,因为前端代码可以被注入或篡改。
第三个底线是callbackId的防伪。低信任页面可能伪造callbackId来劫持结果。比较稳的做法是在Native侧维护一个PendingRequest池,调用发起时记录请求上下文,回传时校验callbackId确实存在且只被消费一次,消费完立刻移除。这样做还有一个附带好处:能够识别出“这个回调是不是伪造的”以及“这个回调是不是早就该超时了”。
还有一点要专门说:Android的addJavascriptInterface只加在需要的WebView上,页面销毁或容器不再使用时,主动调removeJavascriptInterface清理掉。iOS侧对应是userController.removeScriptMessageHandler(forName:),不然WebView释放时handler还指向Controller,内存泄漏稳得一批。这类泄漏不爆大问题,但统计App内存曲线时会让你非常难受。
4.2 性能优化:减少跨桥次数比优化单次更快
JSBridge的性能瓶颈不在单次消息传递的耗时,而在调用次数。一次跨运行时通信要走序列化、跨线程、执行回调几步,单次可能只要几毫秒,但一旦业务里高频埋点、疯狂轮询地调,WebView的JS线程和原生消息队列就会被灌满,整个页面跟着卡。
所以性能优化的第一原则是:能合并不合并,能批量的不单发。上报埋点是最典型的场景,H5里的点击事件如果每条都单独调一次桥,高峰期一秒钟可能几百条消息。正确做法是JS侧攒一批,比如每隔5秒或者攒满20条后打包成一个数组,一次性传给Native,Native端解包后逐条处理。这样总时间可能差不多,但对主线程的压力完全是两个量级。
第二原则是减少序列化开销。params里能少传字段就少传字段,别每次把一个几十KB的对象整体扔过去。JSON.stringify在低端安卓机上的性能差距非常明显,别在小字段上浪费。
第三原则是选择正确的底层通道。同一个项目里如果同时存在注入和URL拦截两套通道,优先走注入,URL拦截只当兜底。iOS端能上WKWebView就尽量别留UIWebView,前者对JS执行和跨桥消息的优化提升非常明显,尤其是页面复杂并且桥调用频繁的场景下,差距一度能达到几倍。
最后一个容易被忽略的点:JS侧的Promise包装不是越花哨越好。原生只认callbackId回传,Promise只是SDK内部的封装形式。如果项目里Promise相关代码在低版本WebView上报错,先排查是不是SDK的Promise实现有问题,而不是把锅甩给桥层。
4.3 兼容性:一张表看清Android和iOS的差异
JSBridge的实现细节在不同的平台、不同系统版本上存在不少差异,我直接列一张表方便大家对照:
| 能力 | Android方案 | iOS方案 |
|---|---|---|
| JS调Native主体 | addJavascriptInterface,4.2起必须加@JavascriptInterface注解 | WKWebView只管WKScriptMessageHandler,UIWebView时代JavaScriptCore直接注入 |
| 发消息姿势 | window.JSBridge.callNative(...) | window.webkit.messageHandlers.JSBridge.postMessage(...) |
| URL拦截位置 | WebViewClient.shouldOverrideUrlLoading | WKNavigationDelegate.decidePolicyForNavigationAction |
| Native执行JS | evaluateJavascript | evaluateJavaScript |
| 回调线程要求 | 必须在主线程操作WebView | 回调直接在WebKit线程,注意切主线程 |
| 清理时机 | removeJavascriptInterface | removeScriptMessageHandler(forName:) |
老项目的兼容性还要考虑Android WebView内核碎片化。厂商定制系统、系统WebView版本不一,注入API在某些旧内核上可能不稳定,实际工程里经常是“注入为主 + URL拦截兜底”两套并存。SDK内部封装时自动降级,这套思路我在第3章的代码里已经演示过。
iOS端老项目还可能出现UIWebView和WKWebView并存的日子,两套拦截机制完全不同,SDK内部必须做平台分离。我建议这种过渡期不要写两套独立SDK,而是用一个抽象接口,UIWebView和WKWebView各实现一份适配器,业务侧调用的还是同一个入口。否则等全面切到WKWebView那天,你会发现所有调用桥的地方都要跟着改,那就非常酸爽了。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
JSBridge相关的问题,线上排查时往往不是原理层面的问题,而是具体平台行为差异导致的“奇怪现象”。我把这些年默倒过的高频问题整理成速查表,按图索骥会快很多:
| 问题现象 | 可能原因 | 排查步骤 | 推荐解法 |
|---|---|---|---|
| 桥调用无反应,集中在部分Android机型 | iframe发起URL请求被内核吞掉,或iframe被提前移除 | 真机调试看WebConsole,确认URL是否发出 | URL拦截兜底时改用location.href加定时器还原,或直接切注入通道 |
| 调用桥后页面白屏卡住 | WKWebView的decisionHandler未及时调用,或桥调用和页面主导航冲突 | 检查导航代理方法是否每次都被执行到了;看系统日志 | 确保decidePolicyForNavigationAction里所有分支都调用了decisionHandler,返回allowed或cancel都不能漏 |
| 中文参数乱码 | URL拼接时没做encodeURIComponent | 在Native侧打印原始url | 统一用encodeURIComponent包参数,不要手动拼字符串 |
| 回调永远不触发 | Native端没执行evaluateJavascript,或页面早已销毁 | 在Native回传处打日志,JS侧打全局回调日志 | 回调走主线程;页面销毁时主动清理回调;JS侧加超时兜底 |
| 第二次调用失效 | iframe对象未重新创建,或上次iframe没被移除 | 断点看DOM里是否堆积iframe | 每次调用都创建新iframe并确保移除,建议用requestAnimationFrame包裹remove操作 |
| Android 4.2以下调用失败 | 注入方案不可用,或者老内核不支持@JavascriptInterface | 确认系统版本和内核版本 | 老系统自动降级到URL拦截 |
| iOS消息通道收不到消息 | WKUserContentController注册名和JS侧名称不一致,或提前移除过handler | 检查注册名称和控制台是否报handler not found | 确认两边的注册名完全一致 |
这张表不是用来收藏的,建议直接贴进团队Wiki。很多问题第一次排查要花半天,第二次看表十分钟就能定位。
5.2 两个经典案例的排查过程
案例一:某Android设备上JS调用无响应,其它机型都正常。当时页面用的是iframe发URL拦截请求,前端同事排查了很久,发现只有一部分出厂版本很老的机器有问题,而且不是偶发,是必然失败。
定位过程分三步走:先在Native侧打日志,确认shouldOverrideUrlLoading到底有没有被触发——结果没触发,说明请求根本没到Native。再在JS侧打日志,确认iframe确实挂到了DOM上。对比正常机型后发现问题出在移除时机上:代码原本是在创建iframe之后立刻用setTimeout移除,时延设置得太短,这台老内核在异步任务执行前就把请求取消了,iframe还没来得及发起加载就被干掉。
解决办法是改用requestAnimationFrame包住移除操作,给内核留一个渲染帧的调度时间,让请求真正发出后再清理DOM。改完问题消失。后来我把所有类似操作都统一成这个节奏,早期不少“偶发无响应”都是这个坑。
案例二:iOS一个页面上,前几次桥调用正常,频繁操作之后回调开始出现错乱,甚至把上一次的结果交给下一次的调用。查日志发现callbackId大量重复,而且页面每次跳转后没有清空旧的回调缓存。
原因拆开看有两点:第一是当年SDK里用Math.random生成callbackId,理论上随机数可能重复,频率低时没暴露,一旦高频调用就撞车;第二是页面路由跳转后,旧页面的回调Map还留在内存里没有清理,Native回传时误匹配到已经废弃的页面回调。
修法也很直接:callbackId改成“时间戳+自增计数器”,保证单进程内不重号;页面卸载时调用SDK的destroy方法,清理所有pending回调并按约定好的格式通知业务层“这些调用已失效”。从此这类错乱问题再也没出现过。
写在后面的一些经验
原理、代码和坑都讲完了,最后想说点实在话。JSBridge这层东西,看起来就是几十行代码的事,真正难的是在两个运行时之间长期维护一套稳定、安全、可演进的协议。我个人的体会是:越早把协议定好,越早用统一SDK收口,后面加能力、改逻辑就越轻松;反过来,如果一开始图省事,每个业务自己写字符串拼接,等桥多了再回头治理,代价至少是重写一遍所有调用方。
另外一个小建议:给桥层单独加一份操作日志,线上打开开关能看到每次调用的method、params大小、耗时和result。排查问题时这份日志能救你无数次,比事后猜“是不是桥没调通”高效得多。这个开关记得只在debug或白名单用户上开,别全量打日志拖垮性能。
JSBridge没有多少花哨的高级技巧,它考验的是对通信本质的理解和工程上的克制。把本章节这套协议和封装方式消化掉,落地时再对照速查表规避,基本就不会被这层坑拦住了。