iframe 与 webview ,记录一次使用 jsBridge 遇到的 bug 解决过程

前提-出现场景

  1. 使用机型为 Android 9,API 28
  2. 使用的 jsBridge 为 link

bug 描述

在页面加载先后若是连续屡次调用原生的方法,会遇到回调参数未被调用的状况。javascript

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

复制代码

bug 的稳定复现方式

在页面加载时经过jsBridge和原生进行10次以上的数据交换。html

出现的缘由

查询所得

在多篇文章(1,2)中看到是由于 jsBridge 使用 iframe 的 src 变化 和 shouldOverrideUrlLoading 来实现原生与js的沟通致使的问题,而刷新 iframe 并不能保证 shouldOverrideUrlLoading 会被调用java

因而咱们以此为假设进行验证android

  • 验证1: jsBridge 是否使用 iframe.src 的变化来进行js与原生的通信git

    咱们能够直接看看进行一次完整的通信的调用过程。github

//依据调用链 
 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,完成一次通信web

验证1证明完毕。api

  • 验证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,一个完整的通信过程,其调用时序图以下:

jsBridge时序图

回顾咱们最初遇到的问题,屡次调用 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),且将之后一次调用时的参数为准。

webview的输出

原生的输

上述图示是一次失败通信的日志,能够看到,前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 上….固然个人方案不是最好的,若是你有更好的方案,欢迎留言。

相关文章
相关标签/搜索