window.WebViewJavascriptBridge.callHandler 偶现失败

澹台举
2023-12-01
window.WebViewJavascriptBridge.callHandler(api, parameter, callback);

bug 描述

在页面加载前后如果连续多次调用原生的方法,会遇到回调参数未被调用的情况。

// 多次调用如下函数, 部分 callback 将不会被调用
window.WebViewJavascriptBridge.callHandler(api, parameter, callback);

bug 的稳定复现方式

在页面加载时通过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 被调用次数
    预计1818
    T139
    T1714
    T136
    F1718
    F63
    T118

    验证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. 查清为什么多次 iframe.src 变化只调用更少次数的 shouldOverrideUrlLoading,并解决...
  2. 修改 _fetchQueue 函数
  3. js 在调用时只能线性调用

鉴于1的实施难度对我这个切图仔来说有点大,优先考虑后续两个解决方法。

修改 _fetchQueue 函数

  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 数量,但是存在队首阻塞的问题,甚至因为没保证所以没采用。

  1. 既然是因为 _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 上….当然我的方案不是最好的,如果你有更好的方案,欢迎留言。

 类似资料: