window.WebViewJavascriptBridge.callHandler(api, parameter, callback);
在页面加载前后如果连续多次调用原生的方法,会遇到回调参数未被调用的情况。
// 多次调用如下函数, 部分 callback 将不会被调用 window.WebViewJavascriptBridge.callHandler(api, parameter, callback);
在页面加载时通过jsBridge和原生进行10次以上的数据交换。
在多篇文章(1,2)中看到是因为 jsBridge 使用 iframe 的 src 变化 和 shouldOverrideUrlLoading 来实现原生与js的沟通导致的问题,而刷新 iframe 并不能保证 shouldOverrideUrlLoading 会被调用。
于是我们以此为假设进行验证
验证1: jsBridge 是否使用 iframe.src 的变化来进行js与原生的通讯
我们可以直接看看进行一次完整的通讯的调用过程。
//依据调用链 window.WebViewJavascriptBridge.callHandler(api, parameter, callback); function callHandler(handlerName, data, responseCallback) { _doSend( { handlerName: handlerName, data: data }, responseCallback ); } function _doSend(message, responseCallback) { if (responseCallback) { var callbackId = "cb_" + uniqueId++ + "_" + new Date().getTime(); responseCallbacks[callbackId] = responseCallback; message.callbackId = callbackId; } sendMessageQueue.push(message); //改变html内的iframe的src messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + "://" + QUEUE_HAS_MESSAGE; } // 此时步骤转到原生层面
// shouldOverrideUrlLoading 将在 iframe.src 改变时被调用 public boolean shouldOverrideUrlLoading(WebView view, String urlString) { super.shouldOverrideUrlLoading(view, urlString); if (PhoneUtil.INSTANCE.startTelActivity(getActivity(), urlString)) return true; if (mWebViewHelper.shouldOverrideUrlLoading(view, urlString)) return true; return false; } //父类的 shouldOverrideUrlLoading public boolean shouldOverrideUrlLoading(WebView view, String url) { try { url = URLDecoder.decode(url, "UTF-8"); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } // 根据 url 的内容,区分是哪种类型的操作 // 事实上 只有 YY_RETURN_DATA 和 YY_OVERRIDE_SCHEMA 两种 // YY_RETURN_DATA 根据 url 的 参数,返回数据,即原生备好数据后调用 js 原生方法(js 的回调函数) // YY_OVERRIDE_SCHEMA 则注入脚本到 webview 调用 js 原生方法 _fetchQueue if (url.startsWith(BridgeUtil.YY_RETURN_DATA)) { webView.handlerReturnData(url); return true; } else if (url.startsWith(BridgeUtil.YY_OVERRIDE_SCHEMA)) { // webView.flushMessageQueue(); return true; } else { return super.shouldOverrideUrlLoading(view, url); } } //通讯结束
// YY_OVERRIDE_SCHEMA 类型通讯所调用的原生方法 function _fetchQueue() { var messageQueueString = JSON.stringify(sendMessageQueue); console.warn(++count, "-", messageQueueString); sendMessageQueue = []; //android can't read directly the return data, //so we can reload iframe src to communicate with java messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + "://return/_fetchQueue/" + encodeURIComponent(messageQueueString); }
从源码可以看出,一个完整的通讯过程,将改变两次 src,也就是说 shouldOverrideUrlLoading 会被调用两次(预计)。@q说来 jsBridge 设计也奇怪,为什么不设计成一次 src,完成一次通讯。
验证1证实完毕。
验证2:iframe 改变 src 是否与 shouldOverrideUrlLoading 调用次数一致。
我在 WebViewJavascriptBridge.js 中对 ifram.src 的变化 和 BasewebviewFragment.java 的 shouldOverrideUrlLoading 调用进行计数,发现两边的次数确实不一致。
通讯状态 | iframe 的 src 改变次数 | shouldOverrideUrlLoading 被调用次数 |
---|---|---|
预计 | 18 | 18 |
T | 13 | 9 |
T | 17 | 14 |
T | 13 | 6 |
F | 17 | 18 |
F | 6 | 3 |
T | 11 | 8 |
验证2 证实完毕。
同时我们也得知,就算二者调用次数不一致,也不影响 js 与 native 的通讯,几次通讯成功的情况二者的次数都不一致,甚至我们可以初步预测,二者的次数根本不需要一致就能实现通讯。
@q 那么通讯成功的充分必要条件是什么呢?
回顾我们之前所做的验证1,一个完整的通讯过程,其调用时序图如下:
回顾我们最初遇到的问题,多次调用 callHandler 后,部分 callback 没有被调用,导致通讯失败。
根据流程图逆行推理, callback 未被调用 => 表示携带该callback 的 respMessage 未被传递过来,也就是说 yy://return/ ${resp} 缺失了 => _fetchQueue 传递的数据有缺失
function _fetchQueue() { var messageQueueString = JSON.stringify(sendMessageQueue); // ATENTION 这里在将 string 化后立即清空了当前的 messageQueue sendMessageQueue = []; messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + "://return/_fetchQueue/" + encodeURIComponent(messageQueueString); }
从 _fetchQueue 的源码中,发现在将 message 传递后就立马清空了,实际上这并不准确,因为连续N次改变 iframe 的 src ,shouldOverrideUrlLoading 的实际调用次数为 M(M<N),且将后一次调用时的参数为准。
上述图示是一次失败通讯的日志,可以看到,前6次调用为 _doSend 的调用,即改变了 6次 iframe 的 src,但实际上只有两次生效了,第一次生效的通讯调用了 _fetchQueue ,传递前 6 次的 message 给 native,但是由于清空了 message 队列,紧跟的第二次 _fetchQueue 执行时传递空数组给 native ,又因为两次 _fetchQueue 的调用间隔太短,实际上只有第二次 _fetchQueue 的调用传递给了 native ,此时 native 只收到一个 空数组的 通讯,自然没有了后续的操作。
所以我们最初 callHandler 里的 callback,都没人再调用了...
原因已经明了,当前的问题是如何解决。切入点有以下几个,
鉴于1的实施难度对我这个切图仔来说有点大,优先考虑后续两个解决方法。
修改 _fetchQueue 函数
function _fetchQueue() { if (sendMessageQueue.length === 0 || fetchingQueueLength > 0) { return; } // 记录当前等待 native 响应的个数 fetchingQueueLength += sendMessageQueue.length; var messageQueueString = JSON.stringify(sendMessageQueue); sendMessageQueue = []; //android can't read directly the return data, so we can reload iframe src to communicate with java bizMessagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://return/_fetchQueue/' + encodeURIComponent(messageQueueString); } /* ... */ function _dispatchMessageFromNative(messageJSON) { setTimeout(function() { var message = JSON.parse(messageJSON); fetchingQueueLength--; // 如果通讯完毕,清理被阻塞的 message if (fetchingQueueLength === 0) { // 使用 sto,在当前的通讯结束后再 _fetchQueue setTimeout(function() { _fetchQueue(); }); } ...
以私有变量 fetchingQueueLength 记录等待响应的 message 数量,但是存在队首阻塞的问题,甚至因为没保证所以没采用。
既然是因为 _fetchQueue 调用间隔太短,所以就采用了切图仔常用的节流方案。
var lastCallTime = 0; var stoId = null; var FETCH_QUEUE = 20; function _fetchQueue() { // 空数组直接返回 if (sendMessageQueue.length === 0) { return; } if (new Date().getTime() - lastCallTime < FETCH_QUEUE) { if (!stoId) { stoId = setTimeout(_fetchQueue, FETCH_QUEUE); } return; } lastCallTime = new Date().getTime(); stoId = null; var messageQueueString = JSON.stringify(sendMessageQueue); sendMessageQueue = []; //android can't read directly the return data, so we can reload iframe src to communicate with java bizMessagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://return/_fetchQueue/' + encodeURIComponent(messageQueueString); }
这个 20 ms,其实我是有些随意的定义的,从 200 开始向下试验,20 是我觉得比较稳定一个数字… 。20 ms 内连续的调用 _fetchQueue 将只有一次生效,回顾之前通讯流程的同学应该知道 _fetchQueue 的触发是依靠 native 的调用的,所以 _fetchQueue 的触发对 _doSend 来说是异步的,所以并不需要一一对应,_doSend 只是往 sendMessageQueue 里添加任务,而 _fetchQueue 只负责将 sendMessageQueue 里的任务清空,只要保证至少有一个 _fetchQueue 晚于 _doSend 执行即可。
但是这里改动 WebViewJavascriptBridge.js 是需要重新发包的。
修改 js 调用时的函数
这个其实有点难处理,因为是在 js 层面,这里解决的点仍然是 2. 中的 _fetchQueue 调用频繁的问题,从这个角度切入有点隔山打牛的意味。但是因为改动只在页面,不依赖原生发包,所以在某些场景也适用。
这里的思想类似,封装 callHandler 函数,节流或者串行均可,当然串行就会有阻塞的可能,节流,这里的节流是想让 _fetchQueue 的调用节流,但是 _fetchQueue 的触发毕竟是异步,而且掌控在原生代码那边,所有其实不太推荐适用这个方案。
纵观整个通讯过程,其实就是一个网络协议的缩影。最开始考虑部分通讯失败的问题时,想的这是不是就是网络里的丢包,想想 TCP 怎么解决丢包的,好像是记录字节序 + 定时器,但是这里响应体只包含通讯内容,光是标记请求就有点麻烦了,再加上定时器...如果要改就是大重构了…算了;后来开始针对 _fetchQueue ,要不就考虑学 HTTP 一来一回吧,但是这样效率太低了,js 单线程也没有并发,而且还有队首阻塞的问题… 后来转而一想,既然 fetchQueue 间隔短,那我控制间隔不就好了吗…于是引入了节流的方案… 变动小代码简单易懂…虽然这个 20ms 不太具有事实依据性。
总的来说解决问题并不难,难得是找到问题的核心,为了这个我甚至找了原生开发小哥 copy 一份源码…,好在之前有过 RN 调试经验… 不至于卡在配置 android studio 上….当然我的方案不是最好的,如果你有更好的方案,欢迎留言。